1import os
2import platform
3import re
4import shutil
5import stat
6import errno
7import subprocess
8import tempfile
9from abc import ABCMeta, abstractmethod
10from datetime import datetime, timedelta
11from distutils.spawn import find_executable
12
13from six.moves.urllib.parse import urlsplit
14import requests
15
16from .utils import call, get, untar, unzip
17
18uname = platform.uname()
19
20# the rootUrl for the firefox-ci deployment of Taskcluster
21FIREFOX_CI_ROOT_URL = 'https://firefox-ci-tc.services.mozilla.com'
22
23
24def _get_fileversion(binary, logger=None):
25    command = "(Get-Item '%s').VersionInfo.FileVersion" % binary.replace("'", "''")
26    try:
27        return call("powershell.exe", command).strip()
28    except (subprocess.CalledProcessError, OSError):
29        if logger is not None:
30            logger.warning("Failed to call %s in PowerShell" % command)
31        return None
32
33
34def handle_remove_readonly(func, path, exc):
35    excvalue = exc[1]
36    if func in (os.rmdir, os.remove) and excvalue.errno == errno.EACCES:
37        os.chmod(path, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)  # 0777
38        func(path)
39    else:
40        raise
41
42
43def get_ext(filename):
44    """Get the extension from a filename with special handling for .tar.foo"""
45    name, ext = os.path.splitext(filename)
46    if name.endswith(".tar"):
47        ext = ".tar%s" % ext
48    return ext
49
50
51def get_taskcluster_artifact(index, path):
52    TC_INDEX_BASE = FIREFOX_CI_ROOT_URL + "/api/index/v1/"
53
54    resp = get(TC_INDEX_BASE + "task/%s/artifacts/%s" % (index, path))
55    resp.raise_for_status()
56
57    return resp
58
59
60class Browser(object):
61    __metaclass__ = ABCMeta
62
63    def __init__(self, logger):
64        self.logger = logger
65
66    @abstractmethod
67    def download(self, dest=None, channel=None, rename=None):
68        """Download a package or installer for the browser
69        :param dest: Directory in which to put the dowloaded package
70        :param channel: Browser channel to download
71        :param rename: Optional name for the downloaded package; the original
72                       extension is preserved.
73        """
74        return NotImplemented
75
76    @abstractmethod
77    def install(self, dest=None):
78        """Install the browser."""
79        return NotImplemented
80
81    @abstractmethod
82    def install_webdriver(self, dest=None, channel=None, browser_binary=None):
83        """Install the WebDriver implementation for this browser."""
84        return NotImplemented
85
86    @abstractmethod
87    def find_binary(self, venv_path=None, channel=None):
88        """Find the binary of the browser.
89
90        If the WebDriver for the browser is able to find the binary itself, this
91        method doesn't need to be implemented, in which case NotImplementedError
92        is suggested to be raised to prevent accidental use.
93        """
94        return NotImplemented
95
96    @abstractmethod
97    def find_webdriver(self, channel=None):
98        """Find the binary of the WebDriver."""
99        return NotImplemented
100
101    @abstractmethod
102    def version(self, binary=None, webdriver_binary=None):
103        """Retrieve the release version of the installed browser."""
104        return NotImplemented
105
106    @abstractmethod
107    def requirements(self):
108        """Name of the browser-specific wptrunner requirements file"""
109        return NotImplemented
110
111
112class Firefox(Browser):
113    """Firefox-specific interface.
114
115    Includes installation, webdriver installation, and wptrunner setup methods.
116    """
117
118    product = "firefox"
119    binary = "browsers/firefox/firefox"
120    requirements = "requirements_firefox.txt"
121
122    platform = {
123        "Linux": "linux",
124        "Windows": "win",
125        "Darwin": "macos"
126    }.get(uname[0])
127
128    application_name = {
129        "stable": "Firefox.app",
130        "beta": "Firefox.app",
131        "nightly": "Firefox Nightly.app"
132    }
133
134    def platform_string_geckodriver(self):
135        if self.platform is None:
136            raise ValueError("Unable to construct a valid Geckodriver package name for current platform")
137
138        if self.platform in ("linux", "win"):
139            bits = "64" if uname[4] == "x86_64" else "32"
140        else:
141            bits = ""
142
143        return "%s%s" % (self.platform, bits)
144
145    def _get_dest(self, dest, channel):
146        if dest is None:
147            # os.getcwd() doesn't include the venv path
148            dest = os.path.join(os.getcwd(), "_venv")
149
150        dest = os.path.join(dest, "browsers", channel)
151
152        if not os.path.exists(dest):
153            os.makedirs(dest)
154
155        return dest
156
157    def download(self, dest=None, channel="nightly", rename=None):
158        product = {
159            "nightly": "firefox-nightly-latest-ssl",
160            "beta": "firefox-beta-latest-ssl",
161            "stable": "firefox-latest-ssl"
162        }
163
164        os_builds = {
165            ("linux", "x86"): "linux",
166            ("linux", "x86_64"): "linux64",
167            ("win", "x86"): "win",
168            ("win", "AMD64"): "win64",
169            ("macos", "x86_64"): "osx",
170        }
171        os_key = (self.platform, uname[4])
172
173        if dest is None:
174            dest = self._get_dest(None, channel)
175
176        if channel not in product:
177            raise ValueError("Unrecognised release channel: %s" % channel)
178
179        if os_key not in os_builds:
180            raise ValueError("Unsupported platform: %s %s" % os_key)
181
182        url = "https://download.mozilla.org/?product=%s&os=%s&lang=en-US" % (product[channel],
183                                                                             os_builds[os_key])
184        self.logger.info("Downloading Firefox from %s" % url)
185        resp = get(url)
186
187        filename = None
188
189        content_disposition = resp.headers.get('content-disposition')
190        if content_disposition:
191            filenames = re.findall("filename=(.+)", content_disposition)
192            if filenames:
193                filename = filenames[0]
194
195        if not filename:
196            filename = urlsplit(resp.url).path.rsplit("/", 1)[1]
197
198        if not filename:
199            filename = "firefox.tar.bz2"
200
201        if rename:
202            filename = "%s%s" % (rename, get_ext(filename))
203
204        installer_path = os.path.join(dest, filename)
205
206        with open(installer_path, "wb") as f:
207            f.write(resp.content)
208
209        return installer_path
210
211    def install(self, dest=None, channel="nightly"):
212        """Install Firefox."""
213        import mozinstall
214
215        dest = self._get_dest(dest, channel)
216
217        filename = os.path.basename(dest)
218
219        installer_path = self.download(dest, channel)
220
221        try:
222            mozinstall.install(installer_path, dest)
223        except mozinstall.mozinstall.InstallError:
224            if self.platform == "macos" and os.path.exists(os.path.join(dest, self.application_name.get(channel, "Firefox Nightly.app"))):
225                # mozinstall will fail if nightly is already installed in the venv because
226                # mac installation uses shutil.copy_tree
227                mozinstall.uninstall(os.path.join(dest, self.application_name.get(channel, "Firefox Nightly.app")))
228                mozinstall.install(filename, dest)
229            else:
230                raise
231
232        os.remove(installer_path)
233        return self.find_binary_path(dest)
234
235    def find_binary_path(self, path=None, channel="nightly"):
236        """Looks for the firefox binary in the virtual environment"""
237
238        if path is None:
239            # os.getcwd() doesn't include the venv path
240            path = os.path.join(os.getcwd(), "_venv", "browsers", channel)
241
242        binary = None
243
244        if self.platform == "linux":
245            binary = find_executable("firefox", os.path.join(path, "firefox"))
246        elif self.platform == "win":
247            import mozinstall
248            try:
249                binary = mozinstall.get_binary(path, "firefox")
250            except mozinstall.InvalidBinary:
251                # ignore the case where we fail to get a binary
252                pass
253        elif self.platform == "macos":
254            binary = find_executable("firefox", os.path.join(path, self.application_name.get(channel, "Firefox Nightly.app"),
255                                                             "Contents", "MacOS"))
256
257        return binary
258
259    def find_binary(self, venv_path=None, channel="nightly"):
260        if venv_path is None:
261            venv_path = os.path.join(os.getcwd(), "_venv")
262
263        path = os.path.join(venv_path, "browsers", channel)
264        binary = self.find_binary_path(path, channel)
265
266        if not binary and self.platform == "win":
267            winpaths = [os.path.expandvars("$SYSTEMDRIVE\\Program Files\\Mozilla Firefox"),
268                        os.path.expandvars("$SYSTEMDRIVE\\Program Files (x86)\\Mozilla Firefox")]
269            for winpath in winpaths:
270                binary = self.find_binary_path(winpath, channel)
271                if binary is not None:
272                    break
273
274        if not binary and self.platform == "macos":
275            macpaths = ["/Applications/Firefox Nightly.app/Contents/MacOS",
276                        os.path.expanduser("~/Applications/Firefox Nightly.app/Contents/MacOS"),
277                        "/Applications/Firefox Developer Edition.app/Contents/MacOS",
278                        os.path.expanduser("~/Applications/Firefox Developer Edition.app/Contents/MacOS"),
279                        "/Applications/Firefox.app/Contents/MacOS",
280                        os.path.expanduser("~/Applications/Firefox.app/Contents/MacOS")]
281            return find_executable("firefox", os.pathsep.join(macpaths))
282
283        if binary is None:
284            return find_executable("firefox")
285
286        return binary
287
288    def find_certutil(self):
289        path = find_executable("certutil")
290        if path is None:
291            return None
292        if os.path.splitdrive(os.path.normcase(path))[1].split(os.path.sep) == ["", "windows", "system32", "certutil.exe"]:
293            return None
294        return path
295
296    def find_webdriver(self, channel=None):
297        return find_executable("geckodriver")
298
299    def get_version_and_channel(self, binary):
300        version_string = call(binary, "--version").strip()
301        m = re.match(r"Mozilla Firefox (\d+\.\d+(?:\.\d+)?)(a|b)?", version_string)
302        if not m:
303            return None, "nightly"
304        version, status = m.groups()
305        channel = {"a": "nightly", "b": "beta"}
306        return version, channel.get(status, "stable")
307
308    def get_profile_bundle_url(self, version, channel):
309        if channel == "stable":
310            repo = "https://hg.mozilla.org/releases/mozilla-release"
311            tag = "FIREFOX_%s_RELEASE" % version.replace(".", "_")
312        elif channel == "beta":
313            repo = "https://hg.mozilla.org/releases/mozilla-beta"
314            major_version = version.split(".", 1)[0]
315            # For beta we have a different format for betas that are now in stable releases
316            # vs those that are not
317            tags = get("https://hg.mozilla.org/releases/mozilla-beta/json-tags").json()["tags"]
318            tags = {item["tag"] for item in tags}
319            end_tag = "FIREFOX_BETA_%s_END" % major_version
320            if end_tag in tags:
321                tag = end_tag
322            else:
323                tag = "tip"
324        else:
325            repo = "https://hg.mozilla.org/mozilla-central"
326            # Always use tip as the tag for nightly; this isn't quite right
327            # but to do better we need the actual build revision, which we
328            # can get if we have an application.ini file
329            tag = "tip"
330
331        return "%s/archive/%s.zip/testing/profiles/" % (repo, tag)
332
333    def install_prefs(self, binary, dest=None, channel=None):
334        if binary:
335            version, channel_ = self.get_version_and_channel(binary)
336            if channel is not None and channel != channel_:
337                # Beta doesn't always seem to have the b in the version string, so allow the
338                # manually supplied value to override the one from the binary
339                self.logger.warning("Supplied channel doesn't match binary, using supplied channel")
340            elif channel is None:
341                channel = channel_
342        else:
343            version = None
344
345        if dest is None:
346            dest = os.curdir
347
348        dest = os.path.join(dest, "profiles", channel)
349        if version:
350            dest = os.path.join(dest, version)
351        have_cache = False
352        if os.path.exists(dest) and len(os.listdir(dest)) > 0:
353            if channel != "nightly":
354                have_cache = True
355            else:
356                now = datetime.now()
357                have_cache = (datetime.fromtimestamp(os.stat(dest).st_mtime) >
358                              now - timedelta(days=1))
359
360        # If we don't have a recent download, grab and extract the latest one
361        if not have_cache:
362            if os.path.exists(dest):
363                shutil.rmtree(dest)
364            os.makedirs(dest)
365
366            url = self.get_profile_bundle_url(version, channel)
367
368            self.logger.info("Installing test prefs from %s" % url)
369            try:
370                extract_dir = tempfile.mkdtemp()
371                unzip(get(url).raw, dest=extract_dir)
372
373                profiles = os.path.join(extract_dir, os.listdir(extract_dir)[0], 'testing', 'profiles')
374                for name in os.listdir(profiles):
375                    path = os.path.join(profiles, name)
376                    shutil.move(path, dest)
377            finally:
378                shutil.rmtree(extract_dir)
379        else:
380            self.logger.info("Using cached test prefs from %s" % dest)
381
382        return dest
383
384    def _latest_geckodriver_version(self):
385        """Get and return latest version number for geckodriver."""
386        # This is used rather than an API call to avoid rate limits
387        tags = call("git", "ls-remote", "--tags", "--refs",
388                    "https://github.com/mozilla/geckodriver.git")
389        release_re = re.compile(r".*refs/tags/v(\d+)\.(\d+)\.(\d+)")
390        latest_release = (0,0,0)
391        for item in tags.split("\n"):
392            m = release_re.match(item)
393            if m:
394                version = tuple(int(item) for item in m.groups())
395                if version > latest_release:
396                    latest_release = version
397        assert latest_release != (0,0,0)
398        return "v%s.%s.%s" % tuple(str(item) for item in latest_release)
399
400    def install_webdriver(self, dest=None, channel=None, browser_binary=None):
401        """Install latest Geckodriver."""
402        if dest is None:
403            dest = os.getcwd()
404
405        if channel == "nightly":
406            path = self.install_geckodriver_nightly(dest)
407            if path is not None:
408                return path
409            else:
410                self.logger.warning("Nightly webdriver not found; falling back to release")
411
412        version = self._latest_geckodriver_version()
413        format = "zip" if uname[0] == "Windows" else "tar.gz"
414        self.logger.debug("Latest geckodriver release %s" % version)
415        url = ("https://github.com/mozilla/geckodriver/releases/download/%s/geckodriver-%s-%s.%s" %
416               (version, version, self.platform_string_geckodriver(), format))
417        if format == "zip":
418            unzip(get(url).raw, dest=dest)
419        else:
420            untar(get(url).raw, dest=dest)
421        return find_executable(os.path.join(dest, "geckodriver"))
422
423    def install_geckodriver_nightly(self, dest):
424        self.logger.info("Attempting to install webdriver from nightly")
425
426        platform_bits = ("64" if uname[4] == "x86_64" else
427                         ("32" if self.platform == "win" else ""))
428        tc_platform = "%s%s" % (self.platform, platform_bits)
429
430        archive_ext = ".zip" if uname[0] == "Windows" else ".tar.gz"
431        archive_name = "public/geckodriver%s" % archive_ext
432
433        try:
434            resp = get_taskcluster_artifact(
435                "gecko.v2.mozilla-central.latest.geckodriver.%s" % tc_platform,
436                archive_name)
437        except Exception:
438            self.logger.info("Geckodriver download failed")
439            return
440
441        if archive_ext == ".zip":
442            unzip(resp.raw, dest)
443        else:
444            untar(resp.raw, dest)
445
446        exe_ext = ".exe" if uname[0] == "Windows" else ""
447        path = os.path.join(dest, "geckodriver%s" % exe_ext)
448
449        self.logger.info("Extracted geckodriver to %s" % path)
450
451        return path
452
453    def version(self, binary=None, webdriver_binary=None):
454        """Retrieve the release version of the installed browser."""
455        version_string = call(binary, "--version").strip()
456        m = re.match(r"Mozilla Firefox (.*)", version_string)
457        if not m:
458            return None
459        return m.group(1)
460
461
462class FirefoxAndroid(Browser):
463    """Android-specific Firefox interface."""
464
465    product = "firefox_android"
466    requirements = "requirements_firefox.txt"
467
468    def download(self, dest=None, channel=None, rename=None):
469        if dest is None:
470            dest = os.pwd
471
472
473        resp = get_taskcluster_artifact(
474            "gecko.v2.mozilla-central.latest.mobile.android-x86_64-opt",
475            "public/build/geckoview-androidTest.apk")
476
477        filename = "geckoview-androidTest.apk"
478        if rename:
479            filename = "%s%s" % (rename, get_ext(filename)[1])
480        apk_path = os.path.join(dest, filename)
481
482        with open(apk_path, "wb") as f:
483            f.write(resp.content)
484
485        return apk_path
486
487    def install(self, dest=None, channel=None):
488        return self.download(dest, channel)
489
490    def install_prefs(self, binary, dest=None, channel=None):
491        fx_browser = Firefox(self.logger)
492        return fx_browser.install_prefs(binary, dest, channel)
493
494    def find_binary(self, venv_path=None, channel=None):
495        raise NotImplementedError
496
497    def find_webdriver(self, channel=None):
498        raise NotImplementedError
499
500    def install_webdriver(self, dest=None, channel=None, browser_binary=None):
501        raise NotImplementedError
502
503    def version(self, binary=None, webdriver_binary=None):
504        return None
505
506
507class Chrome(Browser):
508    """Chrome-specific interface.
509
510    Includes webdriver installation, and wptrunner setup methods.
511    """
512
513    product = "chrome"
514    requirements = "requirements_chrome.txt"
515
516    def download(self, dest=None, channel=None, rename=None):
517        raise NotImplementedError
518
519    def install(self, dest=None, channel=None):
520        raise NotImplementedError
521
522    def platform_string(self):
523        platform = {
524            "Linux": "linux",
525            "Windows": "win",
526            "Darwin": "mac"
527        }.get(uname[0])
528
529        if platform is None:
530            raise ValueError("Unable to construct a valid Chrome package name for current platform")
531
532        if platform == "linux":
533            bits = "64" if uname[4] == "x86_64" else "32"
534        elif platform == "mac":
535            bits = "64"
536        elif platform == "win":
537            bits = "32"
538
539        return "%s%s" % (platform, bits)
540
541    def chromium_platform_string(self):
542        platform = {
543            "Linux": "Linux",
544            "Windows": "Win",
545            "Darwin": "Mac"
546        }.get(uname[0])
547
548        if platform is None:
549            raise ValueError("Unable to construct a valid Chromium package name for current platform")
550
551        if (platform == "Linux" or platform == "Win") and uname[4] == "x86_64":
552            platform += "_x64"
553
554        return platform
555
556    def find_binary(self, venv_path=None, channel=None):
557        if uname[0] == "Linux":
558            name = "google-chrome"
559            if channel == "stable":
560                name += "-stable"
561            elif channel == "beta":
562                name += "-beta"
563            elif channel == "dev":
564                name += "-unstable"
565            # No Canary on Linux.
566            return find_executable(name)
567        if uname[0] == "Darwin":
568            if channel == "canary":
569                return "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"
570            # All other channels share the same path on macOS.
571            return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
572        if uname[0] == "Windows":
573            return os.path.expandvars(r"$SYSTEMDRIVE\Program Files (x86)\Google\Chrome\Application\chrome.exe")
574        self.logger.warning("Unable to find the browser binary.")
575        return None
576
577    def find_webdriver(self, channel=None):
578        return find_executable("chromedriver")
579
580    def _official_chromedriver_url(self, chrome_version):
581        # http://chromedriver.chromium.org/downloads/version-selection
582        parts = chrome_version.split(".")
583        assert len(parts) == 4
584        latest_url = "https://chromedriver.storage.googleapis.com/LATEST_RELEASE_%s.%s.%s" % tuple(parts[:-1])
585        try:
586            latest = get(latest_url).text.strip()
587        except requests.RequestException:
588            latest_url = "https://chromedriver.storage.googleapis.com/LATEST_RELEASE_%s" % parts[0]
589            try:
590                latest = get(latest_url).text.strip()
591            except requests.RequestException:
592                return None
593        return "https://chromedriver.storage.googleapis.com/%s/chromedriver_%s.zip" % (
594            latest, self.platform_string())
595
596    def _chromium_chromedriver_url(self, chrome_version):
597        try:
598            # Try to find the Chromium build with the same revision.
599            omaha = get("https://omahaproxy.appspot.com/deps.json?version=" + chrome_version).json()
600            revision = omaha['chromium_base_position']
601            url = "https://storage.googleapis.com/chromium-browser-snapshots/%s/%s/chromedriver_%s.zip" % (
602                self.chromium_platform_string(), revision, self.platform_string())
603            # Check the status without downloading the content (this is a streaming request).
604            get(url)
605        except requests.RequestException:
606            # Fall back to the tip-of-tree Chromium build.
607            revision_url = "https://storage.googleapis.com/chromium-browser-snapshots/%s/LAST_CHANGE" % (
608                self.chromium_platform_string())
609            revision = get(revision_url).text.strip()
610            url = "https://storage.googleapis.com/chromium-browser-snapshots/%s/%s/chromedriver_%s.zip" % (
611                self.chromium_platform_string(), revision, self.platform_string())
612        return url
613
614    def _latest_chromedriver_url(self, chrome_version):
615        # Remove channel suffixes (e.g. " dev").
616        chrome_version = chrome_version.split(' ')[0]
617        return (self._official_chromedriver_url(chrome_version) or
618                self._chromium_chromedriver_url(chrome_version))
619
620    def install_webdriver_by_version(self, version, dest=None):
621        assert version, "Cannot install ChromeDriver without Chrome version"
622        if dest is None:
623            dest = os.pwd
624        url = self._latest_chromedriver_url(version)
625        self.logger.info("Downloading ChromeDriver from %s" % url)
626        unzip(get(url).raw, dest)
627        chromedriver_dir = os.path.join(
628            dest, 'chromedriver_%s' % self.platform_string())
629        if os.path.isfile(os.path.join(chromedriver_dir, "chromedriver")):
630            shutil.move(os.path.join(chromedriver_dir, "chromedriver"), dest)
631            shutil.rmtree(chromedriver_dir)
632        return find_executable("chromedriver", dest)
633
634    def install_webdriver(self, dest=None, channel=None, browser_binary=None):
635        if browser_binary is None:
636            browser_binary = self.find_binary(channel)
637        return self.install_webdriver_by_version(
638            self.version(browser_binary), dest)
639
640    def version(self, binary=None, webdriver_binary=None):
641        if not binary:
642            self.logger.warning("No browser binary provided.")
643            return None
644
645        if uname[0] == "Windows":
646            return _get_fileversion(binary, self.logger)
647
648        try:
649            version_string = call(binary, "--version").strip()
650        except subprocess.CalledProcessError:
651            self.logger.warning("Failed to call %s" % binary)
652            return None
653        m = re.match(r"(?:Google Chrome|Chromium) (.*)", version_string)
654        if not m:
655            self.logger.warning("Failed to extract version from: %s" % version_string)
656            return None
657        return m.group(1)
658
659
660class ChromeAndroidBase(Browser):
661    """A base class for ChromeAndroid and AndroidWebView.
662
663    On Android, WebView is based on Chromium open source project, and on some
664    versions of Android we share the library with Chrome. Therefore, we have
665    a very similar WPT runner implementation.
666    Includes webdriver installation.
667    """
668    __metaclass__ = ABCMeta  # This is an abstract class.
669
670    def __init__(self, logger):
671        super(ChromeAndroidBase, self).__init__(logger)
672        self.device_serial = None
673
674    def download(self, dest=None, channel=None, rename=None):
675        raise NotImplementedError
676
677    def install(self, dest=None, channel=None):
678        raise NotImplementedError
679
680    @abstractmethod
681    def find_binary(self, venv_path=None, channel=None):
682        raise NotImplementedError
683
684    def find_webdriver(self, channel=None):
685        return find_executable("chromedriver")
686
687    def install_webdriver(self, dest=None, channel=None, browser_binary=None):
688        if browser_binary is None:
689            browser_binary = self.find_binary(channel)
690        chrome = Chrome(self.logger)
691        return chrome.install_webdriver_by_version(
692            self.version(browser_binary), dest)
693
694    def version(self, binary=None, webdriver_binary=None):
695        if not binary:
696            self.logger.warning("No package name provided.")
697            return None
698
699        command = ['adb']
700        if self.device_serial:
701            command.extend(['-s', self.device_serial])
702        command.extend(['shell', 'dumpsys', 'package', binary])
703        try:
704            output = call(*command)
705        except (subprocess.CalledProcessError, OSError):
706            self.logger.warning("Failed to call %s" % " ".join(command))
707            return None
708        match = re.search(r'versionName=(.*)', output)
709        if not match:
710            self.logger.warning("Failed to find versionName")
711            return None
712        return match.group(1)
713
714
715class ChromeAndroid(ChromeAndroidBase):
716    """Chrome-specific interface for Android.
717    """
718
719    product = "chrome_android"
720    requirements = "requirements_chrome_android.txt"
721
722    def find_binary(self, venv_path=None, channel=None):
723        if channel in ("beta", "dev", "canary"):
724            return "com.chrome." + channel
725        return "com.android.chrome"
726
727
728#TODO(aluo): This is largely copied from the AndroidWebView implementation.
729#            Tests are not running for weblayer yet (crbug/1019521), this
730#            initial implementation will help to reproduce and debug any issues.
731class AndroidWeblayer(ChromeAndroidBase):
732    """Weblayer-specific interface for Android."""
733
734    product = "android_weblayer"
735    # TODO(aluo): replace this with weblayer version after tests are working.
736    requirements = "requirements_android_webview.txt"
737
738    def find_binary(self, venv_path=None, channel=None):
739        return "org.chromium.weblayer.shell"
740
741
742class AndroidWebview(ChromeAndroidBase):
743    """Webview-specific interface for Android.
744
745    Design doc:
746    https://docs.google.com/document/d/19cGz31lzCBdpbtSC92svXlhlhn68hrsVwSB7cfZt54o/view
747    """
748
749    product = "android_webview"
750    requirements = "requirements_android_webview.txt"
751
752    def find_binary(self, venv_path=None, channel=None):
753        # Just get the current package name of the WebView provider.
754        # For WebView, it is not trivial to change the WebView provider, so
755        # we will just grab whatever is available.
756        # https://chromium.googlesource.com/chromium/src/+/HEAD/android_webview/docs/channels.md
757        command = ['adb']
758        if self.device_serial:
759            command.extend(['-s', self.device_serial])
760        command.extend(['shell', 'dumpsys', 'webviewupdate'])
761        try:
762            output = call(*command)
763        except (subprocess.CalledProcessError, OSError):
764            self.logger.warning("Failed to call %s" % " ".join(command))
765            return None
766        m = re.search(r'^\s*Current WebView package \(name, version\): \((.*), ([0-9.]*)\)$',
767                      output, re.M)
768        if m is None:
769            self.logger.warning("Unable to find current WebView package in dumpsys output")
770            return None
771        self.logger.warning("Final package name: " + m.group(1))
772        return m.group(1)
773
774
775class ChromeiOS(Browser):
776    """Chrome-specific interface for iOS.
777    """
778
779    product = "chrome_ios"
780    requirements = "requirements_chrome_ios.txt"
781
782    def download(self, dest=None, channel=None, rename=None):
783        raise NotImplementedError
784
785    def install(self, dest=None, channel=None):
786        raise NotImplementedError
787
788    def find_binary(self, venv_path=None, channel=None):
789        raise NotImplementedError
790
791    def find_webdriver(self, channel=None):
792        raise NotImplementedError
793
794    def install_webdriver(self, dest=None, channel=None, browser_binary=None):
795        raise NotImplementedError
796
797    def version(self, binary=None, webdriver_binary=None):
798        return None
799
800
801class Opera(Browser):
802    """Opera-specific interface.
803
804    Includes webdriver installation, and wptrunner setup methods.
805    """
806
807    product = "opera"
808    requirements = "requirements_opera.txt"
809
810    @property
811    def binary(self):
812        if uname[0] == "Linux":
813            return "/usr/bin/opera"
814        # TODO Windows, Mac?
815        self.logger.warning("Unable to find the browser binary.")
816        return None
817
818    def download(self, dest=None, channel=None, rename=None):
819        raise NotImplementedError
820
821    def install(self, dest=None, channel=None):
822        raise NotImplementedError
823
824    def platform_string(self):
825        platform = {
826            "Linux": "linux",
827            "Windows": "win",
828            "Darwin": "mac"
829        }.get(uname[0])
830
831        if platform is None:
832            raise ValueError("Unable to construct a valid Opera package name for current platform")
833
834        if platform == "linux":
835            bits = "64" if uname[4] == "x86_64" else "32"
836        elif platform == "mac":
837            bits = "64"
838        elif platform == "win":
839            bits = "32"
840
841        return "%s%s" % (platform, bits)
842
843    def find_binary(self, venv_path=None, channel=None):
844        raise NotImplementedError
845
846    def find_webdriver(self, channel=None):
847        return find_executable("operadriver")
848
849    def install_webdriver(self, dest=None, channel=None, browser_binary=None):
850        if dest is None:
851            dest = os.pwd
852        latest = get("https://api.github.com/repos/operasoftware/operachromiumdriver/releases/latest").json()["tag_name"]
853        url = "https://github.com/operasoftware/operachromiumdriver/releases/download/%s/operadriver_%s.zip" % (latest,
854                                                                                                                self.platform_string())
855        unzip(get(url).raw, dest)
856
857        operadriver_dir = os.path.join(dest, "operadriver_%s" % self.platform_string())
858        shutil.move(os.path.join(operadriver_dir, "operadriver"), dest)
859        shutil.rmtree(operadriver_dir)
860
861        path = find_executable("operadriver")
862        st = os.stat(path)
863        os.chmod(path, st.st_mode | stat.S_IEXEC)
864        return path
865
866    def version(self, binary=None, webdriver_binary=None):
867        """Retrieve the release version of the installed browser."""
868        binary = binary or self.binary
869        try:
870            output = call(binary, "--version")
871        except subprocess.CalledProcessError:
872            self.logger.warning("Failed to call %s" % binary)
873            return None
874        m = re.search(r"[0-9\.]+( [a-z]+)?$", output.strip())
875        if m:
876            return m.group(0)
877
878
879class EdgeChromium(Browser):
880    """MicrosoftEdge-specific interface."""
881    platform = {
882        "Linux": "linux",
883        "Windows": "win",
884        "Darwin": "macos"
885    }.get(uname[0])
886    product = "edgechromium"
887    edgedriver_name = "msedgedriver"
888    requirements = "requirements_edge_chromium.txt"
889
890    def download(self, dest=None, channel=None, rename=None):
891        raise NotImplementedError
892
893    def install(self, dest=None, channel=None):
894        raise NotImplementedError
895
896    def find_binary(self, venv_path=None, channel=None):
897        binary = None
898        if self.platform == "win":
899            binaryname = "msedge"
900            binary = find_executable(binaryname)
901            if not binary:
902                # Use paths from different Edge channels starting with Release\Beta\Dev\Canary
903                winpaths = [os.path.expanduser("~\\AppData\\Local\\Microsoft\\Edge\\Application"),
904                            os.path.expandvars("$SYSTEMDRIVE\\Program Files\\Microsoft\\Edge\\Application"),
905                            os.path.expandvars("$SYSTEMDRIVE\\Program Files\\Microsoft\\Edge Beta\\Application"),
906                            os.path.expandvars("$SYSTEMDRIVE\\Program Files\\Microsoft\\Edge Dev\\Application"),
907                            os.path.expandvars("$SYSTEMDRIVE\\Program Files (x86)\\Microsoft\\Edge\\Application"),
908                            os.path.expandvars("$SYSTEMDRIVE\\Program Files (x86)\\Microsoft\\Edge Beta\\Application"),
909                            os.path.expandvars("$SYSTEMDRIVE\\Program Files (x86)\\Microsoft\\Edge Dev\\Application"),
910                            os.path.expanduser("~\\AppData\\Local\\Microsoft\\Edge SxS\\Application")]
911                return find_executable(binaryname, os.pathsep.join(winpaths))
912        if self.platform == "macos":
913            binaryname = "Microsoft Edge Canary"
914            binary = find_executable(binaryname)
915            if not binary:
916                macpaths = ["/Applications/Microsoft Edge.app/Contents/MacOS",
917                            os.path.expanduser("~/Applications/Microsoft Edge.app/Contents/MacOS"),
918                            "/Applications/Microsoft Edge Canary.app/Contents/MacOS",
919                            os.path.expanduser("~/Applications/Microsoft Edge Canary.app/Contents/MacOS")]
920                return find_executable("Microsoft Edge Canary", os.pathsep.join(macpaths))
921        return binary
922
923    def find_webdriver(self, channel=None):
924        return find_executable("msedgedriver")
925
926    def install_webdriver(self, dest=None, channel=None, browser_binary=None):
927        if self.platform != "win" and self.platform != "macos":
928            raise ValueError("Only Windows and Mac platforms are currently supported")
929
930        if dest is None:
931            dest = os.pwd
932
933        if channel is None:
934            version_url = "https://msedgedriver.azureedge.net/LATEST_DEV"
935        else:
936            version_url = "https://msedgedriver.azureedge.net/LATEST_%s" % channel.upper()
937        version = get(version_url).text.strip()
938
939        if self.platform == "macos":
940            bits = "mac64"
941            edgedriver_path = os.path.join(dest, self.edgedriver_name)
942        else:
943            bits = "win64" if uname[4] == "x86_64" else "win32"
944            edgedriver_path = os.path.join(dest, "%s.exe" % self.edgedriver_name)
945        url = "https://msedgedriver.azureedge.net/%s/edgedriver_%s.zip" % (version, bits)
946
947        # cleanup existing Edge driver files to avoid access_denied errors when unzipping
948        if os.path.isfile(edgedriver_path):
949            # remove read-only attribute
950            os.chmod(edgedriver_path, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO)  # 0777
951            print("Delete %s file" % edgedriver_path)
952            os.remove(edgedriver_path)
953        driver_notes_path = os.path.join(dest, "Driver_notes")
954        if os.path.isdir(driver_notes_path):
955            print("Delete %s folder" % driver_notes_path)
956            shutil.rmtree(driver_notes_path, ignore_errors=False, onerror=handle_remove_readonly)
957
958        self.logger.info("Downloading MSEdgeDriver from %s" % url)
959        unzip(get(url).raw, dest)
960        if os.path.isfile(edgedriver_path):
961            self.logger.info("Successfully downloaded MSEdgeDriver to %s" % edgedriver_path)
962        return find_executable(self.edgedriver_name, dest)
963
964    def version(self, binary=None, webdriver_binary=None):
965        if binary is None:
966            binary = self.find_binary()
967        if self.platform != "win":
968            try:
969                version_string = call(binary, "--version").strip()
970            except subprocess.CalledProcessError:
971                self.logger.warning("Failed to call %s" % binary)
972                return None
973            m = re.match(r"Microsoft Edge (.*) ", version_string)
974            if not m:
975                self.logger.warning("Failed to extract version from: %s" % version_string)
976                return None
977            return m.group(1)
978        else:
979            if binary is not None:
980                return _get_fileversion(binary, self.logger)
981            self.logger.warning("Failed to find Edge binary.")
982            return None
983
984
985class Edge(Browser):
986    """Edge-specific interface."""
987
988    product = "edge"
989    requirements = "requirements_edge.txt"
990
991    def download(self, dest=None, channel=None, rename=None):
992        raise NotImplementedError
993
994    def install(self, dest=None, channel=None):
995        raise NotImplementedError
996
997    def find_binary(self, venv_path=None, channel=None):
998        raise NotImplementedError
999
1000    def find_webdriver(self, channel=None):
1001        return find_executable("MicrosoftWebDriver")
1002
1003    def install_webdriver(self, dest=None, channel=None, browser_binary=None):
1004        raise NotImplementedError
1005
1006    def version(self, binary=None, webdriver_binary=None):
1007        command = "(Get-AppxPackage Microsoft.MicrosoftEdge).Version"
1008        try:
1009            return call("powershell.exe", command).strip()
1010        except (subprocess.CalledProcessError, OSError):
1011            self.logger.warning("Failed to call %s in PowerShell" % command)
1012            return None
1013
1014
1015class EdgeWebDriver(Edge):
1016    product = "edge_webdriver"
1017
1018
1019class InternetExplorer(Browser):
1020    """Internet Explorer-specific interface."""
1021
1022    product = "ie"
1023    requirements = "requirements_ie.txt"
1024
1025    def download(self, dest=None, channel=None, rename=None):
1026        raise NotImplementedError
1027
1028    def install(self, dest=None, channel=None):
1029        raise NotImplementedError
1030
1031    def find_binary(self, venv_path=None, channel=None):
1032        raise NotImplementedError
1033
1034    def find_webdriver(self, channel=None):
1035        return find_executable("IEDriverServer.exe")
1036
1037    def install_webdriver(self, dest=None, channel=None, browser_binary=None):
1038        raise NotImplementedError
1039
1040    def version(self, binary=None, webdriver_binary=None):
1041        return None
1042
1043
1044class Safari(Browser):
1045    """Safari-specific interface.
1046
1047    Includes installation, webdriver installation, and wptrunner setup methods.
1048    """
1049
1050    product = "safari"
1051    requirements = "requirements_safari.txt"
1052
1053    def download(self, dest=None, channel=None, rename=None):
1054        raise NotImplementedError
1055
1056    def install(self, dest=None, channel=None):
1057        raise NotImplementedError
1058
1059    def find_binary(self, venv_path=None, channel=None):
1060        raise NotImplementedError
1061
1062    def find_webdriver(self, channel=None):
1063        path = None
1064        if channel == "preview":
1065            path = "/Applications/Safari Technology Preview.app/Contents/MacOS"
1066        return find_executable("safaridriver", path)
1067
1068    def install_webdriver(self, dest=None, channel=None, browser_binary=None):
1069        raise NotImplementedError
1070
1071    def version(self, binary=None, webdriver_binary=None):
1072        if webdriver_binary is None:
1073            self.logger.warning("Cannot find Safari version without safaridriver")
1074            return None
1075        # Use `safaridriver --version` to get the version. Example output:
1076        # "Included with Safari 12.1 (14607.1.11)"
1077        # "Included with Safari Technology Preview (Release 67, 13607.1.9.0.1)"
1078        # The `--version` flag was added in STP 67, so allow the call to fail.
1079        try:
1080            version_string = call(webdriver_binary, "--version").strip()
1081        except subprocess.CalledProcessError:
1082            self.logger.warning("Failed to call %s --version" % webdriver_binary)
1083            return None
1084        m = re.match(r"Included with Safari (.*)", version_string)
1085        if not m:
1086            self.logger.warning("Failed to extract version from: %s" % version_string)
1087            return None
1088        return m.group(1)
1089
1090
1091class Servo(Browser):
1092    """Servo-specific interface."""
1093
1094    product = "servo"
1095    requirements = "requirements_servo.txt"
1096
1097    def platform_components(self):
1098        platform = {
1099            "Linux": "linux",
1100            "Windows": "win",
1101            "Darwin": "mac"
1102        }.get(uname[0])
1103
1104        if platform is None:
1105            raise ValueError("Unable to construct a valid Servo package for current platform")
1106
1107        if platform == "linux":
1108            extension = ".tar.gz"
1109            decompress = untar
1110        elif platform == "win" or platform == "mac":
1111            raise ValueError("Unable to construct a valid Servo package for current platform")
1112
1113        return (platform, extension, decompress)
1114
1115    def _get(self, channel="nightly"):
1116        if channel != "nightly":
1117            raise ValueError("Only nightly versions of Servo are available")
1118
1119        platform, extension, _ = self.platform_components()
1120        url = "https://download.servo.org/nightly/%s/servo-latest%s" % (platform, extension)
1121        return get(url)
1122
1123    def download(self, dest=None, channel="nightly", rename=None):
1124        if dest is None:
1125            dest = os.pwd
1126
1127        resp = self._get(dest, channel)
1128        _, extension, _ = self.platform_components()
1129
1130        filename = rename if rename is not None else "servo-latest"
1131        with open(os.path.join(dest, "%s%s" % (filename, extension,)), "w") as f:
1132            f.write(resp.content)
1133
1134    def install(self, dest=None, channel="nightly"):
1135        """Install latest Browser Engine."""
1136        if dest is None:
1137            dest = os.pwd
1138
1139        _, _, decompress = self.platform_components()
1140
1141        resp = self._get(channel)
1142        decompress(resp.raw, dest=dest)
1143        path = find_executable("servo", os.path.join(dest, "servo"))
1144        st = os.stat(path)
1145        os.chmod(path, st.st_mode | stat.S_IEXEC)
1146        return path
1147
1148    def find_binary(self, venv_path=None, channel=None):
1149        path = find_executable("servo", os.path.join(venv_path, "servo"))
1150        if path is None:
1151            path = find_executable("servo")
1152        return path
1153
1154    def find_webdriver(self, channel=None):
1155        return None
1156
1157    def install_webdriver(self, dest=None, channel=None, browser_binary=None):
1158        raise NotImplementedError
1159
1160    def version(self, binary=None, webdriver_binary=None):
1161        """Retrieve the release version of the installed browser."""
1162        output = call(binary, "--version")
1163        m = re.search(r"Servo ([0-9\.]+-[a-f0-9]+)?(-dirty)?$", output.strip())
1164        if m:
1165            return m.group(0)
1166
1167
1168class ServoWebDriver(Servo):
1169    product = "servodriver"
1170
1171
1172class Sauce(Browser):
1173    """Sauce-specific interface."""
1174
1175    product = "sauce"
1176    requirements = "requirements_sauce.txt"
1177
1178    def download(self, dest=None, channel=None, rename=None):
1179        raise NotImplementedError
1180
1181    def install(self, dest=None, channel=None):
1182        raise NotImplementedError
1183
1184    def find_binary(self, venev_path=None, channel=None):
1185        raise NotImplementedError
1186
1187    def find_webdriver(self, channel=None):
1188        raise NotImplementedError
1189
1190    def install_webdriver(self, dest=None, channel=None, browser_binary=None):
1191        raise NotImplementedError
1192
1193    def version(self, binary=None, webdriver_binary=None):
1194        return None
1195
1196
1197class WebKit(Browser):
1198    """WebKit-specific interface."""
1199
1200    product = "webkit"
1201    requirements = "requirements_webkit.txt"
1202
1203    def download(self, dest=None, channel=None, rename=None):
1204        raise NotImplementedError
1205
1206    def install(self, dest=None, channel=None):
1207        raise NotImplementedError
1208
1209    def find_binary(self, venv_path=None, channel=None):
1210        return None
1211
1212    def find_webdriver(self, channel=None):
1213        return None
1214
1215    def install_webdriver(self, dest=None, channel=None, browser_binary=None):
1216        raise NotImplementedError
1217
1218    def version(self, binary=None, webdriver_binary=None):
1219        return None
1220
1221
1222class WebKitGTKMiniBrowser(WebKit):
1223
1224    def find_binary(self, venv_path=None, channel=None):
1225        libexecpaths = ["/usr/libexec/webkit2gtk-4.0"]  # Fedora path
1226        triplet = "x86_64-linux-gnu"
1227        # Try to use GCC to detect this machine triplet
1228        gcc = find_executable("gcc")
1229        if gcc:
1230            try:
1231                triplet = call(gcc, "-dumpmachine").strip()
1232            except subprocess.CalledProcessError:
1233                pass
1234        # Add Debian/Ubuntu path
1235        libexecpaths.append("/usr/lib/%s/webkit2gtk-4.0" % triplet)
1236        if channel == "nightly":
1237            libexecpaths.append("/opt/webkitgtk/nightly")
1238        return find_executable("MiniBrowser", os.pathsep.join(libexecpaths))
1239
1240    def find_webdriver(self, channel=None):
1241        path = os.environ['PATH']
1242        if channel == "nightly":
1243            path = "%s:%s" % (path, "/opt/webkitgtk/nightly")
1244        return find_executable("WebKitWebDriver", path)
1245
1246    def version(self, binary=None, webdriver_binary=None):
1247        if binary is None:
1248            return None
1249        try:  # WebKitGTK MiniBrowser before 2.26.0 doesn't support --version
1250            output = call(binary, "--version").strip()
1251        except subprocess.CalledProcessError:
1252            return None
1253        # Example output: "WebKitGTK 2.26.1"
1254        if output:
1255            m = re.match(r"WebKitGTK (.+)", output)
1256            if not m:
1257                self.logger.warning("Failed to extract version from: %s" % output)
1258                return None
1259            return m.group(1)
1260        return None
1261
1262
1263class Epiphany(Browser):
1264    """Epiphany-specific interface."""
1265
1266    product = "epiphany"
1267    requirements = "requirements_epiphany.txt"
1268
1269    def download(self, dest=None, channel=None, rename=None):
1270        raise NotImplementedError
1271
1272    def install(self, dest=None, channel=None):
1273        raise NotImplementedError
1274
1275    def find_binary(self, venv_path=None, channel=None):
1276        return find_executable("epiphany")
1277
1278    def find_webdriver(self, channel=None):
1279        return find_executable("WebKitWebDriver")
1280
1281    def install_webdriver(self, dest=None, channel=None, browser_binary=None):
1282        raise NotImplementedError
1283
1284    def version(self, binary=None, webdriver_binary=None):
1285        if binary is None:
1286            return None
1287        output = call(binary, "--version")
1288        if output:
1289            # Stable release output looks like: "Web 3.30.2"
1290            # Tech Preview output looks like "Web 3.31.3-88-g97db4f40f"
1291            return output.split()[1]
1292        return None
1293