diff options
Diffstat (limited to '')
523 files changed, 59328 insertions, 0 deletions
diff --git a/testing/mozbase/README.md b/testing/mozbase/README.md new file mode 100644 index 0000000000..dab25961bf --- /dev/null +++ b/testing/mozbase/README.md @@ -0,0 +1,20 @@ +# Mozbase + +Mozbase is a set of easy-to-use Python packages forming a supplemental standard +library for Mozilla. It provides consistency and reduces redundancy in +automation and other system-level software. All of Mozilla's test harnesses use +mozbase to some degree, including Talos, mochitest, and reftest. + +Learn more about mozbase at the [project page][]. + +Read [detailed docs][] online, or build them locally by running "make html" in +the docs directory. + +Consult [open][] [bugs][] and feel free to file [new bugs][]. + + +[project page]: https://wiki.mozilla.org/Auto-tools/Projects/Mozbase +[detailed docs]: https://firefox-source-docs.mozilla.org/mozbase/index.html +[open]: https://bugzilla.mozilla.org/buglist.cgi?resolution=---&component=Mozbase&product=Testing +[bugs]: https://bugzilla.mozilla.org/buglist.cgi?resolution=---&status_whiteboard_type=allwordssubstr&query_format=advanced&status_whiteboard=mozbase +[new bugs]: https://bugzilla.mozilla.org/enter_bug.cgi?product=Testing&component=Mozbase diff --git a/testing/mozbase/docs/Makefile b/testing/mozbase/docs/Makefile new file mode 100644 index 0000000000..386a52db13 --- /dev/null +++ b/testing/mozbase/docs/Makefile @@ -0,0 +1,153 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make <target>' where <target> is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + -rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/MozBase.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/MozBase.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/MozBase" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/MozBase" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." diff --git a/testing/mozbase/docs/_static/structured_example.py b/testing/mozbase/docs/_static/structured_example.py new file mode 100644 index 0000000000..3ec1aa8dcc --- /dev/null +++ b/testing/mozbase/docs/_static/structured_example.py @@ -0,0 +1,111 @@ +# 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/. + +import argparse +import sys +import traceback +import types + +import six +from mozlog import commandline, get_default_logger + + +class TestAssertion(Exception): + pass + + +def assert_equals(a, b): + if a != b: + raise TestAssertion("%r not equal to %r" % (a, b)) + + +def expected(status): + def inner(f): + def test_func(): + f() + + test_func.__name__ = f.__name__ + test_func._expected = status + return test_func + + return inner + + +def test_that_passes(): + assert_equals(1, int("1")) + + +def test_that_fails(): + assert_equals(1, int("2")) + + +def test_that_has_an_error(): + assert_equals(2, 1 + "1") + + +@expected("FAIL") +def test_expected_fail(): + assert_equals(2 + 2, 5) + + +class TestRunner(object): + def __init__(self): + self.logger = get_default_logger(component="TestRunner") + + def gather_tests(self): + for item in six.itervalues(globals()): + if isinstance(item, types.FunctionType) and item.__name__.startswith( + "test_" + ): + yield item.__name__, item + + def run(self): + tests = list(self.gather_tests()) + + self.logger.suite_start(tests=[name for name, func in tests]) + self.logger.info("Running tests") + for name, func in tests: + self.run_test(name, func) + self.logger.suite_end() + + def run_test(self, name, func): + self.logger.test_start(name) + status = None + message = None + expected = func._expected if hasattr(func, "_expected") else "PASS" + try: + func() + except TestAssertion as e: + status = "FAIL" + message = str(e) + except Exception: + status = "ERROR" + message = traceback.format_exc() + else: + status = "PASS" + self.logger.test_end(name, status=status, expected=expected, message=message) + + +def get_parser(): + parser = argparse.ArgumentParser() + return parser + + +def main(): + parser = get_parser() + commandline.add_logging_group(parser) + + args = parser.parse_args() + + logger = commandline.setup_logging("structured-example", args, {"raw": sys.stdout}) + + runner = TestRunner() + try: + runner.run() + except Exception: + logger.critical("Error during test run:\n%s" % traceback.format_exc()) + + +if __name__ == "__main__": + main() diff --git a/testing/mozbase/docs/conf.py b/testing/mozbase/docs/conf.py new file mode 100644 index 0000000000..7855e250b8 --- /dev/null +++ b/testing/mozbase/docs/conf.py @@ -0,0 +1,280 @@ +# -*- coding: utf-8 -*- +# +# MozBase documentation build configuration file, created by +# sphinx-quickstart on Mon Oct 22 14:02:17 2012. +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import os +import sys + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +here = os.path.dirname(os.path.abspath(__file__)) +parent = os.path.dirname(here) +for item in os.listdir(parent): + path = os.path.join(parent, item) + if (not os.path.isdir(path)) or ( + not os.path.exists(os.path.join(path, "setup.py")) + ): + continue + sys.path.insert(0, path) + +# -- General configuration ----------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.doctest", + "sphinx.ext.todo", + "sphinx.ext.viewcode", +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# The suffix of source filenames. +source_suffix = ".rst" + +# The encoding of source files. +# source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = "index" + +# General information about the project. +project = "MozBase" +copyright = "2012, Mozilla Automation and Tools team" + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = "1" +# The full version, including alpha/beta/rc tags. +release = "1" + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +# today = '' +# Else, today_fmt is used as the format for a strftime call. +# today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ["_build"] + +# The reST default role (used for this markup: `text`) to use for all documents. +# default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +# add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +# add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +# show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "sphinx" + +# A list of ignored prefixes for module index sorting. +# modindex_common_prefix = [] + + +# -- Options for HTML output --------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = "default" +on_rtd = os.environ.get("READTHEDOCS", None) == "True" + +if not on_rtd: + try: + import sphinx_rtd_theme + + html_theme = "sphinx_rtd_theme" + html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + except ImportError: + pass + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +# html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# "<project> v<release> documentation". +html_title = "mozbase documentation" + +# A shorter title for the navigation bar. Default is the same as html_title. +# html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +# html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +# html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ["_static"] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +# html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +# html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +# html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +# html_additional_pages = {} + +# If false, no module index is generated. +# html_domain_indices = True + +# If false, no index is generated. +# html_use_index = True + +# If true, the index is split into individual pages for each letter. +# html_split_index = False + +# If true, links to the reST sources are added to the pages. +# html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +# html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +html_show_copyright = False + +# If true, an OpenSearch description file will be output, and all pages will +# contain a <link> tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +# html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +# html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = "MozBasedoc" + + +# -- Options for LaTeX output -------------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # 'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]). +latex_documents = [ + ( + "index", + "MozBase.tex", + "MozBase Documentation", + "Mozilla Automation and Tools team", + "manual", + ), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +# latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +# latex_use_parts = False + +# If true, show page references after internal links. +# latex_show_pagerefs = False + +# If true, show URL addresses after external links. +# latex_show_urls = False + +# Documents to append as an appendix to all manuals. +# latex_appendices = [] + +# If false, no module index is generated. +# latex_domain_indices = True + + +# -- Options for manual page output -------------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ( + "index", + "mozbase", + "MozBase Documentation", + ["Mozilla Automation and Tools team"], + 1, + ) +] + +# If true, show URL addresses after external links. +# man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------------ + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ( + "index", + "MozBase", + "MozBase Documentation", + "Mozilla Automation and Tools team", + "MozBase", + "One line description of project.", + "Miscellaneous", + ), +] + +# Documents to append as an appendix to all manuals. +# texinfo_appendices = [] + +# If false, no module index is generated. +# texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +# texinfo_show_urls = 'footnote' diff --git a/testing/mozbase/docs/devicemanagement.rst b/testing/mozbase/docs/devicemanagement.rst new file mode 100644 index 0000000000..e2c229b3b4 --- /dev/null +++ b/testing/mozbase/docs/devicemanagement.rst @@ -0,0 +1,11 @@ +Device management +----------------- + +Mozbase provides a module called `mozdevice` for the purposes of +running automated tests or scripts on an Android phone, tablet, or +emulator connected to a workstation. + +.. toctree:: + :maxdepth: 3 + + mozdevice diff --git a/testing/mozbase/docs/gettinginfo.rst b/testing/mozbase/docs/gettinginfo.rst new file mode 100644 index 0000000000..35c4c45081 --- /dev/null +++ b/testing/mozbase/docs/gettinginfo.rst @@ -0,0 +1,13 @@ +Getting information on the system under test +============================================ + +It's often necessary to get some information about the system we're +testing, for example to turn on or off some platform specific +behaviour. + +.. toctree:: + :maxdepth: 2 + + mozinfo + moznetwork + mozversion diff --git a/testing/mozbase/docs/index.rst b/testing/mozbase/docs/index.rst new file mode 100644 index 0000000000..f63f0aa68d --- /dev/null +++ b/testing/mozbase/docs/index.rst @@ -0,0 +1,44 @@ +.. MozBase documentation master file, created by + sphinx-quickstart on Mon Oct 22 14:02:17 2012. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +mozbase +======= + +Mozbase is a set of easy-to-use Python packages forming a supplemental standard +library for Mozilla. It provides consistency and reduces redundancy in +automation and other system-level software. All of Mozilla's test harnesses use +mozbase to some degree, including Talos_, mochitest_, and reftest_. + +.. _Talos: https://wiki.mozilla.org/Talos + +.. _mochitest: https://developer.mozilla.org/en-US/docs/Mochitest + +.. _reftest: https://developer.mozilla.org/en-US/docs/Creating_reftest-based_unit_tests + +In the course of writing automated tests at Mozilla, we found that +the same tasks came up over and over, regardless of the specific nature of +what we were testing. We figured that consolidating this code into a set of +libraries would save us a good deal of time, and so we spent some effort +factoring out the best-of-breed automation code into something we named +"mozbase" (usually written all in lower case except at the beginning of a +sentence). + +This is the main documentation for users of mozbase. There is also a +project_ wiki page with notes on development practices and administration. + +.. _project: https://wiki.mozilla.org/Auto-tools/Projects/Mozbase + +The documentation is organized by category, then by module. Figure out what you +want to do then dive in! + +.. toctree:: + :maxdepth: 2 + + manifestparser + gettinginfo + setuprunning + servingcontent + loggingreporting + devicemanagement diff --git a/testing/mozbase/docs/loggingreporting.rst b/testing/mozbase/docs/loggingreporting.rst new file mode 100644 index 0000000000..a8561a49b2 --- /dev/null +++ b/testing/mozbase/docs/loggingreporting.rst @@ -0,0 +1,11 @@ +Logging and reporting +===================== + +Ideally output between different types of testing system should be as +uniform as possible, as well as making it easy to make things more or +less verbose. We created some libraries to make doing this easy. + +.. toctree:: + :maxdepth: 2 + + mozlog diff --git a/testing/mozbase/docs/make.bat b/testing/mozbase/docs/make.bat new file mode 100644 index 0000000000..d67c86ae98 --- /dev/null +++ b/testing/mozbase/docs/make.bat @@ -0,0 +1,190 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^<target^>` where ^<target^> is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\MozBase.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\MozBase.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +:end diff --git a/testing/mozbase/docs/manifestparser.rst b/testing/mozbase/docs/manifestparser.rst new file mode 100644 index 0000000000..3ab2f20098 --- /dev/null +++ b/testing/mozbase/docs/manifestparser.rst @@ -0,0 +1,648 @@ +Managing lists of tests +======================= + +.. py:currentmodule:: manifestparser + +We don't always want to run all tests, all the time. Sometimes a test +may be broken, in other cases we only want to run a test on a specific +platform or build of Mozilla. To handle these cases (and more), we +created a python library to create and use test "manifests", which +codify this information. + +Update for August 2023: Transition to TOML for manifestparser +````````````````````````````````````````````````````````````` + +As of August 2023, manifestparser will be transitioning from INI format +configuration files to TOML. The new TOML format will better support +future continuous integration automation and has a much more +precise syntax (FFI see `Bug 1821199 <https://bugzilla.mozilla.org/show_bug.cgi?id=1821199>`_). +During the migration period both ``*.ini`` files and +``*.toml`` files will be supported. If an INI config file is specified +(e.g. in ``moz.build``) and a TOML file is present, the TOML file will be +used. + +:mod:`manifestparser` --- Create and manage test manifests +----------------------------------------------------------- + +manifestparser lets you easily create and use test manifests, to +control which tests are run under what circumstances. + +What manifestparser gives you: + +* manifests are ordered lists of tests +* tests may have an arbitrary number of key, value pairs +* the parser returns an ordered list of test data structures, which + are just dicts with some keys. For example, a test with no + user-specified metadata looks like this: + +.. code-block:: text + + [{'expected': 'pass', + 'path': '/home/mozilla/mozmill/src/manifestparser/manifestparser/tests/testToolbar/testBackForwardButtons.js', + 'relpath': 'testToolbar/testBackForwardButtons.js', + 'name': 'testBackForwardButtons.js', + 'here': '/home/mozilla/mozmill/src/manifestparser/manifestparser/tests', + 'manifest': '/home/mozilla/mozmill/src/manifestparser/manifestparser/tests/manifest.toml',}] + +The keys displayed here (path, relpath, name, here, and manifest) are +reserved keys for manifestparser and any consuming APIs. You can add +additional key, value metadata to each test. + +Why have test manifests? +```````````````````````` + +It is desirable to have a unified format for test manifests for testing +`mozilla-central <http://hg.mozilla.org/mozilla-central>`_, etc. + +* It is desirable to be able to selectively enable or disable tests based on platform or other conditions. This should be easy to do. Currently, since many of the harnesses just crawl directories, there is no effective way of disabling a test except for removal from mozilla-central +* It is desriable to do this in a universal way so that enabling and disabling tests as well as other tasks are easily accessible to a wider audience than just those intimately familiar with the specific test framework. +* It is desirable to have other metadata on top of the test. For instance, let's say a test is marked as skipped. It would be nice to give the reason why. + + +Most Mozilla test harnesses work by crawling a directory structure. +While this is straight-forward, manifests offer several practical +advantages: + +* ability to turn a test off easily: if a test is broken on m-c + currently, the only way to turn it off, generally speaking, is just + removing the test. Often this is undesirable, as if the test should + be dismissed because other people want to land and it can't be + investigated in real time (is it a failure? is the test bad? is no + one around that knows the test?), then backing out a test is at best + problematic. With a manifest, a test may be disabled without + removing it from the tree and a bug filed with the appropriate + reason: + +.. code-block:: text + + ["test_broken.js"] + disabled = "https://bugzilla.mozilla.org/show_bug.cgi?id=123456" + +* ability to run different (subsets of) tests on different + platforms. Traditionally, we've done a bit of magic or had the test + know what platform it would or would not run on. With manifests, you + can mark what platforms a test will or will not run on and change + these without changing the test. + +.. code-block:: text + + ["test_works_on_windows_only.js"] + skip-if = ["os != 'win'"] + +* ability to markup tests with metadata. We have a large, complicated, + and always changing infrastructure. key, value metadata may be used + as an annotation to a test and appropriately curated and mined. For + instance, we could mark certain tests as randomorange with a bug + number, if it were desirable. + +* ability to have sane and well-defined test-runs. You can keep + different manifests for different test runs and ``["include:FILENAME.toml"]`` + (sub)manifests as appropriate to your needs. + +Manifest Format +``````````````` + +Manifests are ``*.toml`` (formerly ``*.ini``) files with the section names denoting the path +relative to the manifest: + +.. code-block:: text + + ["foo.js"] + ["bar.js"] + ["fleem.js"] + +The sections are read in order. In addition, tests may include +arbitrary key, value metadata to be used by the harness. You may also +have a `[DEFAULT]` section that will give key, value pairs that will +be inherited by each test unless overridden: + +.. code-block:: text + + [DEFAULT] + type = "restart" + + ["lilies.js"] + color = "white" + + ["daffodils.js"] + color = "yellow" + type = "other" + # override type from DEFAULT + + ["roses.js"] + color = "red" + +You can also include other manifests: + +.. code-block:: text + + ["include:subdir/anothermanifest.toml"] + +And reference parent manifests to inherit keys and values from the DEFAULT +section, without adding possible included tests. + +.. code-block:: text + + ["parent:../manifest.toml"] + +Manifests are included relative to the directory of the manifest with +the `[include:]` directive unless they are absolute paths. + +By default you can use '#' as a comment character. Comments can start a +new line, or be inline. + +.. code-block:: text + + ["roses.js"] + # a valid comment + color = "red" # another valid comment + +Because in TOML all values must be quoted there is no risk of an anchor in +an URL being interpreted as a comment. + +.. code-block:: text + + ["test1.js"] + url = "https://foo.com/bar#baz" # Bug 1234 + + +Manifest Conditional Expressions +```````````````````````````````` +The conditional expressions used in manifests are parsed using the *ExpressionParser* class. + +.. autoclass:: manifestparser.ExpressionParser + +Consumers of this module are expected to pass in a value dictionary +for evaluating conditional expressions. A common pattern is to pass +the dictionary from the :mod:`mozinfo` module. + +Data +```` + +Manifest Destiny gives tests as a list of dictionaries (in python +terms). + +* path: full path to the test +* relpath: relative path starting from the root directory. The root directory + is typically the location of the root manifest, or the source + repository. It can be specified at runtime by passing in `rootdir` + to `TestManifest`. Defaults to the directory containing the test's + ancestor manifest. +* name: file name of the test +* here: the parent directory of the manifest +* manifest: the path to the manifest containing the test + +This data corresponds to a one-line manifest: + +.. code-block:: text + + ["testToolbar/testBackForwardButtons.js"] + +If additional key, values were specified, they would be in this dict +as well. + +Outside of the reserved keys, the remaining key, values +are up to convention to use. There is a (currently very minimal) +generic integration layer in manifestparser for use of all harnesses, +`manifestparser.TestManifest`. +For instance, if the 'disabled' key is present, you can get the set of +tests without disabled (various other queries are doable as well). + +Since the system is convention-based, the harnesses may do whatever +they want with the data. They may ignore it completely, they may use +the provided integration layer, or they may provide their own +integration layer. This should allow whatever sort of logic is +desired. For instance, if in yourtestharness you wanted to run only on +mondays for a certain class of tests: + +.. code-block:: text + + tests = [] + for test in manifests.tests: + if 'runOnDay' in test: + if calendar.day_name[calendar.weekday(*datetime.datetime.now().timetuple()[:3])].lower() == test['runOnDay'].lower(): + tests.append(test) + else: + tests.append(test) + +To recap: + +* the manifests allow you to specify test data +* the parser gives you this data +* you can use it however you want or process it further as you need + +Tests are denoted by sections in an ``*.toml`` file (see +https://searchfox.org/mozilla-central/source/testing/mozbase/manifestparser/tests/manifest.toml +). + +Additional manifest files may be included with an `[include:]` directive: + +.. code-block:: text + + ["include:path-to-additional-file-manifest.toml"] + +The path to included files is relative to the current manifest. + +The `[DEFAULT]` section contains variables that all tests inherit from. + +Included files will inherit the top-level variables but may override +in their own `[DEFAULT]` section. + +manifestparser Architecture +```````````````````````````` + +There is a two- or three-layered approach to the manifestparser +architecture, depending on your needs: + +1. ManifestParser: this is a generic parser for ``*.toml`` manifests that +facilitates the `[include:]` logic and the inheritance of +metadata. Despite the internal variable being called `self.tests` +(an oversight), this layer has nothing in particular to do with tests. + +2. TestManifest: this is a harness-agnostic integration layer that is +test-specific. TestManifest facilitates `skip-if` logic. + +3. Optionally, a harness will have an integration layer than inherits +from TestManifest if more harness-specific customization is desired at +the manifest level. + +See the source code at +https://searchfox.org/mozilla-central/source/testing/mozbase/manifestparser +. + +Filtering Manifests +``````````````````` + +After creating a `TestManifest` object, all manifest files are read and a list +of test objects can be accessed via `TestManifest.tests`. However this list contains +all test objects, whether they should be run or not. Normally they need to be +filtered down only to the set of tests that should be run by the test harness. + +To do this, a test harness can call `TestManifest.active_tests`: + +.. code-block:: python + + tests = manifest.active_tests(exists=True, disabled=True, **tags) + +By default, `active_tests` runs the filters found in +:attr:`~.DEFAULT_FILTERS`. It also accepts two convenience arguments: + +1. `exists`: if True (default), filter out tests that do not exist on the local file system. +2. `disabled`: if True (default), do not filter out tests containing the 'disabled' key + (which can be set by `skip-if` manually). + +This works for simple cases, but there are other built-in filters, or even custom filters +that can be applied to the `TestManifest`. To do so, add the filter to `TestManifest.filters`: + +.. code-block:: python + + from manifestparser.filters import subsuite + import mozinfo + + filters = [subsuite('devtools')] + tests = manifest.active_tests(filters=filters, **mozinfo.info) + +.. automodule:: manifestparser.filters + :members: + :exclude-members: filterlist,InstanceFilter,DEFAULT_FILTERS + +.. autodata:: manifestparser.filters.DEFAULT_FILTERS + :annotation: + +For example, suppose we want to introduce a new key called `timeout-if` that adds a +'timeout' property to a test if a certain condition is True. The syntax in the manifest +files will look like this: + +.. code-block:: text + + ["test_foo.py"] + timeout-if = ["300, os == 'win'"] + +The value is <timeout>, <condition> where condition is the same format as the one in +`skip-if`. In the above case, if os == 'win', a timeout of 300 seconds will be +applied. Otherwise, no timeout will be applied. All we need to do is define the filter +and add it: + +.. code-block:: python + + from manifestparser.expression import parse + import mozinfo + + def timeout_if(tests, values): + for test in tests: + if 'timeout-if' in test: + timeout, condition = test['timeout-if'].split(',', 1) + if parse(condition, **values): + test['timeout'] = timeout + yield test + + tests = manifest.active_tests(filters=[timeout_if], **mozinfo.info) + + +CLI +``` + +**NOTE:** *The manifestparser CLI is currently being updated to support TOML.* + +Run `manifestparser help` for usage information. + +To create a manifest from a set of directories: + +.. code-block:: text + + manifestparser [options] create directory <directory> <...> [create-options] + +To output a manifest of tests: + +.. code-block:: text + + manifestparser [options] write manifest <manifest> <...> -tag1 -tag2 --key1=value1 --key2=value2 ... + +To copy tests and manifests from a source: + +.. code-block:: text + + manifestparser [options] copy from_manifest to_manifest -tag1 -tag2 `key1=value1 key2=value2 ... + +To update the tests associated with with a manifest from a source +directory: + +.. code-block:: text + + manifestparser [options] update manifest from_directory -tag1 -tag2 --key1=value1 --key2=value2 ... + +Creating Manifests +`````````````````` + +manifestparser comes with a console script, `manifestparser create`, that +may be used to create a seed manifest structure from a directory of +files. Run `manifestparser help create` for usage information. + +Copying Manifests +````````````````` + +To copy tests and manifests from a source: + +.. code-block:: text + + manifestparser [options] copy from_manifest to_directory -tag1 -tag2 `key1=value1 key2=value2 ... + +Updating Tests +`````````````` + +To update the tests associated with with a manifest from a source +directory: + +.. code-block:: text + + manifestparser [options] update manifest from_directory -tag1 -tag2 `key1=value1 `key2=value2 ... + +Usage example +````````````` + +Here is an example of how to create manifests for a directory tree and +update the tests listed in the manifests from an external source. + +Creating Manifests +`````````````````` + +Let's say you want to make a series of manifests for a given directory structure containing `.js` test files: + +.. code-block:: text + + testing/mozmill/tests/firefox/ + testing/mozmill/tests/firefox/testAwesomeBar/ + testing/mozmill/tests/firefox/testPreferences/ + testing/mozmill/tests/firefox/testPrivateBrowsing/ + testing/mozmill/tests/firefox/testSessionStore/ + testing/mozmill/tests/firefox/testTechnicalTools/ + testing/mozmill/tests/firefox/testToolbar/ + testing/mozmill/tests/firefox/restartTests + +You can use `manifestparser create` to do this: + +.. code-block:: text + + $ manifestparser help create + Usage: manifestparser.py [options] create directory <directory> <...> + + create a manifest from a list of directories + + Options: + -p PATTERN, `pattern=PATTERN + glob pattern for files + -i IGNORE, `ignore=IGNORE + directories to ignore + -w IN_PLACE, --in-place=IN_PLACE + Write .ini files in place; filename to write to + +We only want `.js` files and we want to skip the `restartTests` directory. +We also want to write a manifest per directory, so I use the `--in-place` +option to write the manifests: + +.. code-block:: text + + manifestparser create . -i restartTests -p '*.js' -w manifest.ini + +This creates a manifest.ini per directory that we care about with the JS test files: + +.. code-block:: text + + testing/mozmill/tests/firefox/manifest.ini + testing/mozmill/tests/firefox/testAwesomeBar/manifest.ini + testing/mozmill/tests/firefox/testPreferences/manifest.ini + testing/mozmill/tests/firefox/testPrivateBrowsing/manifest.ini + testing/mozmill/tests/firefox/testSessionStore/manifest.ini + testing/mozmill/tests/firefox/testTechnicalTools/manifest.ini + testing/mozmill/tests/firefox/testToolbar/manifest.ini + +The top-level `manifest.ini` merely has `[include:]` references to the sub manifests: + +.. code-block:: text + + [include:testAwesomeBar/manifest.ini] + [include:testPreferences/manifest.ini] + [include:testPrivateBrowsing/manifest.ini] + [include:testSessionStore/manifest.ini] + [include:testTechnicalTools/manifest.ini] + [include:testToolbar/manifest.ini] + +Each sub-level manifest contains the (`.js`) test files relative to it. + +Updating the tests from manifests +````````````````````````````````` + +You may need to update tests as given in manifests from a different source directory. +`manifestparser update` was made for just this purpose: + +.. code-block:: text + + Usage: manifestparser [options] update manifest directory -tag1 -tag2 `key1=value1 --key2=value2 ... + + update the tests as listed in a manifest from a directory + +To update from a directory of tests in `~/mozmill/src/mozmill-tests/firefox/` run: + +.. code-block:: text + + manifestparser update manifest.ini ~/mozmill/src/mozmill-tests/firefox/ + +Tests +````` + +manifestparser includes a suite of tests. + +`test_manifest.txt` is a doctest that may be helpful in figuring out +how to use the API. Tests are run via `mach python-test testing/mozbase/manifestparser`. + +Using mach manifest skip-fails +`````````````````````````````` + +The first of the ``mach manifest`` subcommands is ``skip-fails``. This command +can be used to *automatically* edit manifests to skip tests that are failing +as well as file the corresponding bugs for the failures. This is particularly +useful when "greening up" a new platform. + +You may verify the proposed changes from ``skip-fails`` output and examine +any local manifest changes with ``hg status``. + +Here is the usage: + +.. code-block:: text + + $ ./mach manifest skip-fails --help + usage: mach [global arguments] manifest skip-fails [command arguments] + + Sub Command Arguments: + try_url Treeherder URL for try (please use quotes) + -b BUGZILLA, --bugzilla BUGZILLA + Bugzilla instance + -m META_BUG_ID, --meta-bug-id META_BUG_ID + Meta Bug id + -s, --turbo Skip all secondary failures + -t SAVE_TASKS, --save-tasks SAVE_TASKS + Save tasks to file + -T USE_TASKS, --use-tasks USE_TASKS + Use tasks from file + -f SAVE_FAILURES, --save-failures SAVE_FAILURES + Save failures to file + -F USE_FAILURES, --use-failures USE_FAILURES + Use failures from file + -M MAX_FAILURES, --max-failures MAX_FAILURES + Maximum number of failures to skip (-1 == no limit) + -v, --verbose Verbose mode + -d, --dry-run Determine manifest changes, but do not write them + $ + +``try_url`` --- Treeherder URL +------------------------------ +This is the url (usually in single quotes) from running tests in try, for example: +'https://treeherder.mozilla.org/jobs?repo=try&revision=babc28f495ee8af2e4f059e9cbd23e84efab7d0d' + +``--bugzilla BUGZILLA`` --- Bugzilla instance +--------------------------------------------- + +By default the Bugzilla instance is ``bugzilla.allizom.org``, but you may set it on the command +line to another value such as ``bugzilla.mozilla.org`` (or by setting the environment variable +``BUGZILLA``). + +``--meta-bug-id META_BUG_ID`` --- Meta Bug id +--------------------------------------------- + +Any new bugs that are filed will block (be dependents of) this "meta" bug (optional). + +``--turbo`` --- Skip all secondary failures +------------------------------------------- + +The default ``skip-fails`` behavior is to skip only the first failure (for a given label) for each test. +In `turbo` mode, all failures for this manifest + label will skipped. + +``--save-tasks SAVE_TASKS`` --- Save tasks to file +-------------------------------------------------- + +This feature is primarily for ``skip-fails`` development and debugging. +It will save the tasks (downloaded via mozci) to the specified JSON file +(which may be used in a future ``--use-tasks`` option) + +``--use-tasks USE_TASKS`` --- Use tasks from file +------------------------------------------------- +This feature is primarily for ``skip-fails`` development and debugging. +It will uses the tasks from the specified JSON file (instead of downloading them via mozci). +See also ``--save-tasks``. + +``--save-failures SAVE_FAILURES`` --- Save failures to file +----------------------------------------------------------- + +This feature is primarily for ``skip-fails`` development and debugging. +It will save the failures (calculated from the tasks) to the specified JSON file +(which may be used in a future ``--use-failures`` option) + +``--use-failures USE_FAILURES`` --- Use failures from file +---------------------------------------------------------- +This feature is primarily for ``skip-fails`` development and debugging. +It will uses the failures from the specified JSON file (instead of downloading them via mozci). +See also ``--save-failures``. + +``--max-failures MAX_FAILURES`` --- Maximum number of failures to skip +---------------------------------------------------------------------- +This feature is primarily for ``skip-fails`` development and debugging. +It will limit the number of failures that are skipped (default is -1 == no limit). + +``--verbose`` --- Verbose mode +------------------------------ +Increase verbosity of output. + +``--dry-run`` --- Dry run +------------------------- +In dry run mode, the manifest changes (and bugs top be filed) are determined, but not written. + + +Bugs +```` + +Please file any bugs or feature requests at + +https://bugzilla.mozilla.org/enter_bug.cgi?product=Testing&component=ManifestParser + +Or contact in #cia on irc.mozilla.org + +Design Considerations +````````````````````` + +Contrary to some opinion, manifestparser.py and the associated ``*.toml`` +format were not magically plucked from the sky but were descended upon +through several design considerations. + +* test manifests should be ordered. The current ``*.toml`` format supports + this (as did the ``*.ini`` format) + +* the manifest format should be easily human readable/writable And + programmatically editable. While the ``*.ini`` format worked for a long + time the underspecified syntax made it difficult to reliably parse. + The new ``*.toml`` format is widely accepted, as a formal syntax as well + as libraries to read and edit it (e.g. ``tomlkit``). + +* there should be a single file that may easily be + transported. Traditionally, test harnesses have lived in + mozilla-central. This is less true these days and it is increasingly + likely that more tests will not live in mozilla-central going + forward. So `manifestparser.py` should be highly consumable. To + this end, it is a single file, as appropriate to mozilla-central, + which is also a working python package deployed to PyPI for easy + installation. + +Historical Reference +```````````````````` + +Date-ordered list of links about how manifests came to be where they are today:: + +* https://wiki.mozilla.org/Auto-tools/Projects/UniversalManifest +* http://alice.nodelman.net/blog/post/2010/05/ +* http://alice.nodelman.net/blog/post/universal-manifest-for-unit-tests-a-proposal/ +* https://elvis314.wordpress.com/2010/07/05/improving-personal-hygiene-by-adjusting-mochitests/ +* https://elvis314.wordpress.com/2010/07/27/types-of-data-we-care-about-in-a-manifest/ +* https://bugzilla.mozilla.org/show_bug.cgi?id=585106 +* http://elvis314.wordpress.com/2011/05/20/converting-xpcshell-from-listing-directories-to-a-manifest/ +* https://bugzilla.mozilla.org/show_bug.cgi?id=616999 +* https://developer.mozilla.org/en/Writing_xpcshell-based_unit_tests#Adding_your_tests_to_the_xpcshell_manifest +* https://bugzilla.mozilla.org/show_bug.cgi?id=1821199 diff --git a/testing/mozbase/docs/mozcrash.rst b/testing/mozbase/docs/mozcrash.rst new file mode 100644 index 0000000000..750c46dd8f --- /dev/null +++ b/testing/mozbase/docs/mozcrash.rst @@ -0,0 +1,8 @@ +:mod:`mozcrash` --- Print stack traces from minidumps left behind by crashed processes +====================================================================================== + +Gets stack traces out of processes that have crashed and left behind +a minidump file using the Google Breakpad library. + +.. automodule:: mozcrash + :members: check_for_crashes diff --git a/testing/mozbase/docs/mozdebug.rst b/testing/mozbase/docs/mozdebug.rst new file mode 100644 index 0000000000..6a4be63f45 --- /dev/null +++ b/testing/mozbase/docs/mozdebug.rst @@ -0,0 +1,5 @@ +:mod:`mozdebug` --- Configure and launch compatible debuggers. +====================================================================================== + +.. automodule:: mozdebug + :members: get_debugger_info, get_default_debugger_name, DebuggerSearch diff --git a/testing/mozbase/docs/mozdevice.rst b/testing/mozbase/docs/mozdevice.rst new file mode 100644 index 0000000000..ea95a97d9f --- /dev/null +++ b/testing/mozbase/docs/mozdevice.rst @@ -0,0 +1,8 @@ +:mod:`mozdevice` --- Interact with Android devices +================================================== + +.. automodule:: mozdevice + :members: + :undoc-members: + :inherited-members: + :show-inheritance: diff --git a/testing/mozbase/docs/mozfile.rst b/testing/mozbase/docs/mozfile.rst new file mode 100644 index 0000000000..3ab5492e90 --- /dev/null +++ b/testing/mozbase/docs/mozfile.rst @@ -0,0 +1,9 @@ +:mod:`mozfile` --- File utilities for use in Mozilla testing +============================================================ + +mozfile is a convenience library for taking care of some common file-related +tasks in automated testing, such as extracting files or recursively removing +directories. + +.. automodule:: mozfile + :members: extract, extract_tarball, extract_zip, move, remove diff --git a/testing/mozbase/docs/mozgeckoprofiler.rst b/testing/mozbase/docs/mozgeckoprofiler.rst new file mode 100644 index 0000000000..8e1ae6090d --- /dev/null +++ b/testing/mozbase/docs/mozgeckoprofiler.rst @@ -0,0 +1,21 @@ +:mod:`mozgeckoprofiler.rst` --- Gecko Profiler utilities +======================================================== + +This module contains various utilities to work with the Firefox Profiler, Gecko's +built-in performance profiler. Gecko itself records the profiles, and can dump them +out to file once the browser shuts down. This package takes those files, symbolicates +them (turns raw memory addresses into function or symbol names), and provides utilities +like opening up a locally stored profile in the Firefox Profiler interface. This +is done by serving the profiles locally, and opening a custom url in profiler.firefox.com. + +:mod:`mozgeckoprofiler.rst` --- File origins in mozgeckoprofiler +---------------------------------------------------------------- +The symbolication files were originally imported from the following repos, +with permission from their respective authors. However, since then the code has +been updated for usage within mozbase. + +https://github.com/vdjeric/Snappy-Symbolication-Server/ +https://github.com/mstange/analyze-tryserver-profiles/ + +The dump_syms_mac binary was copied from the objdir of a Firefox build on Mac. It's a +byproduct of the regular Firefox build process and gets generated in objdir/dist/host/bin/. diff --git a/testing/mozbase/docs/mozhttpd.rst b/testing/mozbase/docs/mozhttpd.rst new file mode 100644 index 0000000000..172744e603 --- /dev/null +++ b/testing/mozbase/docs/mozhttpd.rst @@ -0,0 +1,22 @@ + +:mod:`mozhttpd` --- Serving up content to be consumed by the browser +==================================================================== + + +.. warning:: The mozhttpd module is considered obsolete. For new code, + please use wptserve_ which can do everything mozhttpd does + and more. + +.. _wptserve: https://pypi.python.org/pypi/wptserve + +:mod:`mozhttpd` --- Simple webserver +------------------------------------ + +.. automodule:: mozhttpd + :members: + +Interface +````````` + +.. autoclass:: MozHttpd + :members: diff --git a/testing/mozbase/docs/mozinfo.rst b/testing/mozbase/docs/mozinfo.rst new file mode 100644 index 0000000000..c31ff9f702 --- /dev/null +++ b/testing/mozbase/docs/mozinfo.rst @@ -0,0 +1,70 @@ +:mod:`mozinfo` --- Get system information +========================================= + +Throughout Mozilla python code, checking the underlying +platform is done in many different ways. The various checks needed +lead to a lot of copy+pasting, leaving the reader to wonder....is this +specific check necessary for (e.g.) an operating system? Because +information is not consolidated, checks are not done consistently, nor +is it defined what we are checking for. + +`mozinfo <https://hg.mozilla.org/mozilla-central/file/tip/testing/mozbase/mozinfo>`_ +proposes to solve this problem. mozinfo is a bridge interface, +making the underlying (complex) plethora of OS and architecture +combinations conform to a subset of values of relevance to +Mozilla software. The current implementation exposes relevant keys and +values such as: ``os``, ``version``, ``bits``, and ``processor``. Additionally, the +service pack in use is available on the windows platform. + + +API Usage +--------- + +mozinfo is a python package. Downloading the software and running +``python setup.py develop`` will allow you to do ``import mozinfo`` +from python. +`mozinfo.py <https://hg.mozilla.org/mozilla-central/file/tip/testing/mozbase/mozinfo/mozinfo/mozinfo.py>`_ +is the only file contained in this package, +so if you need a single-file solution, you can just download or call +this file through the web. + +The top level attributes (``os``, ``version``, ``bits``, ``processor``) are +available as module globals:: + + if mozinfo.os == 'win': ... + +In addition, mozinfo exports a dictionary, ``mozinfo.info``, that +contain these values. mozinfo also exports: + +- ``choices``: a dictionary of possible values for os, bits, and + processor +- ``main``: the console_script entry point for mozinfo +- ``unknown``: a singleton denoting a value that cannot be determined + +``unknown`` has the string representation ``"UNKNOWN"``. +``unknown`` will evaluate as ``False`` in python:: + + if not mozinfo.os: ... # unknown! + + +Command Line Usage +------------------ + +mozinfo comes with a command line program, ``mozinfo`` which may be used to +diagnose one's current system. + +Example output:: + + os: linux + version: Ubuntu 10.10 + bits: 32 + processor: x86 + +Three of these fields, os, bits, and processor, have a finite set of +choices. You may display the value of these choices using +``mozinfo --os``, ``mozinfo --bits``, and ``mozinfo --processor``. +``mozinfo --help`` documents command-line usage. + + +.. automodule:: mozinfo + :members: diff --git a/testing/mozbase/docs/mozinstall.rst b/testing/mozbase/docs/mozinstall.rst new file mode 100644 index 0000000000..7db40d73de --- /dev/null +++ b/testing/mozbase/docs/mozinstall.rst @@ -0,0 +1,29 @@ +:mod:`mozinstall` --- Install and uninstall Gecko-based applications +==================================================================== + +mozinstall is a small python module with several convenience methods +useful for installing and uninstalling a gecko-based application +(e.g. Firefox) on the desktop. + +Simple example +-------------- + +:: + + import mozinstall + import tempfile + + tempdir = tempfile.mkdtemp() + firefox_dmg = 'firefox-38.0a1.en-US.mac.dmg' + install_folder = mozinstall.install(src=firefox_dmg, dest=tempdir) + binary = mozinstall.get_binary(install_folder, 'Firefox') + # from here you can execute the binary directly + # ... + mozinstall.uninstall(install_folder) + +API Documentation +----------------- + +.. automodule:: mozinstall + :members: is_installer, install, get_binary, uninstall, + InstallError, InvalidBinary, InvalidSource diff --git a/testing/mozbase/docs/mozlog.rst b/testing/mozbase/docs/mozlog.rst new file mode 100644 index 0000000000..63e3614243 --- /dev/null +++ b/testing/mozbase/docs/mozlog.rst @@ -0,0 +1,520 @@ +:mod:`mozlog` --- Structured logging for test output +=============================================================== + +:py:mod:`mozlog` is a library designed for logging the +execution and results of test harnesses. The internal data model is a +stream of JSON-compatible objects, with one object per log entry. The +default output format is line-based, with one JSON object serialized +per line. + +:py:mod:`mozlog` is *not* based on the stdlib logging +module, although it shares several concepts with it. + +One notable difference between this module and the standard logging +module is the way that loggers are created. The structured logging +module does not require that loggers with a specific name are +singleton objects accessed through a factory function. Instead the +``StructuredLogger`` constructor may be used directly. However all +loggers with the same name share the same internal state (the "Borg" +pattern). In particular the list of handler functions is the same for +all loggers with the same name. + +Typically, you would only instantiate one logger object per +program. Two convenience methods are provided to set and get the +default logger in the program. + +Logging is threadsafe, with access to handlers protected by a +``threading.Lock``. However it is `not` process-safe. This means that +applications using multiple processes, e.g. via the +``multiprocessing`` module, should arrange for all logging to happen in +a single process. + +Data Format +----------- + +Structured loggers produce messages in a simple format designed to be +compatible with the JSON data model. Each message is a single object, +with the type of message indicated by the ``action`` key. It is +intended that the set of ``action`` values be closed; where there are +use cases for additional values they should be integrated into this +module rather than extended in an ad-hoc way. The set of keys present +on on all messages is: + +``action`` + The type of the message (string). + +``time`` + The timestamp of the message in ms since the epoch (int). + +``thread`` + The name of the thread emitting the message (string). + +``pid`` + The pid of the process creating the message (int). + +``source`` + Name of the logger creating the message (string). + +For each ``action`` there are is a further set of specific fields +describing the details of the event that caused the message to be +emitted: + +``suite_start`` + Emitted when the testsuite starts running. + + ``tests`` + A dict of test ids keyed by group. Groups are any logical grouping + of tests, for example a manifest, directory or tag. For convenience, + a list of test ids can be used instead. In this case all tests will + automatically be placed in the 'default' group name. Test ids can + either be strings or lists of strings (an example of the latter is + reftests where the id has the form [test_url, ref_type, ref_url]). + Test ids are assumed to be unique within a given testsuite. In cases + where the test list is not known upfront an empty dict or list may + be passed (dict). + + ``name`` + An optional string to identify the suite by. + + ``run_info`` + An optional dictionary describing the properties of the + build and test environment. This contains the information provided + by :doc:`mozinfo <mozinfo>`, plus a boolean ``debug`` field indicating + whether the build under test is a debug build. + +``suite_end`` + Emitted when the testsuite is finished and no more results will be produced. + +``test_start`` + Emitted when a test is being started. + + ``test`` + A unique id for the test (string or list of strings). + + ``path`` + Optional path to the test relative to some base (typically the root of the + source tree). Mainly used when ``test`` id is not a path (string). + +``test_status`` + Emitted for a test which has subtests to record the result of a + single subtest. + + ``test`` + The same unique id for the test as in the ``test_start`` message. + + ``subtest`` + Name of the subtest (string). + + ``status`` + Result of the test (string enum; ``PASS``, ``FAIL``, + ``PRECONDITION_FAILED``, ``TIMEOUT``, ``NOTRUN``) + + ``expected`` + Expected result of the test. Omitted if the expected result is the + same as the actual result (string enum, same as ``status``). + + ``known_intermittent`` + A list of known intermittent statuses for that test. Omitted if there are + no intermittent statuses expected. (items in the list are string enum, same as ``status``) + +``test_end`` + Emitted to give the result of a test with no subtests, or the status + of the overall file when there are subtests. + + ``test`` + The same unique id for the test as in the ``test_start`` message. + + ``status`` + Either result of the test (if there are no subtests) in which case + (string enum ``PASS``, ``FAIL``, ``PRECONDITION_FAILED``, + ``TIMEOUT``, ``CRASH``, ``ASSERT``, , ``SKIP``) or the status of + the overall file where there are subtests (string enum ``OK``, + ``PRECONDITION_FAILED``, ``ERROR``, ``TIMEOUT``, ``CRASH``, + ``ASSERT``, ``SKIP``). + + ``expected`` + The expected status, or omitted if the expected status matches the + actual status (string enum, same as ``status``). + + ``known_intermittent`` + A list of known intermittent statuses for that test. Omitted if there are + no intermittent statuses expected. (items in the list are string enum, same as ``status``) + +``process_output`` + Output from a managed subprocess. + + ``process`` + pid of the subprocess. + + ``command`` + Command used to launch the subprocess. + + ``data`` + Data output by the subprocess. + +``log`` + General human-readable logging message, used to debug the harnesses + themselves rather than to provide input to other tools. + + ``level`` + Level of the log message (string enum ``CRITICAL``, ``ERROR``, + ``WARNING``, ``INFO``, ``DEBUG``). + + ``message`` + Text of the log message. + +``shutdown`` + This is a special action that can only be logged once per logger state. + It is sent when calling :meth:`StructuredLogger.shutdown` or implicitly + when exiting the context manager. + +Testsuite Protocol +------------------ + +When used for testsuites, the following structured logging messages must be emitted: + + * One ``suite_start`` message before any ``test_*`` messages + + * One ``test_start`` message per test that is run + + * One ``test_status`` message per subtest that is run. This might be + zero if the test type doesn't have the notion of subtests. + + * One ``test_end`` message per test that is run, after the + ``test_start`` and any ``test_status`` messages for that same test. + + * One ``suite_end`` message after all ``test_*`` messages have been + emitted. + +The above mandatory events may be interspersed with ``process_output`` +and ``log`` events, as required. + +Subtests +~~~~~~~~ + +The purpose of subtests is to deal with situations where a single test +produces more than one result, and the exact details of the number of +results is not known ahead of time. For example consider a test +harness that loads JavaScript-based tests in a browser. Each url +loaded would be a single test, with corresponding ``test_start`` and +``test_end`` messages. If there can be more than one JS-defined test +on a page, however, it it useful to track the results of those tests +separately. Therefore each of those tests is a subtest, and one +``test_status`` message must be generated for each subtest result. + +Subtests must have a name that is unique within their parent test. + +Whether or not a test has subtests changes the meaning of the +``status`` property on the test itself. When the test does not have +any subtests, this property is the actual test result such as ``PASS`` +or ``FAIL`` . When a test does have subtests, the test itself does not +have a result as-such; it isn't meaningful to describe it as having a +``PASS`` result, especially if the subtests did not all pass. Instead +this property is used to hold information about whether the test ran +without error. If no errors were detected the test must be given the +status ``OK``. Otherwise the test may get the status ``ERROR`` (for +e.g. uncaught JS exceptions), ``TIMEOUT`` (if no results were reported +in the allowed time) or ``CRASH`` (if the test caused the process +under test to crash). + +StructuredLogger Objects +------------------------ + +.. automodule:: mozlog.structuredlog + :members: set_default_logger, get_default_logger, LoggerShutdownError + +.. autoclass:: StructuredLogger + :members: add_handler, remove_handler, handlers, suite_start, + suite_end, test_start, test_status, test_end, + process_output, critical, error, warning, info, debug, + shutdown + +.. autoclass:: StructuredLogFileLike + :members: + +ProxyLogger Objects +------------------- + +Since :func:`mozlog.structuredlog.get_default_logger` return None when +the default logger is not initialized, it is not possible to directly +use it at the module level. + +With ProxyLogger, it is possible to write the following code: :: + + from mozlog import get_proxy_logger + + LOG = get_proxy_logger('component_name') + + + def my_function(): + LOG.info('logging with a module level object') + + +.. note:: + + mozlog still needs to be initialized before the first call occurs + to a ProxyLogger instance, for example with + :func:`mozlog.commandline.setup_logging`. + +.. automodule:: mozlog.proxy + :members: get_proxy_logger, ProxyLogger + +Handlers +-------- + +A handler is a callable that is called for each log message produced +and is responsible for handling the processing of that +message. The typical example of this is a ``StreamHandler`` which takes +a log message, invokes a formatter which converts the log to a string, +and writes it to a file. + +.. automodule:: mozlog.handlers + +.. autoclass:: BaseHandler + :members: + +.. autoclass:: StreamHandler + :members: + +.. autoclass:: LogLevelFilter + :members: + +.. autoclass:: BufferHandler + :members: + +Formatters +---------- + +Formatters are callables that take a log message, and return either a +string representation of that message, or ``None`` if that message +should not appear in the output. This allows formatters to both +exclude certain items and create internal buffers of the output so +that, for example, a single string might be returned for a +``test_end`` message indicating the overall result of the test, +including data provided in the ``test_status`` messages. + +Formatter modules are written so that they can take raw input on stdin +and write formatted output on stdout. This allows the formatters to be +invoked as part of a command line for post-processing raw log files. + +.. automodule:: mozlog.formatters.base + +.. autoclass:: BaseFormatter + :members: + +.. automodule:: mozlog.formatters.unittest + +.. autoclass:: UnittestFormatter + :members: + +.. automodule:: mozlog.formatters.xunit + +.. autoclass:: XUnitFormatter + :members: + +.. automodule:: mozlog.formatters.html + +.. autoclass:: HTMLFormatter + :members: + +.. automodule:: mozlog.formatters.machformatter + +.. autoclass:: MachFormatter + :members: + +.. automodule:: mozlog.formatters.tbplformatter + +.. autoclass:: TbplFormatter + :members: + +Processing Log Files +-------------------- + +The ``mozlog.reader`` module provides utilities for working +with structured log files. + +.. automodule:: mozlog.reader + :members: + +Integration with argparse +------------------------- + +The `mozlog.commandline` module provides integration with the `argparse` +module to provide uniform logging-related command line arguments to programs +using `mozlog`. Each known formatter gets a command line argument of the form +``--log-{name}``, which takes the name of a file to log to with that format, +or ``-`` to indicate stdout. + +.. automodule:: mozlog.commandline + :members: + +Simple Examples +--------------- + +Log to stdout:: + + from mozlog import structuredlog + from mozlog import handlers, formatters + logger = structuredlog.StructuredLogger("my-test-suite") + logger.add_handler(handlers.StreamHandler(sys.stdout, + formatters.JSONFormatter())) + logger.suite_start(["test-id-1"]) + logger.test_start("test-id-1") + logger.info("This is a message with action='LOG' and level='INFO'") + logger.test_status("test-id-1", "subtest-1", "PASS") + logger.test_end("test-id-1", "OK") + logger.suite_end() + +Log with a context manager:: + + from mozlog.structuredlog import StructuredLogger + from mozlog.handlers import StreamHandler + from mozlog.formatters import JSONFormatter + + with StructuredLogger("my-test-suite") as logger: + logger.add_handler(StreamHandler(sys.stdout, + JSONFormatter())) + logger.info("This is an info message") + +Populate an ``argparse.ArgumentParser`` with logging options, and +create a logger based on the value of those options, defaulting to +JSON output on stdout if nothing else is supplied:: + + import argparse + from mozlog import commandline + + parser = argparse.ArgumentParser() + # Here one would populate the parser with other options + commandline.add_logging_group(parser) + + args = parser.parse_args() + logger = commandline.setup_logging("testsuite-name", args, {"raw": sys.stdout}) + +Count the number of tests that timed out in a testsuite:: + + from mozlog import reader + + count = 0 + + def handle_test_end(data): + global count + if data["status"] == "TIMEOUT": + count += 1 + + reader.each_log(reader.read("my_test_run.log"), + {"test_end": handle_test_end}) + + print count + +More Complete Example +--------------------- + +This example shows a complete toy testharness set up to used +structured logging. It is available as `structured_example.py <_static/structured_example.py>`_: + +.. literalinclude:: _static/structured_example.py + +Each global function with a name starting +``test_`` represents a test. A passing test returns without +throwing. A failing test throws a :py:class:`TestAssertion` exception +via the :py:func:`assert_equals` function. Throwing anything else is +considered an error in the test. There is also a :py:func:`expected` +decorator that is used to annotate tests that are expected to do +something other than pass. + +The main entry point to the test runner is via that :py:func:`main` +function. This is responsible for parsing command line +arguments, and initiating the test run. Although the test harness +itself does not provide any command line arguments, the +:py:class:`ArgumentParser` object is populated by +:py:meth:`commandline.add_logging_group`, which provides a generic +set of structured logging arguments appropriate to all tools producing +structured logging. + +The values of these command line arguments are used to create a +:py:class:`mozlog.StructuredLogger` object populated with the +specified handlers and formatters in +:py:func:`commandline.setup_logging`. The third argument to this +function is the default arguments to use. In this case the default +is to output raw (i.e. JSON-formatted) logs to stdout. + +The main test harness is provided by the :py:class:`TestRunner` +class. This class is responsible for scheduling all the tests and +logging all the results. It is passed the :py:obj:`logger` object +created from the command line arguments. The :py:meth:`run` method +starts the test run. Before the run is started it logs a +``suite_start`` message containing the id of each test that will run, +and after the testrun is done it logs a ``suite_end`` message. + +Individual tests are run in the :py:meth:`run_test` method. For each +test this logs a ``test_start`` message. It then runs the test and +logs a ``test_end`` message containing the test name, status, expected +status, and any informational message about the reason for the +result. In this test harness there are no subtests, so the +``test_end`` message has the status of the test and there are no +``test_status`` messages. + +Example Output +~~~~~~~~~~~~~~ + +When run without providing any command line options, the raw +structured log messages are sent to stdout:: + + $ python structured_example.py + + {"source": "structured-example", "tests": ["test_that_has_an_error", "test_that_fails", "test_expected_fail", "test_that_passes"], "thread": "MainThread", "time": 1401446682787, "action": "suite_start", "pid": 18456} + {"source": "structured-example", "thread": "MainThread", "time": 1401446682787, "action": "log", "message": "Running tests", "level": "INFO", "pid": 18456} + {"source": "structured-example", "test": "test_that_has_an_error", "thread": "MainThread", "time": 1401446682787, "action": "test_start", "pid": 18456} + {"status": "ERROR", "thread": "MainThread", "pid": 18456, "source": "structured-example", "test": "test_that_has_an_error", "time": 1401446682788, "action": "test_end", "message": "Traceback (most recent call last):\n File \"structured_example.py\", line 61, in run_test\n func()\n File \"structured_example.py\", line 31, in test_that_has_an_error\n assert_equals(2, 1 + \"1\")\nTypeError: unsupported operand type(s) for +: 'int' and 'str'\n", "expected": "PASS"} + {"source": "structured-example", "test": "test_that_fails", "thread": "MainThread", "time": 1401446682788, "action": "test_start", "pid": 18456} + {"status": "FAIL", "thread": "MainThread", "pid": 18456, "source": "structured-example", "test": "test_that_fails", "time": 1401446682788, "action": "test_end", "message": "1 not equal to 2", "expected": "PASS"} + {"source": "structured-example", "test": "test_expected_fail", "thread": "MainThread", "time": 1401446682788, "action": "test_start", "pid": 18456} + {"status": "FAIL", "thread": "MainThread", "pid": 18456, "source": "structured-example", "test": "test_expected_fail", "time": 1401446682788, "action": "test_end", "message": "4 not equal to 5"} + {"source": "structured-example", "test": "test_that_passes", "thread": "MainThread", "time": 1401446682788, "action": "test_start", "pid": 18456} + {"status": "PASS", "source": "structured-example", "test": "test_that_passes", "thread": "MainThread", "time": 1401446682789, "action": "test_end", "pid": 18456} + {"source": "structured-example", "test": "test_with_known_intermittent", "thread": "MainThread", "time": 1401446682789, "action": "test_start", "pid": 18456} + {"status": "FAIL", thread": "MainThread", "pid": 18456, "source": "structured-example", "test": "test_with_known_intermittent", "time": 1401446682790, "action": "test_end", "expected": "PASS", "known_intermittent": ["FAIL", "TIMEOUT"]} + {"action": "suite_end", "source": "structured-example", "pid": 18456, "thread": "MainThread", "time": 1401446682790} + +The structured logging module provides a number of command line +options:: + + $ python structured_example.py --help + + usage: structured_example.py [-h] [--log-unittest LOG_UNITTEST] + [--log-raw LOG_RAW] [--log-html LOG_HTML] + [--log-xunit LOG_XUNIT] + [--log-mach LOG_MACH] + + optional arguments: + -h, --help show this help message and exit + + Output Logging: + Options for logging output. Each option represents a possible logging + format and takes a filename to write that format to, or '-' to write to + stdout. + + --log-unittest LOG_UNITTEST + Unittest style output + --log-raw LOG_RAW Raw structured log messages + --log-html LOG_HTML HTML report + --log-xunit LOG_XUNIT + xUnit compatible XML + --log-mach LOG_MACH Human-readable output + +In order to get human-readable output on stdout and the structured log +data to go to the file ``structured.log``, we would run:: + + $ python structured_example.py --log-mach=- --log-raw=structured.log + + 0:00.00 SUITE_START: MainThread 4 + 0:01.00 LOG: MainThread INFO Running tests + 0:01.00 TEST_START: MainThread test_that_has_an_error + 0:01.00 TEST_END: MainThread Harness status ERROR, expected PASS. Subtests passed 0/0. Unexpected 1 + 0:01.00 TEST_START: MainThread test_that_fails + 0:01.00 TEST_END: MainThread Harness status FAIL, expected PASS. Subtests passed 0/0. Unexpected 1 + 0:01.00 TEST_START: MainThread test_expected_fail + 0:02.00 TEST_END: MainThread Harness status FAIL. Subtests passed 0/0. Unexpected 0 + 0:02.00 TEST_START: MainThread test_that_passes + 0:02.00 TEST_END: MainThread Harness status PASS. Subtests passed 0/0. Unexpected 0 + 0:02.00 SUITE_END: MainThread diff --git a/testing/mozbase/docs/moznetwork.rst b/testing/mozbase/docs/moznetwork.rst new file mode 100644 index 0000000000..905433e8a7 --- /dev/null +++ b/testing/mozbase/docs/moznetwork.rst @@ -0,0 +1,8 @@ +:mod:`moznetwork` --- Get network information +============================================= + +.. automodule:: moznetwork + + .. automethod:: moznetwork.get_ip + + .. autoclass:: moznetwork.NetworkError diff --git a/testing/mozbase/docs/mozpower.rst b/testing/mozbase/docs/mozpower.rst new file mode 100644 index 0000000000..76be41d987 --- /dev/null +++ b/testing/mozbase/docs/mozpower.rst @@ -0,0 +1,112 @@ +:mod:`mozpower` --- Power-usage testing +======================================= + +Mozpower provides an interface through which power usage measurements +can be done on any OS and CPU combination (auto-detected) that has +been implemented within the module. It provides 2 methods to start +and stop the measurement gathering as well as methods to get the +result that can also be formatted into a perfherder data blob. + +Basic Usage +----------- + +Although multiple classes exist within the mozpower module, +the only one that should be used is MozPower which is accessible +from the top-level of the module. It handles which subclasses +should be used depending on the detected OS and CPU combination. + +.. code-block:: python + + from mozpower import MozPower + + mp = MozPower( + ipg_measure_duration=600, + sampling_rate=1000, + output_file_path='tempdir/dataprefix' + ) + mp.initialize_power_measurements() + + # Run test TEST_NAME + + mp.finalize_power_measurements( + test_name=TEST_NAME, + output_dir_path=env['MOZ_UPLOAD_DIR'] + ) + + # Get complete PERFHERDER_DATA + perfherder_data = mp.get_full_perfherder_data('raptor') + +All the possible known errors that can occur are also provided +at the top-level of the module. + +.. code-block:: python + + from mozpower import MozPower, IPGExecutableMissingError, OsCpuComboMissingError + + try: + mp = MozPower(ipg_measure_duration=600, sampling_rate=1000) + except IPGExecutableMissingError as e: + pass + except OsCpuComboMissingError as e: + pass + + +.. automodule:: mozpower + +.. _MozPower: + +MozPower Interface +------------------ + +The following class provides a basic interface to interact with the +power measurement tools that have been implemented. The tool used +to measure power depends on the OS and CPU combination, i.e. Intel-based +MacOS machines would use Intel Power Gadget, while ARM64-based Windows +machines would use the native Windows tool powercfg. + +MozPower +```````` + +.. autoclass:: mozpower.MozPower + +Measurement methods ++++++++++++++++++++ +.. automethod:: MozPower.initialize_power_measurements(self, **kwargs) +.. automethod:: MozPower.finalize_power_measurements(self, **kwargs) + +Informational methods ++++++++++++++++++++++ +.. automethod:: MozPower.get_perfherder_data(self) +.. automethod:: MozPower.get_full_perfherder_data(self, framework, lowerisbetter=True, alertthreshold=2.0) + +IPGEmptyFileError +````````````````` +.. autoexception:: mozpower.IPGEmptyFileError + +IPGExecutableMissingError +````````````````````````` +.. autoexception:: mozpower.IPGExecutableMissingError + +IPGMissingOutputFileError +````````````````````````` +.. autoexception:: mozpower.IPGMissingOutputFileError + +IPGTimeoutError +``````````````` +.. autoexception:: mozpower.IPGTimeoutError + +IPGUnknownValueTypeError +```````````````````````` +.. autoexception:: mozpower.IPGUnknownValueTypeError + +MissingProcessorInfoError +````````````````````````` +.. autoexception:: mozpower.MissingProcessorInfoError + +OsCpuComboMissingError +`````````````````````` +.. autoexception:: mozpower.OsCpuComboMissingError + +PlatformUnsupportedError +```````````````````````` +.. autoexception:: mozpower.PlatformUnsupportedError diff --git a/testing/mozbase/docs/mozprocess.rst b/testing/mozbase/docs/mozprocess.rst new file mode 100644 index 0000000000..ef90e5aa0c --- /dev/null +++ b/testing/mozbase/docs/mozprocess.rst @@ -0,0 +1,324 @@ +:mod:`mozprocess` --- Launch and manage processes +================================================= + +Mozprocess is a process-handling module that provides some additional +features beyond those available with python's subprocess: + +* better handling of child processes, especially on Windows +* the ability to timeout the process after some absolute period, or some + period without any data written to stdout/stderr +* the ability to specify output handlers that will be called + for each line of output produced by the process +* the ability to specify handlers that will be called on process timeout + and normal process termination + +Running a process +----------------- + +mozprocess consists of two classes: ProcessHandler inherits from ProcessHandlerMixin. + +Let's see how to run a process. +First, the class should be instantiated with at least one argument which is a command (or a list formed by the command followed by its arguments). +Then the process can be launched using the *run()* method. +Finally the *wait()* method will wait until end of execution. + +.. code-block:: python + + from mozprocess import processhandler + + # under Windows replace by command = ['dir', '/a'] + command = ['ls', '-l'] + p = processhandler.ProcessHandler(command) + print("execute command: %s" % p.commandline) + p.run() + p.wait() + +Note that using *ProcessHandler* instead of *ProcessHandlerMixin* will print the output of executed command. The attribute *commandline* provides the launched command. + +Collecting process output +------------------------- + +Let's now consider a basic shell script that will print numbers from 1 to 5 waiting 1 second between each. +This script will be used as a command to launch in further examples. + +**proc_sleep_echo.sh**: + +.. code-block:: sh + + #!/bin/sh + + for i in 1 2 3 4 5 + do + echo $i + sleep 1 + done + +If you are running under Windows, you won't be able to use the previous script (unless using Cygwin). +So you'll use the following script: + +**proc_sleep_echo.bat**: + +.. code-block:: bat + + @echo off + FOR %%A IN (1 2 3 4 5) DO ( + ECHO %%A + REM if you have TIMEOUT then use it instead of PING + REM TIMEOUT /T 1 /NOBREAK + PING -n 2 127.0.0.1 > NUL + ) + +Mozprocess allows the specification of custom output handlers to gather process output while running. +ProcessHandler will by default write all outputs on stdout. You can also provide (to ProcessHandler or ProcessHandlerMixin) a function or a list of functions that will be used as callbacks on each output line generated by the process. + +In the following example the command's output will be stored in a file *output.log* and printed in stdout: + +.. code-block:: python + + import sys + from mozprocess import processhandler + + fd = open('output.log', 'w') + + def tostdout(line): + sys.stdout.write("<%s>\n" % line) + + def tofile(line): + fd.write("<%s>\n" % line) + + # under Windows you'll replace by 'proc_sleep_echo.bat' + command = './proc_sleep_echo.sh' + outputs = [tostdout, tofile] + + p = processhandler.ProcessHandlerMixin(command, processOutputLine=outputs) + p.run() + p.wait() + + fd.close() + +The process output can be saved (*obj = ProcessHandler(..., storeOutput=True)*) so as it is possible to request it (*obj.output*) at any time. Note that the default value for *stroreOutput* is *True*, so it is not necessary to provide it in the parameters. + +.. code-block:: python + + import time + import sys + from mozprocess import processhandler + + command = './proc_sleep_echo.sh' # Windows: 'proc_sleep_echo.bat' + + p = processhandler.ProcessHandler(command, storeOutput=True) + p.run() + for i in xrange(10): + print(p.output) + time.sleep(0.5) + p.wait() + +In previous example, you will see the *p.output* list growing. + +Execution +--------- + +Status +`````` + +It is possible to query the status of the process via *poll()* that will return None if the process is still running, 0 if it ended without failures and a negative value if it was killed by a signal (Unix-only). + +.. code-block:: python + + import time + import signal + from mozprocess import processhandler + + command = './proc_sleep_echo.sh' + p = processhandler.ProcessHandler(command) + p.run() + time.sleep(2) + print("poll status: %s" % p.poll()) + time.sleep(1) + p.kill(signal.SIGKILL) + print("poll status: %s" % p.poll()) + +Timeout +``````` + +A timeout can be provided to the *run()* method. If the process last more than timeout seconds, it will be stopped. + +After execution, the property *timedOut* will be set to True if a timeout was reached. + +It is also possible to provide functions (*obj = ProcessHandler[Mixin](..., onTimeout=functions)*) that will be called if the timeout was reached. + +.. code-block:: python + + from mozprocess import processhandler + + def ontimeout(): + print("REACHED TIMEOUT") + + command = './proc_sleep_echo.sh' # Windows: 'proc_sleep_echo.bat' + functions = [ontimeout] + p = processhandler.ProcessHandler(command, onTimeout=functions) + p.run(timeout=2) + p.wait() + print("timedOut = %s" % p.timedOut) + +By default the process will be killed on timeout but it is possible to prevent this by setting *kill_on_timeout* to *False*. + +.. code-block:: python + + p = processhandler.ProcessHandler(command, onTimeout=functions, kill_on_timeout=False) + p.run(timeout=2) + p.wait() + print("timedOut = %s" % p.timedOut) + +In this case, no output will be available after the timeout, but the process will still be running. + +Waiting +``````` + +It is possible to wait until the process exits as already seen with the method *wait()*, or until the end of a timeout if given. Note that in last case the process is still alive after the timeout. + +.. code-block:: python + + command = './proc_sleep_echo.sh' # Windows: 'proc_sleep_echo.bat' + p = processhandler.ProcessHandler(command) + p.run() + p.wait(timeout=2) + print("timedOut = %s" % p.timedOut) + p.wait() + +Killing +``````` + +You can request to kill the process with the method *kill*. f the parameter "ignore_children" is set to False when the process handler class is initialized, all the process's children will be killed as well. + +Except on Windows, you can specify the signal with which to kill method the process (e.g.: *kill(signal.SIGKILL)*). + +.. code-block:: python + + import time + from mozprocess import processhandler + + command = './proc_sleep_echo.sh' # Windows: 'proc_sleep_echo.bat' + p = processhandler.ProcessHandler(command) + p.run() + time.sleep(2) + p.kill() + +End of execution +```````````````` + +You can provide a function or a list of functions to call at the end of the process using the initialization parameter *onFinish*. + +.. code-block:: python + + from mozprocess import processhandler + + def finish(): + print("Finished!!") + + command = './proc_sleep_echo.sh' # Windows: 'proc_sleep_echo.bat' + + p = processhandler.ProcessHandler(command, onFinish=finish) + p.run() + p.wait() + +Child management +---------------- + +Consider the following scripts: + +**proc_child.sh**: + +.. code-block:: sh + + #!/bin/sh + for i in a b c d e + do + echo $i + sleep 1 + done + +**proc_parent.sh**: + +.. code-block:: sh + + #!/bin/sh + ./proc_child.sh + for i in 1 2 3 4 5 + do + echo $i + sleep 1 + done + +For windows users consider: + +**proc_child.bat**: + +.. code-block:: bat + + @echo off + FOR %%A IN (a b c d e) DO ( + ECHO %%A + REM TIMEOUT /T 1 /NOBREAK + PING -n 2 127.0.0.1 > NUL + ) + +**proc_parent.bat**: + +.. code-block:: bat + + @echo off + call proc_child.bat + FOR %%A IN (1 2 3 4 5) DO ( + ECHO %%A + REM TIMEOUT /T 1 /NOBREAK + PING -n 2 127.0.0.1 > NUL + ) + +For processes that launch other processes, mozprocess allows you to get child running status, wait for child termination, and kill children. + +Ignoring children +````````````````` + +By default the *ignore_children* option is False. In that case, killing the main process will kill all its children at the same time. + +.. code-block:: python + + import time + from mozprocess import processhandler + + def finish(): + print("Finished") + + command = './proc_parent.sh' + p = processhandler.ProcessHandler(command, ignore_children=False, onFinish=finish) + p.run() + time.sleep(2) + print("kill") + p.kill() + +If *ignore_children* is set to *True*, killing will apply only to the main process that will wait children end of execution before stopping (join). + +.. code-block:: python + + import time + from mozprocess import processhandler + + def finish(): + print("Finished") + + command = './proc_parent.sh' + p = processhandler.ProcessHandler(command, ignore_children=True, onFinish=finish) + p.run() + time.sleep(2) + print("kill") + p.kill() + +API Documentation +----------------- + +.. module:: mozprocess +.. autoclass:: ProcessHandlerMixin + :members: __init__, timedOut, commandline, run, kill, processOutputLine, onTimeout, onFinish, wait +.. autoclass:: ProcessHandler + :members: diff --git a/testing/mozbase/docs/mozprofile.rst b/testing/mozbase/docs/mozprofile.rst new file mode 100644 index 0000000000..d5b6e351b9 --- /dev/null +++ b/testing/mozbase/docs/mozprofile.rst @@ -0,0 +1,94 @@ +:mod:`mozprofile` --- Create and modify Mozilla application profiles +==================================================================== + +Mozprofile_ is a python tool for creating and managing profiles for Mozilla's +applications (Firefox, Thunderbird, etc.). In addition to creating profiles, +mozprofile can install addons_ and set preferences. Mozprofile can be utilized +from the command line or as an API. + +The preferred way of setting up profile data (addons, permissions, preferences +etc) is by passing them to the profile_ constructor. + +Addons +------ + +.. automodule:: mozprofile.addons + :members: + +Addons may be installed individually or from a manifest. + +Example:: + + from mozprofile import FirefoxProfile + + # create new profile to pass to mozmill/mozrunner + profile = FirefoxProfile(addons=["adblock.xpi"]) + +Command Line Interface +---------------------- + +.. automodule:: mozprofile.cli + :members: + +The profile to be operated on may be specified with the ``--profile`` +switch. If a profile is not specified, one will be created in a +temporary directory which will be echoed to the terminal:: + + (mozmill)> mozprofile + /tmp/tmp4q1iEU.mozrunner + (mozmill)> ls /tmp/tmp4q1iEU.mozrunner + user.js + +To run mozprofile from the command line enter: +``mozprofile --help`` for a list of options. + +Permissions +----------- + +.. automodule:: mozprofile.permissions + :members: + +You can set permissions by creating a ``ServerLocations`` object that you pass +to the ``Profile`` constructor. Hosts can be added to it with +``add_host(host, port)``. ``port`` can be 0. + +Preferences +----------- + +.. automodule:: mozprofile.prefs + :members: + +Preferences can be set in several ways: + +- using the API: You can make a dictionary with the preferences and pass it to + the ``Profile`` constructor. You can also add more preferences with the + ``Profile.set_preferences`` method. +- using a JSON blob file: ``mozprofile --preferences myprefs.json`` +- using a ``.ini`` file: ``mozprofile --preferences myprefs.ini`` +- via the command line: ``mozprofile --pref key:value --pref key:value [...]`` + +When setting preferences from an ``.ini`` file or the ``--pref`` switch, +the value will be interpolated as an integer or a boolean +(``true``/``false``) if possible. + +Profile +-------------------- + +.. automodule:: mozprofile.profile + :members: + +Resources +----------- +Other Mozilla programs offer additional and overlapping functionality +for profiles. There is also substantive documentation on profiles and +their management. + +- profile documentation_ + + +.. _Mozprofile: https://hg.mozilla.org/mozilla-central/file/tip/testing/mozbase/mozprofile +.. _addons: https://developer.mozilla.org/en/addons +.. _mozprofile.profile: https://hg.mozilla.org/mozilla-central/file/tip/testing/mozbase/mozprofile/mozprofile/profile.py +.. _AddonManager: https://hg.mozilla.org/mozilla-central/file/tip/testing/mozbase/mozprofile/mozprofile/addons.py +.. _here: https://hg.mozilla.org/mozilla-central/file/tip/testing/mozbase/mozprofile/mozprofile/permissions.py +.. _documentation: http://support.mozilla.com/en-US/kb/Profiles diff --git a/testing/mozbase/docs/mozproxy.rst b/testing/mozbase/docs/mozproxy.rst new file mode 100644 index 0000000000..f6863d7e22 --- /dev/null +++ b/testing/mozbase/docs/mozproxy.rst @@ -0,0 +1,46 @@ +:mod:`mozproxy` --- Provides an HTTP proxy +========================================== + +Mozproxy let you launch an HTTP proxy when we need to run tests against +third-part websites in a reliable and reproducible way. + +Mozproxy provides an interface to a proxy software, and the currently +supported backend is **mitmproxy** for Desktop and Android. + +Mozproxy is used by Raptor to run performance test without having to interact +with the real web site. + +Mozproxy provide a function that returns a playback class. The usage pattern is +:: + + from mozproxy import get_playback + + config = {'playback_tool': 'mitmproxy'} + pb = get_playback(config) + pb.start() + try: + # do your test + finally: + pb.stop() + +**config** is a dict with the following options: + +- **playback_tool**: name of the backend. can be "mitmproxy", "mitmproxy-android" +- **playback_version**: playback tool version +- **playback_files**: playback recording path/manifest/URL +- **binary**: path of the browser binary +- **obj_path**: build dir +- **platform**: platform name (provided by mozinfo.os) +- **run_local**: if True, the test is running locally. +- **app**: tested app. Can be "firefox", "geckoview", "refbrow", "fenix" or "firefox" +- **host**: hostname for the policies.json file +- **local_profile_dir**: profile dir + + +Supported environment variables: + +- **MOZPROXY_DIR**: directory used by mozproxy for all data files, set by mozproxy +- **MOZ_UPLOAD_DIR**: upload directory path +- **GECKO_HEAD_REPOSITORY**: used to find the certutils binary path from the CI +- **GECKO_HEAD_REV**: used to find the certutils binary path from the CI +- **HOSTUTILS_MANIFEST_PATH**: used to find the certutils binary path from the CI diff --git a/testing/mozbase/docs/mozrunner.rst b/testing/mozbase/docs/mozrunner.rst new file mode 100644 index 0000000000..5020e76cbb --- /dev/null +++ b/testing/mozbase/docs/mozrunner.rst @@ -0,0 +1,183 @@ +:mod:`mozrunner` --- Manage remote and local gecko processes +============================================================ + +Mozrunner provides an API to manage a gecko-based application with an +arbitrary configuration profile. It currently supports local desktop +binaries such as Firefox and Thunderbird, as well as Firefox OS on +mobile devices and emulators. + + +Basic usage +----------- + +The simplest way to use mozrunner, is to instantiate a runner, start it +and then wait for it to finish: + +.. code-block:: python + + from mozrunner import FirefoxRunner + binary = 'path/to/firefox/binary' + runner = FirefoxRunner(binary=binary) + runner.start() + runner.wait() + +This automatically creates and uses a default mozprofile object. If you +wish to use a specialized or pre-existing profile, you can create a +:doc:`mozprofile <mozprofile>` object and pass it in: + +.. code-block:: python + + from mozprofile import FirefoxProfile + from mozrunner import FirefoxRunner + import os + + binary = 'path/to/firefox/binary' + profile_path = 'path/to/profile' + if os.path.exists(profile_path): + profile = FirefoxProfile.clone(path_from=profile_path) + else: + profile = FirefoxProfile(profile=profile_path) + runner = FirefoxRunner(binary=binary, profile=profile) + runner.start() + runner.wait() + + +Handling output +--------------- + +By default, mozrunner dumps the output of the gecko process to standard output. +It is possible to add arbitrary output handlers by passing them in via the +`process_args` argument. Be careful, passing in a handler overrides the default +behaviour. So if you want to use a handler in addition to dumping to stdout, you +need to specify that explicitly. For example: + +.. code-block:: python + + from mozrunner import FirefoxRunner + + def handle_output_line(line): + do_something(line) + + binary = 'path/to/firefox/binary' + process_args = { 'stream': sys.stdout, + 'processOutputLine': [handle_output_line] } + runner = FirefoxRunner(binary=binary, process_args=process_args) + +Mozrunner uses :doc:`mozprocess <mozprocess>` to manage the underlying gecko +process and handle output. See the :doc:`mozprocess documentation <mozprocess>` +for all available arguments accepted by `process_args`. + + +Handling timeouts +----------------- + +Sometimes gecko can hang, or maybe it is just taking too long. To handle this case you +may want to set a timeout. Mozrunner has two kinds of timeouts, the +traditional `timeout`, and the `outputTimeout`. These get passed into the +`runner.start()` method. Setting `timeout` will cause gecko to be killed after +the specified number of seconds, no matter what. Setting `outputTimeout` will cause +gecko to be killed after the specified number of seconds with no output. In both +cases the process handler's `onTimeout` callbacks will be triggered. + +.. code-block:: python + + from mozrunner import FirefoxRunner + + def on_timeout(): + print('timed out after 10 seconds with no output!') + + binary = 'path/to/firefox/binary' + process_args = { 'onTimeout': on_timeout } + runner = FirefoxRunner(binary=binary, process_args=process_args) + runner.start(outputTimeout=10) + runner.wait() + +The `runner.wait()` method also accepts a timeout argument. But unlike the arguments +to `runner.start()`, this one simply returns from the wait call and does not kill the +gecko process. + +.. code-block:: python + + runner.start(timeout=100) + + waiting = 0 + while runner.wait(timeout=1) is None: + waiting += 1 + print("Been waiting for %d seconds so far.." % waiting) + assert waiting <= 100 + + +Using a device runner +--------------------- + +The previous examples used a GeckoRuntimeRunner. If you want to control a +gecko process on a remote device, you need to use a DeviceRunner. The api is +nearly identical except you don't pass in a binary, instead you create a device +object. For example to run Firefox for Android on the emulator, you might do: + +.. code-block:: python + + from mozrunner import FennecEmulatorRunner + + avd_home = 'path/to/avd' + runner = FennecEmulatorRunner(app='org.mozilla.fennec', avd_home=avd_home) + runner.start() + runner.wait() + +Device runners have a `device` object. Remember that the gecko process runs on +the device. In the case of the emulator, it is possible to start the +device independently of the gecko process. + +.. code-block:: python + + runner.device.start() # launches the emulator + runner.start() # stops the gecko process (if started), installs the profile, (re)starts the gecko process + + +Runner API Documentation +------------------------ + +Application Runners +~~~~~~~~~~~~~~~~~~~ +.. automodule:: mozrunner.runners + :members: + +BaseRunner +~~~~~~~~~~ +.. autoclass:: mozrunner.base.BaseRunner + :members: + +GeckoRuntimeRunner +~~~~~~~~~~~~~~~~~~ +.. autoclass:: mozrunner.base.GeckoRuntimeRunner + :show-inheritance: + :members: + +BlinkRuntimeRunner +~~~~~~~~~~~~~~~~~~ +.. autoclass:: mozrunner.base.BlinkRuntimeRunner + :show-inheritance: + :members: + +DeviceRunner +~~~~~~~~~~~~ +.. autoclass:: mozrunner.base.DeviceRunner + :show-inheritance: + :members: + +Device API Documentation +------------------------ + +Generally using the device classes directly shouldn't be required, but in some +cases it may be desirable. + +Device +~~~~~~ +.. autoclass:: mozrunner.devices.Device + :members: + +EmulatorAVD +~~~~~~~~~~~ +.. autoclass:: mozrunner.devices.EmulatorAVD + :show-inheritance: + :members: diff --git a/testing/mozbase/docs/mozversion.rst b/testing/mozbase/docs/mozversion.rst new file mode 100644 index 0000000000..ca2be48c1f --- /dev/null +++ b/testing/mozbase/docs/mozversion.rst @@ -0,0 +1,70 @@ +:mod:`mozversion` --- Get application information +================================================= + +`mozversion <https://hg.mozilla.org/mozilla-central/file/tip/testing/mozbase/mozversion>`_ +provides version information such as the application name and the changesets +that it has been built from. This is commonly used in reporting or for +conditional logic based on the application under test. + +API Usage +--------- + +.. automodule:: mozversion + :members: get_version + +Examples +```````` + +Firefox:: + + import mozversion + + version = mozversion.get_version(binary='/path/to/firefox') + for (key, value) in sorted(version.items()): + if value: + print '%s: %s' % (key, value) + +Firefox for Android:: + + version = mozversion.get_version(binary='path/to/firefox.apk') + print version['application_changeset'] # gets hg revision of build + +Command Line Usage +------------------ + +mozversion comes with a command line program, ``mozversion`` which may be used to +get version information from an application. + +Usage:: + + mozversion [options] + +Options +``````` + +---binary +''''''''' + +This is the path to the target application binary or .apk. If this is omitted +then the current directory is checked for the existence of an +application.ini file. If not found, then it is assumed the target +application is a remote Firefox OS instance. + +Examples +```````` + +Firefox:: + + $ mozversion --binary=/path/to/firefox-bin + application_buildid: 20131205075310 + application_changeset: 39faf812aaec + application_name: Firefox + application_repository: http://hg.mozilla.org/releases/mozilla-release + application_version: 26.0 + platform_buildid: 20131205075310 + platform_changeset: 39faf812aaec + platform_repository: http://hg.mozilla.org/releases/mozilla-release + +Firefox for Android:: + + $ mozversion --binary=/path/to/firefox.apk diff --git a/testing/mozbase/docs/requirements.txt b/testing/mozbase/docs/requirements.txt new file mode 100644 index 0000000000..53dd4ca675 --- /dev/null +++ b/testing/mozbase/docs/requirements.txt @@ -0,0 +1 @@ +marionette_client diff --git a/testing/mozbase/docs/servingcontent.rst b/testing/mozbase/docs/servingcontent.rst new file mode 100644 index 0000000000..b1960d9447 --- /dev/null +++ b/testing/mozbase/docs/servingcontent.rst @@ -0,0 +1,11 @@ +Handling content for the browser +================================ + +It's often necessary to handle data for the browser. This can be accomplished +by using a local webserver or by setting up a proxy. + +.. toctree:: + :maxdepth: 2 + + mozhttpd + mozproxy diff --git a/testing/mozbase/docs/setuprunning.rst b/testing/mozbase/docs/setuprunning.rst new file mode 100644 index 0000000000..30845a5c7a --- /dev/null +++ b/testing/mozbase/docs/setuprunning.rst @@ -0,0 +1,20 @@ +Set up and running +------------------ + +Activities under this domain include installing the software, creating +a profile (a set of configuration settings), running a program in a +controlled environment such that it can be shut down safely, and +correctly handling the case where the system crashes. + +.. toctree:: + :maxdepth: 2 + + mozfile + mozgeckoprofiler + mozinstall + mozpower + mozprofile + mozprocess + mozrunner + mozcrash + mozdebug diff --git a/testing/mozbase/manifestparser/manifestparser/__init__.py b/testing/mozbase/manifestparser/manifestparser/__init__.py new file mode 100644 index 0000000000..c8d19d9712 --- /dev/null +++ b/testing/mozbase/manifestparser/manifestparser/__init__.py @@ -0,0 +1,8 @@ +# flake8: noqa +# 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 .expression import * +from .ini import * +from .manifestparser import * diff --git a/testing/mozbase/manifestparser/manifestparser/cli.py b/testing/mozbase/manifestparser/manifestparser/cli.py new file mode 100644 index 0000000000..18fdaa88e8 --- /dev/null +++ b/testing/mozbase/manifestparser/manifestparser/cli.py @@ -0,0 +1,286 @@ +#!/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/. + +""" +Mozilla universal manifest parser +""" +import os +import sys +from optparse import OptionParser + +from .logger import Logger +from .manifestparser import ManifestParser, convert + + +class ParserError(Exception): + """error for exceptions while parsing the command line""" + + +def parse_args(_args): + """ + parse and return: + --keys=value (or --key value) + -tags + args + """ + + # return values + _dict = {} + tags = [] + args = [] + + # parse the arguments + key = None + for arg in _args: + if arg.startswith("---"): + raise ParserError("arguments should start with '-' or '--' only") + elif arg.startswith("--"): + if key: + raise ParserError("Key %s still open" % key) + key = arg[2:] + if "=" in key: + key, value = key.split("=", 1) + _dict[key] = value + key = None + continue + elif arg.startswith("-"): + if key: + raise ParserError("Key %s still open" % key) + tags.append(arg[1:]) + continue + else: + if key: + _dict[key] = arg + continue + args.append(arg) + + # return values + return (_dict, tags, args) + + +class CLICommand(object): + usage = "%prog [options] command" + + def __init__(self, parser): + self._parser = parser # master parser + self.logger = Logger() + + def parser(self): + return OptionParser( + usage=self.usage, description=self.__doc__, add_help_option=False + ) + + +class CopyCLI(CLICommand): + """ + To copy tests and manifests from a source + """ + + usage = "%prog [options] copy manifest directory -tag1 -tag2 --key1=value1 --key2=value2 ..." + + def __call__(self, global_options, args): + # parse the arguments + try: + kwargs, tags, args = parse_args(args) + except ParserError as e: + self._parser.error(str(e)) + + # make sure we have some manifests, otherwise it will + # be quite boring + if not len(args) == 2: + self.logger.error("missing arguments: manifest directory") + HelpCLI(self._parser)(global_options, ["copy"]) + return 1 + + # read the manifests + # TODO: should probably ensure these exist here + manifests = ManifestParser() + manifests.read(args[0]) + + # print the resultant query + manifests.copy(args[1], None, *tags, **kwargs) + return 0 + + +class CreateCLI(CLICommand): + """ + create a manifest from a list of directories + """ + + usage = "%prog [options] create directory <directory> <...>" + + def parser(self): + parser = CLICommand.parser(self) + parser.add_option( + "-p", "--pattern", dest="pattern", help="glob pattern for files" + ) + parser.add_option( + "-i", + "--ignore", + dest="ignore", + default=[], + action="append", + help="directories to ignore", + ) + parser.add_option( + "-w", + "--in-place", + dest="in_place", + help="Write .ini files in place; filename to write to", + ) + return parser + + def __call__(self, global_options, args): + parser = self.parser() + options, args = parser.parse_args(args) + + # need some directories + if not len(args): + self.logger.error("missing arguments: directory ...") + parser.print_usage() + return 1 + + # add the directories to the manifest + for arg in args: + assert os.path.exists(arg) + assert os.path.isdir(arg) + manifest = convert( + args, + pattern=options.pattern, + ignore=options.ignore, + write=options.in_place, + ) + if manifest: + print(manifest) + return 0 + + +class HelpCLI(CLICommand): + """ + get help on a command + """ + + usage = "%prog [options] help [command]" + + def __call__(self, global_options, args): + if len(args) == 1 and args[0] in commands: + commands[args[0]](self._parser).parser().print_help() + else: + self._parser.print_help() + print("\nCommands:") + for command in sorted(commands): + print(" %s : %s" % (command, commands[command].__doc__.strip())) + + +class UpdateCLI(CLICommand): + """ + update the tests as listed in a manifest from a directory + """ + + usage = "%prog [options] update manifest directory -tag1 -tag2 --key1=value1 --key2=value2 ..." + + def __call__(self, options, args): + # parse the arguments + try: + kwargs, tags, args = parse_args(args) + except ParserError as e: + self._parser.error(str(e)) + + # make sure we have some manifests, otherwise it will + # be quite boring + if not len(args) == 2: + self.logger.error("missing arguments: manifest directory") + HelpCLI(self._parser)(options, ["update"]) + return 1 + + # read the manifests + # TODO: should probably ensure these exist here + manifests = ManifestParser() + manifests.read(args[0]) + + # print the resultant query + manifests.update(args[1], None, *tags, **kwargs) + return 0 + + +class WriteCLI(CLICommand): + """ + write a manifest based on a query + """ + + usage = "%prog [options] write manifest <manifest> -tag1 -tag2 --key1=value1 --key2=value2 ..." + + def __call__(self, options, args): + # parse the arguments + try: + kwargs, tags, args = parse_args(args) + except ParserError as e: + self._parser.error(str(e)) + + # make sure we have some manifests, otherwise it will + # be quite boring + if not args: + self.logger.error("missing arguments: manifest ...") + HelpCLI(self._parser)(options, ["write"]) + return 1 + + # read the manifests + # TODO: should probably ensure these exist here + manifests = ManifestParser() + manifests.read(*args) + + # print the resultant query + manifests.write(global_tags=tags, global_kwargs=kwargs) + return 0 + + +# command -> class mapping +commands = { + "copy": CopyCLI, + "create": CreateCLI, + "help": HelpCLI, + "update": UpdateCLI, + "write": WriteCLI, +} + + +def main(args=sys.argv[1:]): + """console_script entry point""" + + # set up an option parser + usage = "%prog [options] [command] ..." + description = "%s. Use `help` to display commands" % __doc__.strip() + parser = OptionParser(usage=usage, description=description) + parser.add_option( + "-s", + "--strict", + dest="strict", + action="store_true", + default=False, + help="adhere strictly to errors", + ) + parser.disable_interspersed_args() + + global_options, args = parser.parse_args(args) + + if not args: + HelpCLI(parser)(global_options, args) + parser.exit() + + # get the command + command = args[0] + if command not in commands: + parser.error( + "Command must be one of %s (you gave '%s')" + % (", ".join(sorted(commands.keys())), command) + ) + return 1 + + handler = commands[command](parser) + return handler(global_options, args[1:]) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/testing/mozbase/manifestparser/manifestparser/expression.py b/testing/mozbase/manifestparser/manifestparser/expression.py new file mode 100644 index 0000000000..bea29b1f0c --- /dev/null +++ b/testing/mozbase/manifestparser/manifestparser/expression.py @@ -0,0 +1,324 @@ +# 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/. + +import re +import sys +import traceback + +__all__ = ["parse", "ParseError", "ExpressionParser"] + +# expr.py +# from: +# http://k0s.org/mozilla/hg/expressionparser +# http://hg.mozilla.org/users/tmielczarek_mozilla.com/expressionparser + +# Implements a top-down parser/evaluator for simple boolean expressions. +# ideas taken from http://effbot.org/zone/simple-top-down-parsing.htm +# +# Rough grammar: +# expr := literal +# | '(' expr ')' +# | expr '&&' expr +# | expr '||' expr +# | expr '==' expr +# | expr '!=' expr +# | expr '<' expr +# | expr '>' expr +# | expr '<=' expr +# | expr '>=' expr +# literal := BOOL +# | INT +# | STRING +# | IDENT +# BOOL := true|false +# INT := [0-9]+ +# STRING := "[^"]*" +# IDENT := [A-Za-z_]\w* + +# Identifiers take their values from a mapping dictionary passed as the second +# argument. + +# Glossary (see above URL for details): +# - nud: null denotation +# - led: left detonation +# - lbp: left binding power +# - rbp: right binding power + + +class ident_token(object): + def __init__(self, scanner, value): + self.value = value + + def nud(self, parser): + # identifiers take their value from the value mappings passed + # to the parser + return parser.value(self.value) + + +class literal_token(object): + def __init__(self, scanner, value): + self.value = value + + def nud(self, parser): + return self.value + + +class eq_op_token(object): + "==" + + def led(self, parser, left): + return left == parser.expression(self.lbp) + + +class neq_op_token(object): + "!=" + + def led(self, parser, left): + return left != parser.expression(self.lbp) + + +class lt_op_token(object): + "<" + + def led(self, parser, left): + return left < parser.expression(self.lbp) + + +class gt_op_token(object): + ">" + + def led(self, parser, left): + return left > parser.expression(self.lbp) + + +class le_op_token(object): + "<=" + + def led(self, parser, left): + return left <= parser.expression(self.lbp) + + +class ge_op_token(object): + ">=" + + def led(self, parser, left): + return left >= parser.expression(self.lbp) + + +class not_op_token(object): + "!" + + def nud(self, parser): + return not parser.expression(100) + + +class and_op_token(object): + "&&" + + def led(self, parser, left): + right = parser.expression(self.lbp) + return left and right + + +class or_op_token(object): + "||" + + def led(self, parser, left): + right = parser.expression(self.lbp) + return left or right + + +class lparen_token(object): + "(" + + def nud(self, parser): + expr = parser.expression() + parser.advance(rparen_token) + return expr + + +class rparen_token(object): + ")" + + +class end_token(object): + """always ends parsing""" + + +# derived literal tokens + + +class bool_token(literal_token): + def __init__(self, scanner, value): + value = {"true": True, "false": False}[value] + literal_token.__init__(self, scanner, value) + + +class int_token(literal_token): + def __init__(self, scanner, value): + literal_token.__init__(self, scanner, int(value)) + + +class string_token(literal_token): + def __init__(self, scanner, value): + literal_token.__init__(self, scanner, value[1:-1]) + + +precedence = [ + (end_token, rparen_token), + (or_op_token,), + (and_op_token,), + (lt_op_token, gt_op_token, le_op_token, ge_op_token, eq_op_token, neq_op_token), + (lparen_token,), +] +for index, rank in enumerate(precedence): + for token in rank: + token.lbp = index # lbp = lowest left binding power + + +class ParseError(Exception): + """error parsing conditional expression""" + + +class ExpressionParser(object): + r""" + A parser for a simple expression language. + + The expression language can be described as follows:: + + EXPRESSION ::= LITERAL | '(' EXPRESSION ')' | '!' EXPRESSION | EXPRESSION OP EXPRESSION + OP ::= '==' | '!=' | '<' | '>' | '<=' | '>=' | '&&' | '||' + LITERAL ::= BOOL | INT | IDENT | STRING + BOOL ::= 'true' | 'false' + INT ::= [0-9]+ + IDENT ::= [a-zA-Z_]\w* + STRING ::= '"' [^\"] '"' | ''' [^\'] ''' + + At its core, expressions consist of booleans, integers, identifiers and. + strings. Booleans are one of *true* or *false*. Integers are a series + of digits. Identifiers are a series of English letters and underscores. + Strings are a pair of matching quote characters (single or double) with + zero or more characters inside. + + Expressions can be combined with operators: the equals (==) and not + equals (!=) operators compare two expressions and produce a boolean. The + and (&&) and or (||) operators take two expressions and produce the logical + AND or OR value of them, respectively. An expression can also be prefixed + with the not (!) operator, which produces its logical negation. + + Finally, any expression may be contained within parentheses for grouping. + + Identifiers take their values from the mapping provided. + """ + + scanner = None + + def __init__(self, text, valuemapping, strict=False): + """ + Initialize the parser + :param text: The expression to parse as a string. + :param valuemapping: A dict mapping identifier names to values. + :param strict: If true, referencing an identifier that was not + provided in :valuemapping: will raise an error. + """ + self.text = text + self.valuemapping = valuemapping + self.strict = strict + + def _tokenize(self): + """ + Lex the input text into tokens and yield them in sequence. + """ + if not ExpressionParser.scanner: + ExpressionParser.scanner = re.Scanner( + [ + # Note: keep these in sync with the class docstring above. + (r"true|false", bool_token), + (r"[a-zA-Z_]\w*", ident_token), + (r"[0-9]+", int_token), + (r'("[^"]*")|(\'[^\']*\')', string_token), + (r"==", eq_op_token()), + (r"!=", neq_op_token()), + (r"<=", le_op_token()), + (r">=", ge_op_token()), + (r"<", lt_op_token()), + (r">", gt_op_token()), + (r"\|\|", or_op_token()), + (r"!", not_op_token()), + (r"&&", and_op_token()), + (r"\(", lparen_token()), + (r"\)", rparen_token()), + (r"\s+", None), # skip whitespace + ] + ) + tokens, remainder = ExpressionParser.scanner.scan(self.text) + for t in tokens: + yield t + yield end_token() + + def value(self, ident): + """ + Look up the value of |ident| in the value mapping passed in the + constructor. + """ + if self.strict: + return self.valuemapping[ident] + else: + return self.valuemapping.get(ident, "") + + def advance(self, expected): + """ + Assert that the next token is an instance of |expected|, and advance + to the next token. + """ + if not isinstance(self.token, expected): + raise Exception("Unexpected token!") + self.token = next(self.iter) + + def expression(self, rbp=0): + """ + Parse and return the value of an expression until a token with + right binding power greater than rbp is encountered. + """ + t = self.token + self.token = next(self.iter) + left = t.nud(self) + while rbp < self.token.lbp: + t = self.token + self.token = next(self.iter) + left = t.led(self, left) + return left + + def parse(self): + """ + Parse and return the value of the expression in the text + passed to the constructor. Raises a ParseError if the expression + could not be parsed. + """ + try: + self.iter = self._tokenize() + self.token = next(self.iter) + return self.expression() + except Exception: + extype, ex, tb = sys.exc_info() + formatted = "".join(traceback.format_exception_only(extype, ex)) + pe = ParseError( + "could not parse: %s\nexception: %svariables: %s" + % (self.text, formatted, self.valuemapping) + ) + raise pe.with_traceback(tb) + + __call__ = parse + + +def parse(text, **values): + """ + Parse and evaluate a boolean expression. + :param text: The expression to parse, as a string. + :param values: A dict containing a name to value mapping for identifiers + referenced in *text*. + :rtype: the final value of the expression. + :raises: :py:exc::ParseError: will be raised if parsing fails. + """ + return ExpressionParser(text, values).parse() diff --git a/testing/mozbase/manifestparser/manifestparser/filters.py b/testing/mozbase/manifestparser/manifestparser/filters.py new file mode 100644 index 0000000000..3191f00cc9 --- /dev/null +++ b/testing/mozbase/manifestparser/manifestparser/filters.py @@ -0,0 +1,557 @@ +# 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/. + +""" +A filter is a callable that accepts an iterable of test objects and a +dictionary of values, and returns a new iterable of test objects. It is +possible to define custom filters if the built-in ones are not enough. +""" + +import itertools +import os +from collections import defaultdict +from collections.abc import MutableSequence + +from .expression import ParseError, parse +from .logger import Logger +from .util import normsep + +# built-in filters + + +def _match(exprs, **values): + if any(parse(e, **values) for e in exprs.splitlines() if e): + return True + return False + + +def skip_if(tests, values): + """ + Sets disabled on all tests containing the `skip-if` tag and whose condition + is True. This filter is added by default. + """ + tag = "skip-if" + for test in tests: + if tag in test and _match(test[tag], **values): + test.setdefault("disabled", "{}: {}".format(tag, test[tag])) + yield test + + +def run_if(tests, values): + """ + Sets disabled on all tests containing the `run-if` tag and whose condition + is False. This filter is added by default. + """ + tag = "run-if" + for test in tests: + if tag in test and not _match(test[tag], **values): + test.setdefault("disabled", "{}: {}".format(tag, test[tag])) + yield test + + +def fail_if(tests, values): + """ + Sets expected to 'fail' on all tests containing the `fail-if` tag and whose + condition is True. This filter is added by default. + """ + tag = "fail-if" + for test in tests: + if tag in test and _match(test[tag], **values): + test["expected"] = "fail" + yield test + + +def enabled(tests, values): + """ + Removes all tests containing the `disabled` key. This filter can be + added by passing `disabled=False` into `active_tests`. + """ + for test in tests: + if "disabled" not in test: + yield test + + +def exists(tests, values): + """ + Removes all tests that do not exist on the file system. This filter is + added by default, but can be removed by passing `exists=False` into + `active_tests`. + """ + for test in tests: + if os.path.exists(test["path"]): + yield test + + +# built-in instance filters + + +class InstanceFilter(object): + """ + Generally only one instance of a class filter should be applied at a time. + Two instances of `InstanceFilter` are considered equal if they have the + same class name. This ensures only a single instance is ever added to + `filterlist`. This class also formats filters' __str__ method for easier + debugging. + """ + + unique = True + + __hash__ = super.__hash__ + + def __init__(self, *args, **kwargs): + self.fmt_args = ", ".join( + itertools.chain( + [str(a) for a in args], + ["{}={}".format(k, v) for k, v in kwargs.items()], + ) + ) + + def __eq__(self, other): + if self.unique: + return self.__class__ == other.__class__ + return self.__hash__() == other.__hash__() + + def __str__(self): + return "{}({})".format(self.__class__.__name__, self.fmt_args) + + +class subsuite(InstanceFilter): + """ + If `name` is None, removes all tests that have a `subsuite` key. + Otherwise removes all tests that do not have a subsuite matching `name`. + + It is possible to specify conditional subsuite keys using: + subsuite = foo,condition + + where 'foo' is the subsuite name, and 'condition' is the same type of + condition used for skip-if. If the condition doesn't evaluate to true, + the subsuite designation will be removed from the test. + + :param name: The name of the subsuite to run (default None) + """ + + def __init__(self, name=None): + InstanceFilter.__init__(self, name=name) + self.name = name + + def __call__(self, tests, values): + # Look for conditional subsuites, and replace them with the subsuite + # itself (if the condition is true), or nothing. + for test in tests: + subsuite = test.get("subsuite", "") + if "," in subsuite: + try: + subsuite, cond = subsuite.split(",") + except ValueError: + raise ParseError("subsuite condition can't contain commas") + matched = parse(cond, **values) + if matched: + test["subsuite"] = subsuite + else: + test["subsuite"] = "" + + # Filter on current subsuite + if self.name is None: + if not test.get("subsuite"): + yield test + elif test.get("subsuite", "") == self.name: + yield test + + +class chunk_by_slice(InstanceFilter): + """ + Basic chunking algorithm that splits tests evenly across total chunks. + + :param this_chunk: the current chunk, 1 <= this_chunk <= total_chunks + :param total_chunks: the total number of chunks + :param disabled: Whether to include disabled tests in the chunking + algorithm. If False, each chunk contains an equal number + of non-disabled tests. If True, each chunk contains an + equal number of tests (default False) + """ + + def __init__(self, this_chunk, total_chunks, disabled=False): + assert 1 <= this_chunk <= total_chunks + InstanceFilter.__init__(self, this_chunk, total_chunks, disabled=disabled) + self.this_chunk = this_chunk + self.total_chunks = total_chunks + self.disabled = disabled + + def __call__(self, tests, values): + tests = list(tests) + if self.disabled: + chunk_tests = tests[:] + else: + chunk_tests = [t for t in tests if "disabled" not in t] + + tests_per_chunk = float(len(chunk_tests)) / self.total_chunks + # pylint: disable=W1633 + start = int(round((self.this_chunk - 1) * tests_per_chunk)) + end = int(round(self.this_chunk * tests_per_chunk)) + + if not self.disabled: + # map start and end back onto original list of tests. Disabled + # tests will still be included in the returned list, but each + # chunk will contain an equal number of enabled tests. + if self.this_chunk == 1: + start = 0 + elif start < len(chunk_tests): + start = tests.index(chunk_tests[start]) + + if self.this_chunk == self.total_chunks: + end = len(tests) + elif end < len(chunk_tests): + end = tests.index(chunk_tests[end]) + return (t for t in tests[start:end]) + + +class chunk_by_dir(InstanceFilter): + """ + Basic chunking algorithm that splits directories of tests evenly at a + given depth. + + For example, a depth of 2 means all test directories two path nodes away + from the base are gathered, then split evenly across the total number of + chunks. The number of tests in each of the directories is not taken into + account (so chunks will not contain an even number of tests). All test + paths must be relative to the same root (typically the root of the source + repository). + + :param this_chunk: the current chunk, 1 <= this_chunk <= total_chunks + :param total_chunks: the total number of chunks + :param depth: the minimum depth of a subdirectory before it will be + considered unique + """ + + def __init__(self, this_chunk, total_chunks, depth): + InstanceFilter.__init__(self, this_chunk, total_chunks, depth) + self.this_chunk = this_chunk + self.total_chunks = total_chunks + self.depth = depth + + def __call__(self, tests, values): + tests_by_dir = defaultdict(list) + ordered_dirs = [] + for test in tests: + path = test["relpath"] + + if path.startswith(os.sep): + path = path[1:] + + dirs = path.split(os.sep) + dirs = dirs[: min(self.depth, len(dirs) - 1)] + path = os.sep.join(dirs) + + # don't count directories that only have disabled tests in them, + # but still yield disabled tests that are alongside enabled tests + if path not in ordered_dirs and "disabled" not in test: + ordered_dirs.append(path) + tests_by_dir[path].append(test) + + # pylint: disable=W1633 + tests_per_chunk = float(len(ordered_dirs)) / self.total_chunks + start = int(round((self.this_chunk - 1) * tests_per_chunk)) + end = int(round(self.this_chunk * tests_per_chunk)) + + for i in range(start, end): + for test in tests_by_dir.pop(ordered_dirs[i]): + yield test + + # find directories that only contain disabled tests. They still need to + # be yielded for reporting purposes. Put them all in chunk 1 for + # simplicity. + if self.this_chunk == 1: + disabled_dirs = [ + v for k, v in tests_by_dir.items() if k not in ordered_dirs + ] + for disabled_test in itertools.chain(*disabled_dirs): + yield disabled_test + + +class chunk_by_manifest(InstanceFilter): + """ + Chunking algorithm that tries to evenly distribute tests while ensuring + tests in the same manifest stay together. + + :param this_chunk: the current chunk, 1 <= this_chunk <= total_chunks + :param total_chunks: the total number of chunks + """ + + def __init__(self, this_chunk, total_chunks, *args, **kwargs): + InstanceFilter.__init__(self, this_chunk, total_chunks, *args, **kwargs) + self.this_chunk = this_chunk + self.total_chunks = total_chunks + + def __call__(self, tests, values): + tests = list(tests) + manifests = set(t["manifest"] for t in tests) + + tests_by_manifest = [] + for manifest in manifests: + mtests = [t for t in tests if t["manifest"] == manifest] + tests_by_manifest.append(mtests) + # Sort tests_by_manifest from largest manifest to shortest; include + # manifest name as secondary key to ensure consistent order across + # multiple runs. + tests_by_manifest.sort(reverse=True, key=lambda x: (len(x), x[0]["manifest"])) + + tests_by_chunk = [[] for i in range(self.total_chunks)] + for batch in tests_by_manifest: + # Sort to guarantee the chunk with the lowest score will always + # get the next batch of tests. + tests_by_chunk.sort( + key=lambda x: (len(x), x[0]["manifest"] if len(x) else "") + ) + tests_by_chunk[0].extend(batch) + + return (t for t in tests_by_chunk[self.this_chunk - 1]) + + +class chunk_by_runtime(InstanceFilter): + """ + Chunking algorithm that attempts to group tests into chunks based on their + average runtimes. It keeps manifests of tests together and pairs slow + running manifests with fast ones. + + :param this_chunk: the current chunk, 1 <= this_chunk <= total_chunks + :param total_chunks: the total number of chunks + :param runtimes: dictionary of manifest runtime data, of the form + {<manifest path>: <average runtime>} + """ + + def __init__(self, this_chunk, total_chunks, runtimes): + InstanceFilter.__init__(self, this_chunk, total_chunks, runtimes) + self.this_chunk = this_chunk + self.total_chunks = total_chunks + self.runtimes = {normsep(m): r for m, r in runtimes.items()} + self.logger = Logger() + + @classmethod + def get_manifest(cls, test): + manifest = normsep(test.get("ancestor_manifest", "")) + + # Ignore ancestor_manifests that live at the root (e.g, don't have a + # path separator). The only time this should happen is when they are + # generated by the build system and we shouldn't count generated + # manifests for chunking purposes. + if not manifest or "/" not in manifest: + manifest = normsep(test["manifest_relpath"]) + return manifest + + def get_chunked_manifests(self, manifests): + # Find runtimes for all relevant manifests. + runtimes = [(self.runtimes[m], m) for m in manifests if m in self.runtimes] + + # Compute the average to use as a default for manifests that don't exist. + times = [r[0] for r in runtimes] + # pylint --py3k W1619 + # pylint: disable=W1633 + avg = round(sum(times) / len(times), 2) if times else 0 + missing = sorted([m for m in manifests if m not in self.runtimes]) + self.logger.debug( + "Applying average runtime of {}s to the following missing manifests:\n{}".format( + avg, " " + "\n ".join(missing) + ) + ) + runtimes.extend([(avg, m) for m in missing]) + + # Each chunk is of the form [<runtime>, <manifests>]. + chunks = [[0, []] for i in range(self.total_chunks)] + + # Sort runtimes from slowest -> fastest. + for runtime, manifest in sorted(runtimes, reverse=True): + # Sort chunks from fastest -> slowest. This guarantees the fastest + # chunk will be assigned the slowest remaining manifest. + chunks.sort(key=lambda x: (x[0], len(x[1]), x[1])) + chunks[0][0] += runtime + chunks[0][1].append(manifest) + + # Sort one last time so we typically get chunks ordered from fastest to + # slowest. + chunks.sort(key=lambda x: (x[0], len(x[1]))) + return chunks + + def __call__(self, tests, values): + tests = list(tests) + manifests = set(self.get_manifest(t) for t in tests) + chunks = self.get_chunked_manifests(manifests) + runtime, this_manifests = chunks[self.this_chunk - 1] + # pylint --py3k W1619 + # pylint: disable=W1633 + self.logger.debug( + "Cumulative test runtime is around {} minutes (average is {} minutes)".format( + round(runtime / 60), + round(sum([c[0] for c in chunks]) / (60 * len(chunks))), + ) + ) + return (t for t in tests if self.get_manifest(t) in this_manifests) + + +class tags(InstanceFilter): + """ + Removes tests that don't contain any of the given tags. This overrides + InstanceFilter's __eq__ method, so multiple instances can be added. + Multiple tag filters is equivalent to joining tags with the AND operator. + + To define a tag in a manifest, add a `tags` attribute to a test or DEFAULT + section. Tests can have multiple tags, in which case they should be + whitespace delimited. For example: + + .. code-block:: toml + + ['test_foobar.html'] + tags = 'foo bar' + + :param tags: A tag or list of tags to filter tests on + """ + + unique = False + + def __init__(self, tags): + InstanceFilter.__init__(self, tags) + if isinstance(tags, str): + tags = [tags] + self.tags = tags + + def __call__(self, tests, values): + for test in tests: + if "tags" not in test: + continue + + test_tags = [t.strip() for t in test["tags"].split()] + if any(t in self.tags for t in test_tags): + yield test + + +class failures(InstanceFilter): + """ + .. code-block:: toml + + ['test_fooar.html'] + fail-if = [ + "keyword", # <comment> + ] + + :param keywords: A keyword to filter tests on + """ + + def __init__(self, keyword): + InstanceFilter.__init__(self, keyword) + self.keyword = keyword.strip('"') + + def __call__(self, tests, values): + for test in tests: + for key in ["skip-if", "fail-if"]: + if key not in test: + continue + + matched = [ + self.keyword in e and parse(e, **values) + for e in test[key].splitlines() + if e + ] + if any(matched): + test["expected"] = "fail" + yield test + + +class pathprefix(InstanceFilter): + """ + Removes tests that don't start with any of the given test paths. + + :param paths: A list of test paths (or manifests) to filter on + """ + + def __init__(self, paths): + InstanceFilter.__init__(self, paths) + if isinstance(paths, str): + paths = [paths] + self.paths = paths + self.missing = set() + + def __call__(self, tests, values): + seen = set() + for test in tests: + for testpath in self.paths: + tp = os.path.normpath(testpath) + + if tp.endswith(".ini") or tp.endswith(".toml"): + mpaths = [test["manifest_relpath"]] + if "ancestor_manifest" in test: + mpaths.append(test["ancestor_manifest"]) + + if os.path.isabs(tp): + root = test["manifest"][: -len(test["manifest_relpath"]) - 1] + mpaths = [os.path.join(root, m) for m in mpaths] + + # only return tests that are in this manifest + if not any(os.path.normpath(m) == tp for m in mpaths): + continue + else: + # only return tests that start with this path + path = test["relpath"] + if os.path.isabs(tp): + path = test["path"] + + if not os.path.normpath(path).startswith(tp): + continue + + # any test path that points to a single file will be run no + # matter what, even if it's disabled + if "disabled" in test and os.path.normpath(test["relpath"]) == tp: + del test["disabled"] + + seen.add(tp) + yield test + break + + self.missing = set(self.paths) - seen + + +# filter container + +DEFAULT_FILTERS = ( + skip_if, + run_if, + fail_if, +) +""" +By default :func:`~.active_tests` will run the :func:`~.skip_if`, +:func:`~.run_if` and :func:`~.fail_if` filters. +""" + + +class filterlist(MutableSequence): + """ + A MutableSequence that raises TypeError when adding a non-callable and + ValueError if the item is already added. + """ + + def __init__(self, items=None): + self.items = [] + if items: + self.items = list(items) + + def _validate(self, item): + if not callable(item): + raise TypeError("Filters must be callable!") + if item in self: + raise ValueError("Filter {} is already applied!".format(item)) + + def __getitem__(self, key): + return self.items[key] + + def __setitem__(self, key, value): + self._validate(value) + self.items[key] = value + + def __delitem__(self, key): + del self.items[key] + + def __len__(self): + return len(self.items) + + def insert(self, index, value): + self._validate(value) + self.items.insert(index, value) diff --git a/testing/mozbase/manifestparser/manifestparser/ini.py b/testing/mozbase/manifestparser/manifestparser/ini.py new file mode 100644 index 0000000000..b5ffe7a2f0 --- /dev/null +++ b/testing/mozbase/manifestparser/manifestparser/ini.py @@ -0,0 +1,208 @@ +# 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/. + +import io +import os +import sys + +__all__ = ["read_ini", "combine_fields"] + + +class IniParseError(Exception): + def __init__(self, fp, linenum, msg): + if isinstance(fp, str): + path = fp + elif hasattr(fp, "name"): + path = fp.name + else: + path = getattr(fp, "path", "unknown") + msg = "Error parsing manifest file '{}', line {}: {}".format(path, linenum, msg) + super(IniParseError, self).__init__(msg) + + +def read_ini( + fp, + defaults=None, + default="DEFAULT", + comments=None, + separators=None, + strict=True, + handle_defaults=True, + document=False, +): + """ + read an .ini file and return a list of [(section, values)] + - fp : file pointer or path to read + - defaults : default set of variables + - default : name of the section for the default section + - comments : characters that if they start a line denote a comment + - separators : strings that denote key, value separation in order + - strict : whether to be strict about parsing + - handle_defaults : whether to incorporate defaults into each section + """ + + # variables + defaults = defaults or {} + default_section = {} + comments = comments or ("#",) + separators = separators or ("=", ":") + sections = [] + key = value = None + section_names = set() + if isinstance(fp, str): + fp = io.open(fp, encoding="utf-8") + + # read the lines + section = default + current_section = {} + current_section_name = "" + key_indent = 0 + for linenum, line in enumerate(fp.read().splitlines(), start=1): + stripped = line.strip() + + # ignore blank lines + if not stripped: + # reset key and value to avoid continuation lines + key = value = None + continue + + # ignore comment lines + if any(stripped.startswith(c) for c in comments): + continue + + # strip inline comments (borrowed from configparser) + comment_start = sys.maxsize + inline_prefixes = {p: -1 for p in comments} + while comment_start == sys.maxsize and inline_prefixes: + next_prefixes = {} + for prefix, i in inline_prefixes.items(): + index = stripped.find(prefix, i + 1) + if index == -1: + continue + next_prefixes[prefix] = index + if index == 0 or (index > 0 and stripped[index - 1].isspace()): + comment_start = min(comment_start, index) + inline_prefixes = next_prefixes + + if comment_start != sys.maxsize: + stripped = stripped[:comment_start].rstrip() + + # check for a new section + if len(stripped) > 2 and stripped[0] == "[" and stripped[-1] == "]": + section = stripped[1:-1].strip() + key = value = None + key_indent = 0 + + # deal with DEFAULT section + if section.lower() == default.lower(): + if strict: + assert default not in section_names + section_names.add(default) + current_section = default_section + current_section_name = "DEFAULT" + continue + + if strict: + # make sure this section doesn't already exist + assert ( + section not in section_names + ), "Section '%s' already found in '%s'" % (section, section_names) + + section_names.add(section) + current_section = {} + current_section_name = section + sections.append((section, current_section)) + continue + + # if there aren't any sections yet, something bad happen + if not section_names: + raise IniParseError( + fp, + linenum, + "Expected a comment or section, " "instead found '{}'".format(stripped), + ) + + # continuation line ? + line_indent = len(line) - len(line.lstrip(" ")) + if key and line_indent > key_indent: + value = "%s%s%s" % (value, os.linesep, stripped) + if strict: + # make sure the value doesn't contain assignments + if " = " in value: + raise IniParseError( + fp, + linenum, + "Should not assign in {} condition for {}".format( + key, current_section_name + ), + ) + current_section[key] = value + continue + + # (key, value) pair + for separator in separators: + if separator in stripped: + key, value = stripped.split(separator, 1) + key = key.strip() + value = value.strip() + key_indent = line_indent + + # make sure this key isn't already in the section + if key: + assert ( + key not in current_section + ), f"Found duplicate key {key} in section {section}" + + if strict: + # make sure this key isn't empty + assert key + # make sure the value doesn't contain assignments + if " = " in value: + raise IniParseError( + fp, + linenum, + "Should not assign in {} condition for {}".format( + key, current_section_name + ), + ) + + current_section[key] = value + break + else: + # something bad happened! + raise IniParseError(fp, linenum, "Unexpected line '{}'".format(stripped)) + + # merge global defaults with the DEFAULT section + defaults = combine_fields(defaults, default_section) + if handle_defaults: + # merge combined defaults into each section + sections = [(i, combine_fields(defaults, j)) for i, j in sections] + return sections, defaults, None + + +def combine_fields(global_vars, local_vars): + """ + Combine the given manifest entries according to the semantics of specific fields. + This is used to combine manifest level defaults with a per-test definition. + """ + if not global_vars: + return local_vars + if not local_vars: + return global_vars.copy() + field_patterns = { + "args": "%s %s", + "prefs": "%s %s", + "skip-if": "%s\n%s", # consider implicit logical OR: "%s ||\n%s" + "support-files": "%s %s", + } + final_mapping = global_vars.copy() + for field_name, value in local_vars.items(): + if field_name not in field_patterns or field_name not in global_vars: + final_mapping[field_name] = value + continue + global_value = global_vars[field_name] + pattern = field_patterns[field_name] + final_mapping[field_name] = pattern % (global_value, value) + + return final_mapping diff --git a/testing/mozbase/manifestparser/manifestparser/logger.py b/testing/mozbase/manifestparser/manifestparser/logger.py new file mode 100644 index 0000000000..807f959098 --- /dev/null +++ b/testing/mozbase/manifestparser/manifestparser/logger.py @@ -0,0 +1,76 @@ +# 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/. + +import os + + +class Logger(object): + """ + ManifestParser needs to ensure a singleton for mozlog as documented here: + + https://firefox-source-docs.mozilla.org/mozbase/mozlog.html#mozlog-structured-logging-for-test-output + + Logging is threadsafe, with access to handlers protected by a + threading.Lock. However it is not process-safe. This means that + applications using multiple processes, e.g. via the multiprocessing + module, should arrange for all logging to happen in a single process. + + The test: + `testing/mochitest/tests/python/test_mochitest_integration.py::test_output_testfile_in_dupe_manifests` + creates two ManifestParser instances and runs them at the same + tripping over the condition (above) resulting in this exception: + + [task 2023-08-02T17:16:41.636Z] File "/builds/worker/checkouts/gecko/testing/mozbase/mozlog/mozlog/handlers/base.py", line 113, in __call__ + [task 2023-08-02T17:16:41.636Z] self.stream.write(formatted) + [task 2023-08-02T17:16:41.636Z] ValueError: I/O operation on closed file + """ + + logger = None + CI = False # True if we are running in CI + + def __init__(self): + "Lazily will create an instance of mozlog" + pass + + def _initialize(self): + "Creates an instance of mozlog, if needed" + if "TASK_ID" in os.environ: + Logger.CI = True # We are running in CI + else: + Logger.CI = False + if Logger.logger is None: + component = "manifestparser" + import mozlog + + Logger.logger = mozlog.get_default_logger(component) + if Logger.logger is None: + Logger.logger = mozlog.unstructured.getLogger(component) + + def critical(self, *args, **kwargs): + self._initialize() + Logger.logger.critical(*args, **kwargs) + + def debug(self, *args, **kwargs): + self._initialize() + Logger.logger.debug(*args, **kwargs) + + def debug_ci(self, *args, **kwargs): + "Log to INFO level in CI else DEBUG level" + self._initialize() + if Logger.CI: + Logger.logger.info(*args, **kwargs) + else: + Logger.logger.debug(*args, **kwargs) + + def error(self, *args, **kwargs): + self._initialize() + Logger.logger.error(*args, **kwargs) + + def info(self, *args, **kwargs): + self._initialize() + Logger.logger.info(*args, **kwargs) + + def warning(self, *args, **kwargs): + self._initialize() + Logger.logger.warning(*args, **kwargs) diff --git a/testing/mozbase/manifestparser/manifestparser/manifestparser.py b/testing/mozbase/manifestparser/manifestparser/manifestparser.py new file mode 100644 index 0000000000..63eaeefe05 --- /dev/null +++ b/testing/mozbase/manifestparser/manifestparser/manifestparser.py @@ -0,0 +1,938 @@ +# 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/. + +import codecs +import fnmatch +import io +import json +import os +import shutil +import sys +import types +from io import StringIO + +from .filters import DEFAULT_FILTERS, enabled, filterlist +from .filters import exists as _exists +from .ini import read_ini +from .logger import Logger +from .toml import read_toml + +__all__ = ["ManifestParser", "TestManifest", "convert"] + +relpath = os.path.relpath + + +# path normalization + + +def normalize_path(path): + """normalize a relative path""" + if sys.platform.startswith("win"): + return path.replace("/", os.path.sep) + return path + + +def denormalize_path(path): + """denormalize a relative path""" + if sys.platform.startswith("win"): + return path.replace(os.path.sep, "/") + return path + + +# objects for parsing manifests + + +class ManifestParser(object): + """read .ini manifests""" + + def __init__( + self, + manifests=(), + defaults=None, + strict=True, + rootdir=None, + finder=None, + handle_defaults=True, + use_toml=True, + document=False, + ): + """Creates a ManifestParser from the given manifest files. + + :param manifests: An iterable of file paths or file objects corresponding + to manifests. If a file path refers to a manifest file that + does not exist, an IOError is raised. + :param defaults: Variables to pre-define in the environment for evaluating + expressions in manifests. + :param strict: If False, the provided manifests may contain references to + listed (test) files that do not exist without raising an + IOError during reading, and certain errors in manifests + are not considered fatal. Those errors include duplicate + section names, redefining variables, and defining empty + variables. + :param rootdir: The directory used as the basis for conversion to and from + relative paths during manifest reading. + :param finder: If provided, this finder object will be used for filesystem + interactions. Finder objects are part of the mozpack package, + documented at + http://firefox-source-docs.mozilla.org/python/mozpack.html#module-mozpack.files + :param handle_defaults: If not set, do not propagate manifest defaults to individual + test objects. Callers are expected to manage per-manifest + defaults themselves via the manifest_defaults member + variable in this case. + :param use_toml: If True *.toml configration files will be used iff present in the same location as *.ini files (applies to included files as well). If False only *.ini files will be considered. (defaults to True) + :param document: If True *.toml configration will preserve the parsed document from `tomlkit` in self.source_documents[filename] (defaults to False) + """ + self._defaults = defaults or {} + self.tests = [] + self.manifest_defaults = {} + self.source_files = set() + self.source_documents = {} # source document for each filename (optional) + self.strict = strict + self.rootdir = rootdir + self._root = None + self.finder = finder + self._handle_defaults = handle_defaults + self.use_toml = use_toml + self.document = document + self.logger = Logger() + if manifests: + self.read(*manifests) + + def path_exists(self, path): + if self.finder: + return self.finder.get(path) is not None + return os.path.exists(path) + + @property + def root(self): + if not self._root: + if self.rootdir is None: + self._root = "" + else: + assert os.path.isabs(self.rootdir) + self._root = self.rootdir + os.path.sep + return self._root + + def relative_to_root(self, path): + # Microoptimization, because relpath is quite expensive. + # We know that rootdir is an absolute path or empty. If path + # starts with rootdir, then path is also absolute and the tail + # of the path is the relative path (possibly non-normalized, + # when here is unknown). + # For this to work rootdir needs to be terminated with a path + # separator, so that references to sibling directories with + # a common prefix don't get misscomputed (e.g. /root and + # /rootbeer/file). + # When the rootdir is unknown, the relpath needs to be left + # unchanged. We use an empty string as rootdir in that case, + # which leaves relpath unchanged after slicing. + if path.startswith(self.root): + return path[len(self.root) :] + else: + return relpath(path, self.root) + + # methods for reading manifests + def _get_fp_filename(self, filename): + # get directory of this file if not file-like object + if isinstance(filename, str): + # If we're using mercurial as our filesystem via a finder + # during manifest reading, the getcwd() calls that happen + # with abspath calls will not be meaningful, so absolute + # paths are required. + if self.finder: + assert os.path.isabs(filename) + filename = os.path.abspath(filename) + if self.finder: + fp = codecs.getreader("utf-8")(self.finder.get(filename).open()) + else: + fp = io.open(filename, encoding="utf-8") + else: + fp = filename + if hasattr(fp, "name"): + filename = os.path.abspath(fp.name) + else: + filename = None + return fp, filename + + def _read(self, root, filename, defaults, parentmanifest=None): + """ + Internal recursive method for reading and parsing manifests. + Stores all found tests in self.tests + :param root: The base path + :param filename: File object or string path for the base manifest file + :param defaults: Options that apply to all items + :param parentmanifest: Filename of the parent manifest, relative to rootdir (default None) + """ + + def read_file(type): + include_file = section.split(type, 1)[-1] + include_file = normalize_path(include_file) + if not os.path.isabs(include_file): + include_file = os.path.join(here, include_file) + file_base, file_ext = os.path.splitext(include_file) + if file_ext == ".ini": + toml_name = file_base + ".toml" + if self.path_exists(toml_name): + if self.use_toml: + include_file = toml_name + else: + self.logger.debug_ci( + f"NOTE TOML include file present, but not used: {toml_name}" + ) + elif file_ext != ".toml": + raise IOError( + f"manfestparser file extension not supported: {include_file}" + ) + if not self.path_exists(include_file): + message = "Included file '%s' does not exist" % include_file + if self.strict: + raise IOError(message) + else: + sys.stderr.write("%s\n" % message) + return + return include_file + + # assume we are reading an INI file + read_fn = read_ini + fp, filename = self._get_fp_filename(filename) + manifest_defaults_filename = filename # does not change if TOML is present + if filename is None: + filename_rel = None + here = root + file_base = file_ext = None + else: + self.source_files.add(filename) + filename_rel = self.relative_to_root(filename) + here = os.path.dirname(filename) + file_base, file_ext = os.path.splitext(filename) + if file_ext == ".ini": + toml_name = file_base + ".toml" + if self.path_exists(toml_name): + if self.use_toml: + fp, filename = self._get_fp_filename(toml_name) + read_fn = read_toml + else: + self.logger.debug_ci( + f"NOTE TOML present, but not used: {toml_name}" + ) + elif file_ext == ".toml": + read_fn = read_toml + else: + raise IOError(f"manfestparser file extension not supported: {filename}") + defaults["here"] = here + + # read the configuration + sections, defaults, document = read_fn( + fp=fp, + defaults=defaults, + strict=self.strict, + handle_defaults=self._handle_defaults, + document=self.document, + ) + if filename is not None: + self.source_documents[filename] = document + if parentmanifest and filename: + # A manifest can be read multiple times, via "include:", optionally + # with section-specific variables. These variables only apply to + # the included manifest when included via the same parent manifest, + # so they must be associated with (parentmanifest, filename). + # + # |defaults| is a combination of variables, in the following order: + # - The defaults of the ancestor manifests if self._handle_defaults + # is True. + # - Any variables from the "[include:...]" section. + # - The defaults of the included manifest. + self.manifest_defaults[ + (parentmanifest, manifest_defaults_filename) + ] = defaults + if manifest_defaults_filename != filename: + self.manifest_defaults[(parentmanifest, filename)] = defaults + else: + self.manifest_defaults[manifest_defaults_filename] = defaults + if manifest_defaults_filename != filename: + self.manifest_defaults[filename] = defaults + + # get the tests + for section, data in sections: + # a file to include + # TODO: keep track of included file structure: + # self.manifests = {'manifest.ini': 'relative/path.ini'} + if section.startswith("include:"): + include_file = read_file("include:") + if include_file: + include_defaults = data.copy() + self._read( + root, + include_file, + include_defaults, + parentmanifest=filename_rel, + ) + continue + + # otherwise an item + test = data.copy() + test["name"] = section + + # Will be None if the manifest being read is a file-like object. + test["manifest"] = filename + test["manifest_relpath"] = None + if filename: + test["manifest_relpath"] = filename_rel + + # determine the path + path = test.get("path", section) + _relpath = path + if "://" not in path: # don't futz with URLs + path = normalize_path(path) + if here and not os.path.isabs(path): + # Profiling indicates 25% of manifest parsing is spent + # in this call to normpath, but almost all calls return + # their argument unmodified, so we avoid the call if + # '..' if not present in the path. + path = os.path.join(here, path) + if ".." in path: + path = os.path.normpath(path) + _relpath = self.relative_to_root(path) + + test["path"] = path + test["relpath"] = _relpath + + if parentmanifest is not None: + # If a test was included by a parent manifest we may need to + # indicate that in the test object for the sake of identifying + # a test, particularly in the case a test file is included by + # multiple manifests. + test["ancestor_manifest"] = parentmanifest + + # append the item + self.tests.append(test) + + def read(self, *filenames, **defaults): + """ + read and add manifests from file paths or file-like objects + + filenames -- file paths or file-like objects to read as manifests + defaults -- default variables + """ + + # ensure all files exist + missing = [ + filename + for filename in filenames + if isinstance(filename, str) and not self.path_exists(filename) + ] + if missing: + raise IOError("Missing files: %s" % ", ".join(missing)) + + # default variables + _defaults = defaults.copy() or self._defaults.copy() + _defaults.setdefault("here", None) + + # process each file + for filename in filenames: + # set the per file defaults + defaults = _defaults.copy() + here = None + if isinstance(filename, str): + here = os.path.dirname(os.path.abspath(filename)) + elif hasattr(filename, "name"): + here = os.path.dirname(os.path.abspath(filename.name)) + if here: + defaults["here"] = here # directory of master .ini file + + if self.rootdir is None: + # set the root directory + # == the directory of the first manifest given + self.rootdir = here + + self._read(here, filename, defaults) + + # methods for querying manifests + + def query(self, *checks, **kw): + """ + general query function for tests + - checks : callable conditions to test if the test fulfills the query + """ + tests = kw.get("tests", None) + if tests is None: + tests = self.tests + retval = [] + for test in tests: + for check in checks: + if not check(test): + break + else: + retval.append(test) + return retval + + def get(self, _key=None, inverse=False, tags=None, tests=None, **kwargs): + # TODO: pass a dict instead of kwargs since you might hav + # e.g. 'inverse' as a key in the dict + + # TODO: tags should just be part of kwargs with None values + # (None == any is kinda weird, but probably still better) + + # fix up tags + if tags: + tags = set(tags) + else: + tags = set() + + # make some check functions + if inverse: + + def has_tags(test): + return not tags.intersection(test.keys()) + + def dict_query(test): + for key, value in list(kwargs.items()): + if test.get(key) == value: + return False + return True + + else: + + def has_tags(test): + return tags.issubset(test.keys()) + + def dict_query(test): + for key, value in list(kwargs.items()): + if test.get(key) != value: + return False + return True + + # query the tests + tests = self.query(has_tags, dict_query, tests=tests) + + # if a key is given, return only a list of that key + # useful for keys like 'name' or 'path' + if _key: + return [test[_key] for test in tests] + + # return the tests + return tests + + def manifests(self, tests=None): + """ + return manifests in order in which they appear in the tests + If |tests| is not set, the order of the manifests is unspecified. + """ + if tests is None: + manifests = [] + # Make sure to return all the manifests, even ones without tests. + for m in list(self.manifest_defaults.keys()): + if isinstance(m, tuple): + _parentmanifest, manifest = m + else: + manifest = m + if manifest not in manifests: + manifests.append(manifest) + return manifests + + manifests = [] + for test in tests: + manifest = test.get("manifest") + if not manifest: + continue + if manifest not in manifests: + manifests.append(manifest) + return manifests + + def paths(self): + return [i["path"] for i in self.tests] + + # methods for auditing + + def missing(self, tests=None): + """ + return list of tests that do not exist on the filesystem + """ + if tests is None: + tests = self.tests + existing = list(_exists(tests, {})) + return [t for t in tests if t not in existing] + + def check_missing(self, tests=None): + missing = self.missing(tests=tests) + if missing: + missing_paths = [test["path"] for test in missing] + if self.strict: + raise IOError( + "Strict mode enabled, test paths must exist. " + "The following test(s) are missing: %s" + % json.dumps(missing_paths, indent=2) + ) + print( + "Warning: The following test(s) are missing: %s" + % json.dumps(missing_paths, indent=2), + file=sys.stderr, + ) + return missing + + def verifyDirectory(self, directories, pattern=None, extensions=None): + """ + checks what is on the filesystem vs what is in a manifest + returns a 2-tuple of sets: + (missing_from_filesystem, missing_from_manifest) + """ + + files = set([]) + if isinstance(directories, str): + directories = [directories] + + # get files in directories + for directory in directories: + for dirpath, _dirnames, fnames in os.walk(directory, topdown=True): + filenames = fnames + # only add files that match a pattern + if pattern: + filenames = fnmatch.filter(filenames, pattern) + + # only add files that have one of the extensions + if extensions: + filenames = [ + filename + for filename in filenames + if os.path.splitext(filename)[-1] in extensions + ] + + files.update( + [os.path.join(dirpath, filename) for filename in filenames] + ) + + paths = set(self.paths()) + missing_from_filesystem = paths.difference(files) + missing_from_manifest = files.difference(paths) + return (missing_from_filesystem, missing_from_manifest) + + # methods for output + + def write( + self, + fp=sys.stdout, + rootdir=None, + global_tags=None, + global_kwargs=None, + local_tags=None, + local_kwargs=None, + ): + """ + write a manifest given a query + global and local options will be munged to do the query + globals will be written to the top of the file + locals (if given) will be written per test + """ + + # open file if `fp` given as string + close = False + if isinstance(fp, str): + fp = open(fp, "w") + close = True + + # root directory + if rootdir is None: + rootdir = self.rootdir + + # sanitize input + global_tags = global_tags or set() + local_tags = local_tags or set() + global_kwargs = global_kwargs or {} + local_kwargs = local_kwargs or {} + + # create the query + tags = set([]) + tags.update(global_tags) + tags.update(local_tags) + kwargs = {} + kwargs.update(global_kwargs) + kwargs.update(local_kwargs) + + # get matching tests + tests = self.get(tags=tags, **kwargs) + + # print the .ini manifest + if global_tags or global_kwargs: + print("[DEFAULT]", file=fp) + for tag in global_tags: + print("%s =" % tag, file=fp) + for key, value in list(global_kwargs.items()): + print("%s = %s" % (key, value), file=fp) + print(file=fp) + + for t in tests: + test = t.copy() # don't overwrite + + path = test["name"] + if not os.path.isabs(path): + path = test["path"] + if self.rootdir: + path = relpath(test["path"], self.rootdir) + path = denormalize_path(path) + print("[%s]" % path, file=fp) + + # reserved keywords: + reserved = [ + "path", + "name", + "here", + "manifest", + "manifest_relpath", + "relpath", + "ancestor_manifest", + ] + for key in sorted(test.keys()): + if key in reserved: + continue + if key in global_kwargs: + continue + if key in global_tags and not test[key]: + continue + print("%s = %s" % (key, test[key]), file=fp) + print(file=fp) + + if close: + # close the created file + fp.close() + + def __str__(self): + fp = StringIO() + self.write(fp=fp) + value = fp.getvalue() + return value + + def copy(self, directory, rootdir=None, *tags, **kwargs): + """ + copy the manifests and associated tests + - directory : directory to copy to + - rootdir : root directory to copy to (if not given from manifests) + - tags : keywords the tests must have + - kwargs : key, values the tests must match + """ + # XXX note that copy does *not* filter the tests out of the + # resulting manifest; it just stupidly copies them over. + # ideally, it would reread the manifests and filter out the + # tests that don't match *tags and **kwargs + + # destination + if not os.path.exists(directory): + os.path.makedirs(directory) + else: + # sanity check + assert os.path.isdir(directory) + + # tests to copy + tests = self.get(tags=tags, **kwargs) + if not tests: + return # nothing to do! + + # root directory + if rootdir is None: + rootdir = self.rootdir + + # copy the manifests + tests + manifests = [relpath(manifest, rootdir) for manifest in self.manifests()] + for manifest in manifests: + destination = os.path.join(directory, manifest) + dirname = os.path.dirname(destination) + if not os.path.exists(dirname): + os.makedirs(dirname) + else: + # sanity check + assert os.path.isdir(dirname) + shutil.copy(os.path.join(rootdir, manifest), destination) + + missing = self.check_missing(tests) + tests = [test for test in tests if test not in missing] + for test in tests: + if os.path.isabs(test["name"]): + continue + source = test["path"] + destination = os.path.join(directory, relpath(test["path"], rootdir)) + shutil.copy(source, destination) + # TODO: ensure that all of the tests are below the from_dir + + def update(self, from_dir, rootdir=None, *tags, **kwargs): + """ + update the tests as listed in a manifest from a directory + - from_dir : directory where the tests live + - rootdir : root directory to copy to (if not given from manifests) + - tags : keys the tests must have + - kwargs : key, values the tests must match + """ + + # get the tests + tests = self.get(tags=tags, **kwargs) + + # get the root directory + if not rootdir: + rootdir = self.rootdir + + # copy them! + for test in tests: + if not os.path.isabs(test["name"]): + _relpath = relpath(test["path"], rootdir) + source = os.path.join(from_dir, _relpath) + if not os.path.exists(source): + message = "Missing test: '%s' does not exist!" + if self.strict: + raise IOError(message) + print(message + " Skipping.", file=sys.stderr) + continue + destination = os.path.join(rootdir, _relpath) + shutil.copy(source, destination) + + # directory importers + + @classmethod + def _walk_directories(cls, directories, callback, pattern=None, ignore=()): + """ + internal function to import directories + """ + + if isinstance(pattern, str): + patterns = [pattern] + else: + patterns = pattern + ignore = set(ignore) + + if not patterns: + + def accept_filename(filename): + return True + + else: + + def accept_filename(filename): + for pattern in patterns: + if fnmatch.fnmatch(filename, pattern): + return True + + if not ignore: + + def accept_dirname(dirname): + return True + + else: + + def accept_dirname(dirname): + return dirname not in ignore + + rootdirectories = directories[:] + seen_directories = set() + for rootdirectory in rootdirectories: + # let's recurse directories using list + directories = [os.path.realpath(rootdirectory)] + while directories: + directory = directories.pop(0) + if directory in seen_directories: + # eliminate possible infinite recursion due to + # symbolic links + continue + seen_directories.add(directory) + + files = [] + subdirs = [] + for name in sorted(os.listdir(directory)): + path = os.path.join(directory, name) + if os.path.isfile(path): + # os.path.isfile follow symbolic links, we don't + # need to handle them here. + if accept_filename(name): + files.append(name) + continue + elif os.path.islink(path): + # eliminate symbolic links + path = os.path.realpath(path) + + # we must have a directory here + if accept_dirname(name): + subdirs.append(name) + # this subdir is added for recursion + directories.insert(0, path) + + # here we got all subdirs and files filtered, we can + # call the callback function if directory is not empty + if subdirs or files: + callback(rootdirectory, directory, subdirs, files) + + @classmethod + def populate_directory_manifests( + cls, directories, filename, pattern=None, ignore=(), overwrite=False + ): + """ + walks directories and writes manifests of name `filename` in-place; + returns `cls` instance populated with the given manifests + + filename -- filename of manifests to write + pattern -- shell pattern (glob) or patterns of filenames to match + ignore -- directory names to ignore + overwrite -- whether to overwrite existing files of given name + """ + + manifest_dict = {} + + if os.path.basename(filename) != filename: + raise IOError("filename should not include directory name") + + # no need to hit directories more than once + _directories = directories + directories = [] + for directory in _directories: + if directory not in directories: + directories.append(directory) + + def callback(directory, dirpath, dirnames, filenames): + """write a manifest for each directory""" + + manifest_path = os.path.join(dirpath, filename) + if (dirnames or filenames) and not ( + os.path.exists(manifest_path) and overwrite + ): + with open(manifest_path, "w") as manifest: + for dirname in dirnames: + print( + "[include:%s]" % os.path.join(dirname, filename), + file=manifest, + ) + for _filename in filenames: + print("[%s]" % _filename, file=manifest) + + # add to list of manifests + manifest_dict.setdefault(directory, manifest_path) + + # walk the directories to gather files + cls._walk_directories(directories, callback, pattern=pattern, ignore=ignore) + # get manifests + manifests = [manifest_dict[directory] for directory in _directories] + + # create a `cls` instance with the manifests + return cls(manifests=manifests) + + @classmethod + def from_directories( + cls, directories, pattern=None, ignore=(), write=None, relative_to=None + ): + """ + convert directories to a simple manifest; returns ManifestParser instance + + pattern -- shell pattern (glob) or patterns of filenames to match + ignore -- directory names to ignore + write -- filename or file-like object of manifests to write; + if `None` then a StringIO instance will be created + relative_to -- write paths relative to this path; + if false then the paths are absolute + """ + + # determine output + opened_manifest_file = None # name of opened manifest file + absolute = not relative_to # whether to output absolute path names as names + if isinstance(write, str): + opened_manifest_file = write + write = open(write, "w") + if write is None: + write = StringIO() + + # walk the directories, generating manifests + def callback(directory, dirpath, dirnames, filenames): + # absolute paths + filenames = [os.path.join(dirpath, filename) for filename in filenames] + # ensure new manifest isn't added + filenames = [ + filename for filename in filenames if filename != opened_manifest_file + ] + # normalize paths + if not absolute and relative_to: + filenames = [relpath(filename, relative_to) for filename in filenames] + + # write to manifest + write_content = "\n".join( + ["[{}]".format(denormalize_path(filename)) for filename in filenames] + ) + print(write_content, file=write) + + cls._walk_directories(directories, callback, pattern=pattern, ignore=ignore) + + if opened_manifest_file: + # close file + write.close() + manifests = [opened_manifest_file] + else: + # manifests/write is a file-like object; + # rewind buffer + write.flush() + write.seek(0) + manifests = [write] + + # make a ManifestParser instance + return cls(manifests=manifests) + + +convert = ManifestParser.from_directories + + +class TestManifest(ManifestParser): + """ + apply logic to manifests; this is your integration layer :) + specific harnesses may subclass from this if they need more logic + """ + + def __init__(self, *args, **kwargs): + ManifestParser.__init__(self, *args, **kwargs) + self.filters = filterlist(DEFAULT_FILTERS) + self.last_used_filters = [] + + def active_tests( + self, exists=True, disabled=True, filters=None, noDefaultFilters=False, **values + ): + """ + Run all applied filters on the set of tests. + + :param exists: filter out non-existing tests (default True) + :param disabled: whether to return disabled tests (default True) + :param values: keys and values to filter on (e.g. `os = linux mac`) + :param filters: list of filters to apply to the tests + :returns: list of test objects that were not filtered out + """ + tests = [i.copy() for i in self.tests] # shallow copy + + # mark all tests as passing + for test in tests: + test["expected"] = test.get("expected", "pass") + + # make a copy so original doesn't get modified + if noDefaultFilters: + fltrs = [] + else: + fltrs = self.filters[:] + + if exists: + if self.strict: + self.check_missing(tests) + else: + fltrs.append(_exists) + + if not disabled: + fltrs.append(enabled) + + if filters: + fltrs += filters + + self.last_used_filters = fltrs[:] + for fn in fltrs: + tests = fn(tests, values) + return list(tests) + + def test_paths(self): + return [test["path"] for test in self.active_tests()] + + def fmt_filters(self, filters=None): + filters = filters or self.last_used_filters + names = [] + for f in filters: + if isinstance(f, types.FunctionType): + names.append(f.__name__) + else: + names.append(str(f)) + return ", ".join(names) diff --git a/testing/mozbase/manifestparser/manifestparser/toml.py b/testing/mozbase/manifestparser/manifestparser/toml.py new file mode 100644 index 0000000000..e028a4b0d7 --- /dev/null +++ b/testing/mozbase/manifestparser/manifestparser/toml.py @@ -0,0 +1,321 @@ +# 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/. + +import io +import os +import re + +from .ini import combine_fields + +__all__ = ["read_toml", "alphabetize_toml_str", "add_skip_if", "sort_paths"] + +FILENAME_REGEX = r"^([A-Za-z0-9_./-]*)([Bb][Uu][Gg])([-_]*)([0-9]+)([A-Za-z0-9_./-]*)$" +DEFAULT_SECTION = "DEFAULT" + + +def sort_paths_keyfn(k): + sort_paths_keyfn.rx = getattr(sort_paths_keyfn, "rx", None) # static + if sort_paths_keyfn.rx is None: + sort_paths_keyfn.rx = re.compile(FILENAME_REGEX) + name = str(k) + if name == DEFAULT_SECTION: + return "" + m = sort_paths_keyfn.rx.findall(name) + if len(m) == 1 and len(m[0]) == 5: + prefix = m[0][0] # text before "Bug" + bug = m[0][1] # the word "Bug" + underbar = m[0][2] # underbar or dash (optional) + num = m[0][3] # the bug id + suffix = m[0][4] # text after the bug id + name = f"{prefix}{bug.lower()}{underbar}{int(num):09d}{suffix}" + return name + return name + + +def sort_paths(paths): + """ + Returns a list of paths (tests) in a manifest in alphabetical order. + Ensures DEFAULT is first and filenames with a bug number are + in the proper order. + """ + return sorted(paths, key=sort_paths_keyfn) + + +def parse_toml_str(contents): + """ + Parse TOML contents using toml + """ + import toml + + error = None + manifest = None + try: + manifest = toml.loads(contents) + except toml.TomlDecodeError as pe: + error = str(pe) + return error, manifest + + +def parse_tomlkit_str(contents): + """ + Parse TOML contents using tomlkit + """ + import tomlkit + from tomlkit.exceptions import TOMLKitError + + error = None + manifest = None + try: + manifest = tomlkit.parse(contents) + except TOMLKitError as pe: + error = str(pe) + return error, manifest + + +def read_toml( + fp, + defaults=None, + default=DEFAULT_SECTION, + _comments=None, + _separators=None, + strict=True, + handle_defaults=True, + document=False, +): + """ + read a .toml file and return a list of [(section, values)] + - fp : file pointer or path to read + - defaults : default set of variables + - default : name of the section for the default section + - comments : characters that if they start a line denote a comment + - separators : strings that denote key, value separation in order + - strict : whether to be strict about parsing + - handle_defaults : whether to incorporate defaults into each section + - document: read TOML with tomlkit and return source in test["document"] + """ + + # variables + defaults = defaults or {} + default_section = {} + sections = [] + if isinstance(fp, str): + filename = fp + fp = io.open(fp, encoding="utf-8") + elif hasattr(fp, "name"): + filename = fp.name + else: + filename = "unknown" + contents = fp.read() + inline_comment_rx = re.compile(r"\s#.*$") + + if document: # Use tomlkit to parse the file contents + error, manifest = parse_tomlkit_str(contents) + else: + error, manifest = parse_toml_str(contents) + if error: + raise IOError(f"Error parsing TOML manifest file {filename}: {error}") + + # handle each section of the manifest + for section in manifest.keys(): + current_section = {} + for key in manifest[section].keys(): + val = manifest[section][key] + if isinstance(val, bool): # must coerce to lowercase string + if val: + val = "true" + else: + val = "false" + elif isinstance(val, list): + new_vals = "" + for v in val: + if len(new_vals) > 0: + new_vals += os.linesep + new_val = str(v).strip() # coerce to str + comment_found = inline_comment_rx.search(new_val) + if comment_found: + new_val = new_val[0 : comment_found.span()[0]] + if " = " in new_val: + raise Exception( + f"Should not assign in {key} condition for {section}" + ) + new_vals += new_val + val = new_vals + else: + val = str(val).strip() # coerce to str + comment_found = inline_comment_rx.search(val) + if comment_found: + val = val[0 : comment_found.span()[0]] + if " = " in val: + raise Exception( + f"Should not assign in {key} condition for {section}" + ) + current_section[key] = val + if section.lower() == default.lower(): + default_section = current_section + # DEFAULT does NOT appear in the output + else: + sections.append((section, current_section)) + + # merge global defaults with the DEFAULT section + defaults = combine_fields(defaults, default_section) + if handle_defaults: + # merge combined defaults into each section + sections = [(i, combine_fields(defaults, j)) for i, j in sections] + + if not document: + manifest = None + return sections, defaults, manifest + + +def alphabetize_toml_str(manifest): + """ + Will take a TOMLkit manifest document (i.e. from a previous invocation + of read_toml(..., document=True) and accessing the document + from mp.source_documents[filename]) and return it as a string + in sorted order by section (i.e. test file name, taking bug ids into consideration). + """ + + from tomlkit import document, dumps, table + from tomlkit.items import Table + + preamble = "" + new_manifest = document() + first_section = False + sections = {} + + for k, v in manifest.body: + if k is None: + preamble += v.as_string() + continue + if not isinstance(v, Table): + raise Exception(f"MP TOML illegal keyval in preamble: {k} = {v}") + section = None + if not first_section: + if k == DEFAULT_SECTION: + new_manifest.add(k, v) + else: + new_manifest.add(DEFAULT_SECTION, table()) + first_section = True + else: + values = v.items() + if len(values) == 1: + for kk, vv in values: + if isinstance(vv, Table): # unquoted, dotted key + section = f"{k}.{kk}" + sections[section] = vv + if section is None: + section = str(k).strip("'\"") + sections[section] = v + + if not first_section: + new_manifest.add(DEFAULT_SECTION, table()) + + for section in sort_paths([k for k in sections.keys() if k != DEFAULT_SECTION]): + new_manifest.add(section, sections[section]) + + manifest_str = dumps(new_manifest) + # tomlkit fixups + manifest_str = preamble + manifest_str.replace('"",]', "]") + while manifest_str.endswith("\n\n"): + manifest_str = manifest_str[:-1] + return manifest_str + + +def _simplify_comment(comment): + """Remove any leading #, but preserve leading whitespace in comment""" + + length = len(comment) + i = 0 + j = -1 # remove exactly one space + while i < length and comment[i] in " #": + i += 1 + if comment[i] == " ": + j += 1 + comment = comment[i:] + if j > 0: + comment = " " * j + comment + return comment.rstrip() + + +def add_skip_if(manifest, filename, condition, bug=None): + """ + Will take a TOMLkit manifest document (i.e. from a previous invocation + of read_toml(..., document=True) and accessing the document + from mp.source_documents[filename]) and return it as a string + in sorted order by section (i.e. test file name, taking bug ids into consideration). + """ + from tomlkit import array + from tomlkit.items import Comment, String, Whitespace + + if filename not in manifest: + raise Exception(f"TOML manifest does not contain section: {filename}") + keyvals = manifest[filename] + first = None + first_comment = "" + skip_if = None + existing = False # this condition is already present + if "skip-if" in keyvals: + skip_if = keyvals["skip-if"] + if len(skip_if) == 1: + for e in skip_if._iter_items(): + if not first: + if not isinstance(e, Whitespace): + first = e.as_string().strip('"') + else: + c = e.as_string() + if c != ",": + first_comment += c + if skip_if.trivia is not None: + first_comment += skip_if.trivia.comment + mp_array = array() + if skip_if is None: # add the first one line entry to the table + mp_array.add_line(condition, indent="", add_comma=False, newline=False) + if bug is not None: + mp_array.comment(bug) + skip_if = {"skip-if": mp_array} + keyvals.update(skip_if) + else: + if first is not None: + if first == condition: + existing = True + if first_comment is not None: + mp_array.add_line( + first, indent=" ", comment=_simplify_comment(first_comment) + ) + else: + mp_array.add_line(first, indent=" ") + if len(skip_if) > 1: + e_condition = None + e_comment = None + for e in skip_if._iter_items(): + if isinstance(e, String): + if e_condition is not None: + if e_comment is not None: + mp_array.add_line( + e_condition, indent=" ", comment=e_comment + ) + e_comment = None + else: + mp_array.add_line(e_condition, indent=" ") + e_condition = None + if len(e) > 0: + e_condition = e.as_string().strip('"') + if e_condition == condition: + existing = True + elif isinstance(e, Comment): + e_comment = _simplify_comment(e.as_string()) + if e_condition is not None: + if e_comment is not None: + mp_array.add_line(e_condition, indent=" ", comment=e_comment) + else: + mp_array.add_line(e_condition, indent=" ") + if not existing: + if bug is not None: + mp_array.add_line(condition, indent=" ", comment=bug) + else: + mp_array.add_line(condition, indent=" ") + mp_array.add_line("", indent="") # fixed in write_toml_str + skip_if = {"skip-if": mp_array} + del keyvals["skip-if"] + keyvals.update(skip_if) diff --git a/testing/mozbase/manifestparser/manifestparser/util.py b/testing/mozbase/manifestparser/manifestparser/util.py new file mode 100644 index 0000000000..6cfe57de5c --- /dev/null +++ b/testing/mozbase/manifestparser/manifestparser/util.py @@ -0,0 +1,51 @@ +# 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/. + +import ast +import os + + +def normsep(path): + """ + Normalize path separators, by using forward slashes instead of whatever + :py:const:`os.sep` is. + """ + if os.sep != "/": + # Python 2 is happy to do things like byte_string.replace(u'foo', + # u'bar'), but not Python 3. + if isinstance(path, bytes): + path = path.replace(os.sep.encode("ascii"), b"/") + else: + path = path.replace(os.sep, "/") + if os.altsep and os.altsep != "/": + if isinstance(path, bytes): + path = path.replace(os.altsep.encode("ascii"), b"/") + else: + path = path.replace(os.altsep, "/") + return path + + +def evaluate_list_from_string(list_string): + """ + This is a utility function for converting a string obtained from a manifest + into a list. If the string is not a valid list when converted, an error will be + raised from `ast.eval_literal`. For example, you can convert entries like this + into a list: + ``` + test_settings= + ["hello", "world"], + [1, 10, 100], + values= + 5, + 6, + 7, + 8, + ``` + """ + parts = [ + x.strip(",") + for x in list_string.strip(",").replace("\r", "").split("\n") + if x.strip() + ] + return ast.literal_eval("[" + ",".join(parts) + "]") diff --git a/testing/mozbase/manifestparser/setup.py b/testing/mozbase/manifestparser/setup.py new file mode 100644 index 0000000000..57dea37d08 --- /dev/null +++ b/testing/mozbase/manifestparser/setup.py @@ -0,0 +1,38 @@ +# 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 setuptools import setup + +PACKAGE_NAME = "manifestparser" +PACKAGE_VERSION = "2.4.0" + +DEPS = [ + "mozlog >= 6.0", + "toml >= 0.10.2", + "tomlkit >= 0.12.3", +] +setup( + name=PACKAGE_NAME, + version=PACKAGE_VERSION, + description="Library to create and manage test manifests", + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + classifiers=[ + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + ], + # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + keywords="mozilla manifests", + author="Mozilla Automation and Testing Team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + license="MPL", + zip_safe=False, + packages=["manifestparser"], + install_requires=DEPS, + entry_points=""" + [console_scripts] + manifestparser = manifestparser.cli:main + """, +) diff --git a/testing/mozbase/manifestparser/tests/broken-skip-if.toml b/testing/mozbase/manifestparser/tests/broken-skip-if.toml new file mode 100644 index 0000000000..c8b6b19998 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/broken-skip-if.toml @@ -0,0 +1,4 @@ +[DEFAULT] +skip-if = [ + "os = 'win'", +] diff --git a/testing/mozbase/manifestparser/tests/comment-example.toml b/testing/mozbase/manifestparser/tests/comment-example.toml new file mode 100644 index 0000000000..8562d83ef4 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/comment-example.toml @@ -0,0 +1,11 @@ +# See https://bugzilla.mozilla.org/show_bug.cgi?id=813674 + +["test_0180_fileInUse_xp_win_complete.js"] +["test_0181_fileInUse_xp_win_partial.js"] +["test_0182_rmrfdirFileInUse_xp_win_complete.js"] +["test_0183_rmrfdirFileInUse_xp_win_partial.js"] +["test_0184_fileInUse_xp_win_complete.js"] +["test_0185_fileInUse_xp_win_partial.js"] +["test_0186_rmrfdirFileInUse_xp_win_complete.js"] +["test_0187_rmrfdirFileInUse_xp_win_partial.js"] +# [test_0202_app_launch_apply_update_dirlocked.js] # Test disabled, bug 757632 diff --git a/testing/mozbase/manifestparser/tests/default-skipif.toml b/testing/mozbase/manifestparser/tests/default-skipif.toml new file mode 100644 index 0000000000..e986f29b8c --- /dev/null +++ b/testing/mozbase/manifestparser/tests/default-skipif.toml @@ -0,0 +1,32 @@ +[DEFAULT] +skip-if = [ + "os == 'win' && debug", # a pesky comment +] + +[test1] +skip-if = [ + "debug", +] + +[test2] +skip-if = [ + "os == 'linux'", +] + +[test3] +skip-if = [ + "os == 'win'", +] + +[test4] +skip-if = [ + "os == 'win' && debug", +] + +[test5] +foo = "bar" + +[test6] +skip-if = [ + "debug # a second pesky inline comment", # inline comments are discouraged +] diff --git a/testing/mozbase/manifestparser/tests/default-subsuite.toml b/testing/mozbase/manifestparser/tests/default-subsuite.toml new file mode 100644 index 0000000000..3c897c05e4 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/default-subsuite.toml @@ -0,0 +1,5 @@ +[test1] +subsuite = "baz" + +[test2] +subsuite = "foo" diff --git a/testing/mozbase/manifestparser/tests/default-suppfiles.toml b/testing/mozbase/manifestparser/tests/default-suppfiles.toml new file mode 100644 index 0000000000..52dd7c68a7 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/default-suppfiles.toml @@ -0,0 +1,8 @@ +[DEFAULT] +support-files = "foo.js" # a comment + +[test7] +[test8] +support-files = "bar.js" # another comment +[test9] +foo = "bar" diff --git a/testing/mozbase/manifestparser/tests/edit-manifest-after.toml b/testing/mozbase/manifestparser/tests/edit-manifest-after.toml new file mode 100644 index 0000000000..1e3099b008 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/edit-manifest-after.toml @@ -0,0 +1,37 @@ +# This is an example of comment at the top of a manifest + +[DEFAULT] + +["bug_3.js"] +# This is a comment about Bug 3 +# DO NOT ADD MORE TESTS HERE +skip-if = [ + "os == 'linux'", + "verify", # Bug 33333 +] + +["bug_20.js"] +skip-if = [ + "os == 'mac'", # Bug 20 + "os == 'windows'", # Bug 20 +] + +["bug_100.js"] +skip-if = [ + "debug", # Bug 100 + "apple_catalina", # Bug 200 +] + +["test_bar.html"] +skip-if = [ + "os == 'mac'", # Bug 111 + "os == 'linux'", # Bug 222 + "os == 'win'", # Bug 333 + "tsan", # Bug 444 +] + +["test_foo.html"] +skip-if = [ + "os == 'mac' && !debug", # bug 31415 + "os == 'mac' && debug", +] diff --git a/testing/mozbase/manifestparser/tests/edit-manifest-before.toml b/testing/mozbase/manifestparser/tests/edit-manifest-before.toml new file mode 100644 index 0000000000..bd48666903 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/edit-manifest-before.toml @@ -0,0 +1,23 @@ +# This is an example of comment at the top of a manifest + +["bug_100.js"] +skip-if = [ + "debug", # Bug 100 +] + +["bug_3.js"] +# This is a comment about Bug 3 +skip-if = ["os == 'linux'"] +# DO NOT ADD MORE TESTS HERE + +['bug_20.js'] + +["test_foo.html"] +skip-if = ["os == 'mac' && !debug"] # bug 31415 + +["test_bar.html"] +skip-if = [ + "os == 'mac'", # Bug 111 + "os == 'linux'", # Bug 222 + "os == 'win'", # Bug 333 +] diff --git a/testing/mozbase/manifestparser/tests/filter-example.toml b/testing/mozbase/manifestparser/tests/filter-example.toml new file mode 100644 index 0000000000..044470e4cc --- /dev/null +++ b/testing/mozbase/manifestparser/tests/filter-example.toml @@ -0,0 +1,20 @@ +# illustrate test filters based on various categories + +[windowstest] +skip-if = [ + "os != 'win'", +] + +[fleem] +skip-if = [ + "os == 'mac'", +] + +[linuxtest] +skip-if = [ + "os == 'mac'", + "os == 'win'", +] +fail-if = [ + "os == 'mac'", +] diff --git a/testing/mozbase/manifestparser/tests/fleem b/testing/mozbase/manifestparser/tests/fleem new file mode 100644 index 0000000000..744817b823 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/fleem @@ -0,0 +1 @@ +# dummy spot for "fleem" test diff --git a/testing/mozbase/manifestparser/tests/include-example.toml b/testing/mozbase/manifestparser/tests/include-example.toml new file mode 100644 index 0000000000..e8865d4915 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/include-example.toml @@ -0,0 +1,11 @@ +[DEFAULT] +foo = "bar" + +["include:include/bar.toml"] + +[fleem] + +["include:include/foo.toml"] +red = "roses" +blue = "violets" +yellow = "daffodils" diff --git a/testing/mozbase/manifestparser/tests/include-invalid.toml b/testing/mozbase/manifestparser/tests/include-invalid.toml new file mode 100644 index 0000000000..35534e3e90 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/include-invalid.toml @@ -0,0 +1 @@ +["include:invalid.toml"] diff --git a/testing/mozbase/manifestparser/tests/include/bar.ini b/testing/mozbase/manifestparser/tests/include/bar.ini new file mode 100644 index 0000000000..bcb312d1db --- /dev/null +++ b/testing/mozbase/manifestparser/tests/include/bar.ini @@ -0,0 +1,4 @@ +[DEFAULT] +foo = fleem + +[crash-handling]
\ No newline at end of file diff --git a/testing/mozbase/manifestparser/tests/include/bar.toml b/testing/mozbase/manifestparser/tests/include/bar.toml new file mode 100644 index 0000000000..b6fb12e3fd --- /dev/null +++ b/testing/mozbase/manifestparser/tests/include/bar.toml @@ -0,0 +1,4 @@ +[DEFAULT] +foo = "fleem" + +[crash-handling] diff --git a/testing/mozbase/manifestparser/tests/include/crash-handling b/testing/mozbase/manifestparser/tests/include/crash-handling new file mode 100644 index 0000000000..8e19a63751 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/include/crash-handling @@ -0,0 +1 @@ +# dummy spot for "crash-handling" test diff --git a/testing/mozbase/manifestparser/tests/include/flowers b/testing/mozbase/manifestparser/tests/include/flowers new file mode 100644 index 0000000000..a25acfbe21 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/include/flowers @@ -0,0 +1 @@ +# dummy spot for "flowers" test diff --git a/testing/mozbase/manifestparser/tests/include/foo.ini b/testing/mozbase/manifestparser/tests/include/foo.ini new file mode 100644 index 0000000000..cfc90ace83 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/include/foo.ini @@ -0,0 +1,5 @@ +[DEFAULT] +blue = ocean + +[flowers] +yellow = submarine
\ No newline at end of file diff --git a/testing/mozbase/manifestparser/tests/include/foo.toml b/testing/mozbase/manifestparser/tests/include/foo.toml new file mode 100644 index 0000000000..ac2454e31d --- /dev/null +++ b/testing/mozbase/manifestparser/tests/include/foo.toml @@ -0,0 +1,5 @@ +[DEFAULT] +blue = "ocean" + +[flowers] +yellow = "submarine" diff --git a/testing/mozbase/manifestparser/tests/just-defaults.toml b/testing/mozbase/manifestparser/tests/just-defaults.toml new file mode 100644 index 0000000000..cbf1eb1927 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/just-defaults.toml @@ -0,0 +1,2 @@ +[DEFAULT] +foo = "bar" diff --git a/testing/mozbase/manifestparser/tests/manifest.toml b/testing/mozbase/manifestparser/tests/manifest.toml new file mode 100644 index 0000000000..bb992ad9af --- /dev/null +++ b/testing/mozbase/manifestparser/tests/manifest.toml @@ -0,0 +1,23 @@ +[DEFAULT] +subsuite = "mozbase" + +["test_chunking.py"] + +["test_convert_directory.py"] + +["test_convert_symlinks.py"] +disabled = "https://bugzilla.mozilla.org/show_bug.cgi?id=920938" + +["test_default_overrides.py"] + +["test_expressionparser.py"] + +["test_filters.py"] + +["test_manifestparser.py"] + +["test_read_ini.py"] + +["test_testmanifest.py"] + +["test_util.py"] diff --git a/testing/mozbase/manifestparser/tests/missing-path.toml b/testing/mozbase/manifestparser/tests/missing-path.toml new file mode 100644 index 0000000000..919d8e04da --- /dev/null +++ b/testing/mozbase/manifestparser/tests/missing-path.toml @@ -0,0 +1,2 @@ +[foo] +[bar] diff --git a/testing/mozbase/manifestparser/tests/mozmill-example.toml b/testing/mozbase/manifestparser/tests/mozmill-example.toml new file mode 100644 index 0000000000..6ea81d102e --- /dev/null +++ b/testing/mozbase/manifestparser/tests/mozmill-example.toml @@ -0,0 +1,80 @@ +["testAddons/testDisableEnablePlugin.js"] +["testAddons/testGetAddons.js"] +["testAddons/testSearchAddons.js"] +["testAwesomeBar/testAccessLocationBar.js"] +["testAwesomeBar/testCheckItemHighlight.js"] +["testAwesomeBar/testEscapeAutocomplete.js"] +["testAwesomeBar/testFaviconInAutocomplete.js"] +["testAwesomeBar/testGoButton.js"] +["testAwesomeBar/testLocationBarSearches.js"] +["testAwesomeBar/testPasteLocationBar.js"] +["testAwesomeBar/testSuggestHistoryBookmarks.js"] +["testAwesomeBar/testVisibleItemsMax.js"] +["testBookmarks/testAddBookmarkToMenu.js"] +["testCookies/testDisableCookies.js"] +["testCookies/testEnableCookies.js"] +["testCookies/testRemoveAllCookies.js"] +["testCookies/testRemoveCookie.js"] +["testDownloading/testCloseDownloadManager.js"] +["testDownloading/testDownloadStates.js"] +["testDownloading/testOpenDownloadManager.js"] +["testFindInPage/testFindInPage.js"] +["testFormManager/testAutoCompleteOff.js"] +["testFormManager/testBasicFormCompletion.js"] +["testFormManager/testClearFormHistory.js"] +["testFormManager/testDisableFormManager.js"] +["testGeneral/testGoogleSuggestions.js"] +["testGeneral/testStopReloadButtons.js"] +["testInstallation/testBreakpadInstalled.js"] +["testLayout/testNavigateFTP.js"] +["testPasswordManager/testPasswordNotSaved.js"] +["testPasswordManager/testPasswordSavedAndDeleted.js"] +["testPopups/testPopupsAllowed.js"] +["testPopups/testPopupsBlocked.js"] +["testPreferences/testPaneRetention.js"] +["testPreferences/testPreferredLanguage.js"] +["testPreferences/testRestoreHomepageToDefault.js"] +["testPreferences/testSetToCurrentPage.js"] +["testPreferences/testSwitchPanes.js"] +["testPrivateBrowsing/testAboutPrivateBrowsing.js"] +["testPrivateBrowsing/testCloseWindow.js"] +["testPrivateBrowsing/testDisabledElements.js"] +["testPrivateBrowsing/testDisabledPermissions.js"] +["testPrivateBrowsing/testDownloadManagerClosed.js"] +["testPrivateBrowsing/testGeolocation.js"] +["testPrivateBrowsing/testStartStopPBMode.js"] +["testPrivateBrowsing/testTabRestoration.js"] +["testPrivateBrowsing/testTabsDismissedOnStop.js"] +["testSearch/testAddMozSearchProvider.js"] +["testSearch/testFocusAndSearch.js"] +["testSearch/testGetMoreSearchEngines.js"] +["testSearch/testOpenSearchAutodiscovery.js"] +["testSearch/testRemoveSearchEngine.js"] +["testSearch/testReorderSearchEngines.js"] +["testSearch/testRestoreDefaults.js"] +["testSearch/testSearchSelection.js"] +["testSearch/testSearchSuggestions.js"] +["testSecurity/testBlueLarry.js"] +["testSecurity/testDefaultPhishingEnabled.js"] +["testSecurity/testDefaultSecurityPrefs.js"] +["testSecurity/testEncryptedPageWarning.js"] +["testSecurity/testGreenLarry.js"] +["testSecurity/testGreyLarry.js"] +["testSecurity/testIdentityPopupOpenClose.js"] +["testSecurity/testSSLDisabledErrorPage.js"] +["testSecurity/testSafeBrowsingNotificationBar.js"] +["testSecurity/testSafeBrowsingWarningPages.js"] +["testSecurity/testSecurityInfoViaMoreInformation.js"] +["testSecurity/testSecurityNotification.js"] +["testSecurity/testSubmitUnencryptedInfoWarning.js"] +["testSecurity/testUnknownIssuer.js"] +["testSecurity/testUntrustedConnectionErrorPage.js"] +["testSessionStore/testUndoTabFromContextMenu.js"] +["testTabbedBrowsing/testBackgroundTabScrolling.js"] +["testTabbedBrowsing/testCloseTab.js"] +["testTabbedBrowsing/testNewTab.js"] +["testTabbedBrowsing/testNewWindow.js"] +["testTabbedBrowsing/testOpenInBackground.js"] +["testTabbedBrowsing/testOpenInForeground.js"] +["testTechnicalTools/testAccessPageInfoDialog.js"] +["testToolbar/testBackForwardButtons.js"] diff --git a/testing/mozbase/manifestparser/tests/mozmill-restart-example.toml b/testing/mozbase/manifestparser/tests/mozmill-restart-example.toml new file mode 100644 index 0000000000..5e08bdb45e --- /dev/null +++ b/testing/mozbase/manifestparser/tests/mozmill-restart-example.toml @@ -0,0 +1,26 @@ +[DEFAULT] +type = "restart" + +["restartTests/testExtensionInstallUninstall/test2.js"] +foo = "bar" + +["restartTests/testExtensionInstallUninstall/test1.js"] +foo = "baz" + +["restartTests/testExtensionInstallUninstall/test3.js"] +["restartTests/testSoftwareUpdateAutoProxy/test2.js"] +["restartTests/testSoftwareUpdateAutoProxy/test1.js"] +["restartTests/testPrimaryPassword/test1.js"] +["restartTests/testExtensionInstallGetAddons/test2.js"] +["restartTests/testExtensionInstallGetAddons/test1.js"] +["restartTests/testMultipleExtensionInstallation/test2.js"] +["restartTests/testMultipleExtensionInstallation/test1.js"] +["restartTests/testThemeInstallUninstall/test2.js"] +["restartTests/testThemeInstallUninstall/test1.js"] +["restartTests/testThemeInstallUninstall/test3.js"] +["restartTests/testDefaultBookmarks/test1.js"] +["softwareUpdate/testFallbackUpdate/test2.js"] +["softwareUpdate/testFallbackUpdate/test1.js"] +["softwareUpdate/testFallbackUpdate/test3.js"] +["softwareUpdate/testDirectUpdate/test2.js"] +["softwareUpdate/testDirectUpdate/test1.js"] diff --git a/testing/mozbase/manifestparser/tests/no-tests.toml b/testing/mozbase/manifestparser/tests/no-tests.toml new file mode 100644 index 0000000000..cbf1eb1927 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/no-tests.toml @@ -0,0 +1,2 @@ +[DEFAULT] +foo = "bar" diff --git a/testing/mozbase/manifestparser/tests/parent/include/first/manifest.ini b/testing/mozbase/manifestparser/tests/parent/include/first/manifest.ini new file mode 100644 index 0000000000..828525c18f --- /dev/null +++ b/testing/mozbase/manifestparser/tests/parent/include/first/manifest.ini @@ -0,0 +1,3 @@ +[parent:../manifest.ini] + +[testFirst.js] diff --git a/testing/mozbase/manifestparser/tests/parent/include/first/manifest.toml b/testing/mozbase/manifestparser/tests/parent/include/first/manifest.toml new file mode 100644 index 0000000000..e58d36b8f5 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/parent/include/first/manifest.toml @@ -0,0 +1,3 @@ +["parent:../manifest.ini"] + +['testFirst.js'] diff --git a/testing/mozbase/manifestparser/tests/parent/include/manifest.ini b/testing/mozbase/manifestparser/tests/parent/include/manifest.ini new file mode 100644 index 0000000000..fb9756d6af --- /dev/null +++ b/testing/mozbase/manifestparser/tests/parent/include/manifest.ini @@ -0,0 +1,8 @@ +[DEFAULT] +top = data + +[include:first/manifest.ini] +disabled = YES + +[include:second/manifest.ini] +disabled = NO diff --git a/testing/mozbase/manifestparser/tests/parent/include/manifest.toml b/testing/mozbase/manifestparser/tests/parent/include/manifest.toml new file mode 100644 index 0000000000..e48011f5fb --- /dev/null +++ b/testing/mozbase/manifestparser/tests/parent/include/manifest.toml @@ -0,0 +1,8 @@ +[DEFAULT] +top = "data" + +["include:first/manifest.ini"] +disabled = "YES" + +["include:second/manifest.ini"] +disabled = "NO" diff --git a/testing/mozbase/manifestparser/tests/parent/include/second/manifest.ini b/testing/mozbase/manifestparser/tests/parent/include/second/manifest.ini new file mode 100644 index 0000000000..31f0537566 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/parent/include/second/manifest.ini @@ -0,0 +1,3 @@ +[parent:../manifest.ini] + +[testSecond.js] diff --git a/testing/mozbase/manifestparser/tests/parent/include/second/manifest.toml b/testing/mozbase/manifestparser/tests/parent/include/second/manifest.toml new file mode 100644 index 0000000000..1990ee13ab --- /dev/null +++ b/testing/mozbase/manifestparser/tests/parent/include/second/manifest.toml @@ -0,0 +1,3 @@ +["parent:../manifest.ini"] + +['testSecond.js'] diff --git a/testing/mozbase/manifestparser/tests/parent/level_1/level_1.ini b/testing/mozbase/manifestparser/tests/parent/level_1/level_1.ini new file mode 100644 index 0000000000..ac7c370c3e --- /dev/null +++ b/testing/mozbase/manifestparser/tests/parent/level_1/level_1.ini @@ -0,0 +1,5 @@ +[DEFAULT] +x = level_1 + +[test_1] +[test_2] diff --git a/testing/mozbase/manifestparser/tests/parent/level_1/level_1.toml b/testing/mozbase/manifestparser/tests/parent/level_1/level_1.toml new file mode 100644 index 0000000000..13e92e1eaf --- /dev/null +++ b/testing/mozbase/manifestparser/tests/parent/level_1/level_1.toml @@ -0,0 +1,5 @@ +[DEFAULT] +x = "level_1" + +[test_1] +[test_2] diff --git a/testing/mozbase/manifestparser/tests/parent/level_1/level_2/level_2.ini b/testing/mozbase/manifestparser/tests/parent/level_1/level_2/level_2.ini new file mode 100644 index 0000000000..ada6a510d7 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/parent/level_1/level_2/level_2.ini @@ -0,0 +1,3 @@ +[parent:../level_1.ini] + +[test_2] diff --git a/testing/mozbase/manifestparser/tests/parent/level_1/level_2/level_2.toml b/testing/mozbase/manifestparser/tests/parent/level_1/level_2/level_2.toml new file mode 100644 index 0000000000..5e78db3b2e --- /dev/null +++ b/testing/mozbase/manifestparser/tests/parent/level_1/level_2/level_2.toml @@ -0,0 +1,3 @@ +["parent:../level_1.ini"] + +[test_2] diff --git a/testing/mozbase/manifestparser/tests/parent/level_1/level_2/level_3/level_3.ini b/testing/mozbase/manifestparser/tests/parent/level_1/level_2/level_3/level_3.ini new file mode 100644 index 0000000000..2edd647fcc --- /dev/null +++ b/testing/mozbase/manifestparser/tests/parent/level_1/level_2/level_3/level_3.ini @@ -0,0 +1,3 @@ +[parent:../level_2.ini] + +[test_3] diff --git a/testing/mozbase/manifestparser/tests/parent/level_1/level_2/level_3/level_3.toml b/testing/mozbase/manifestparser/tests/parent/level_1/level_2/level_3/level_3.toml new file mode 100644 index 0000000000..ff3e0a466a --- /dev/null +++ b/testing/mozbase/manifestparser/tests/parent/level_1/level_2/level_3/level_3.toml @@ -0,0 +1,3 @@ +["parent:../level_2.ini"] + +[test_3] diff --git a/testing/mozbase/manifestparser/tests/parent/level_1/level_2/level_3/level_3_default.ini b/testing/mozbase/manifestparser/tests/parent/level_1/level_2/level_3/level_3_default.ini new file mode 100644 index 0000000000..d6aae60ae1 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/parent/level_1/level_2/level_3/level_3_default.ini @@ -0,0 +1,6 @@ +[parent:../level_2.ini] + +[DEFAULT] +x = level_3 + +[test_3] diff --git a/testing/mozbase/manifestparser/tests/parent/level_1/level_2/level_3/level_3_default.toml b/testing/mozbase/manifestparser/tests/parent/level_1/level_2/level_3/level_3_default.toml new file mode 100644 index 0000000000..786139d888 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/parent/level_1/level_2/level_3/level_3_default.toml @@ -0,0 +1,6 @@ +["parent:../level_2.ini"] + +[DEFAULT] +x = "level_3" + +[test_3] diff --git a/testing/mozbase/manifestparser/tests/parent/level_1/level_2/level_3/test_3 b/testing/mozbase/manifestparser/tests/parent/level_1/level_2/level_3/test_3 new file mode 100644 index 0000000000..f5de587529 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/parent/level_1/level_2/level_3/test_3 @@ -0,0 +1 @@ +# dummy spot for "test_3" test diff --git a/testing/mozbase/manifestparser/tests/parent/level_1/level_2/test_2 b/testing/mozbase/manifestparser/tests/parent/level_1/level_2/test_2 new file mode 100644 index 0000000000..5b77e04f31 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/parent/level_1/level_2/test_2 @@ -0,0 +1 @@ +# dummy spot for "test_2" test diff --git a/testing/mozbase/manifestparser/tests/parent/level_1/test_1 b/testing/mozbase/manifestparser/tests/parent/level_1/test_1 new file mode 100644 index 0000000000..dccbf04e4d --- /dev/null +++ b/testing/mozbase/manifestparser/tests/parent/level_1/test_1 @@ -0,0 +1 @@ +# dummy spot for "test_1" test diff --git a/testing/mozbase/manifestparser/tests/parent/root/dummy b/testing/mozbase/manifestparser/tests/parent/root/dummy new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/parent/root/dummy diff --git a/testing/mozbase/manifestparser/tests/parse-error.toml b/testing/mozbase/manifestparser/tests/parse-error.toml new file mode 100644 index 0000000000..93b2bad268 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/parse-error.toml @@ -0,0 +1 @@ +xyz = "123" diff --git a/testing/mozbase/manifestparser/tests/path-example.toml b/testing/mozbase/manifestparser/tests/path-example.toml new file mode 100644 index 0000000000..fcd4967082 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/path-example.toml @@ -0,0 +1,2 @@ +[foo] +path = "fleem" diff --git a/testing/mozbase/manifestparser/tests/relative-path.toml b/testing/mozbase/manifestparser/tests/relative-path.toml new file mode 100644 index 0000000000..032f699fd3 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/relative-path.toml @@ -0,0 +1,5 @@ +[foo] +path = "../fleem" + +[bar] +path = "../testsSIBLING/example" diff --git a/testing/mozbase/manifestparser/tests/subsuite.toml b/testing/mozbase/manifestparser/tests/subsuite.toml new file mode 100644 index 0000000000..1fc81cf837 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/subsuite.toml @@ -0,0 +1,13 @@ +[test1] +subsuite='bar,foo=="bar"' # this has a comment + +[test2] +subsuite='bar,foo=="bar"' + +[test3] +subsuite='baz' + +[test4] +[test5] +[test6] +subsuite='bar,foo=="szy" || foo=="bar"' diff --git a/testing/mozbase/manifestparser/tests/test_chunking.py b/testing/mozbase/manifestparser/tests/test_chunking.py new file mode 100644 index 0000000000..87b30fa6c7 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/test_chunking.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python + +import os +import random +from collections import defaultdict +from itertools import chain +from unittest import TestCase + +import mozunit +from manifestparser.filters import chunk_by_dir, chunk_by_runtime, chunk_by_slice + +here = os.path.dirname(os.path.abspath(__file__)) + + +class ChunkBySlice(TestCase): + """Test chunking related filters""" + + def generate_tests(self, num, disabled=None): + disabled = disabled or [] + tests = [] + for i in range(num): + test = {"name": "test%i" % i} + if i in disabled: + test["disabled"] = "" + tests.append(test) + return tests + + def run_all_combos(self, num_tests, disabled=None): + tests = self.generate_tests(num_tests, disabled=disabled) + + for total in range(1, num_tests + 1): + res = [] + res_disabled = [] + for chunk in range(1, total + 1): + f = chunk_by_slice(chunk, total) + res.append(list(f(tests, {}))) + if disabled: + f.disabled = True + res_disabled.append(list(f(tests, {}))) + + lengths = [len([t for t in c if "disabled" not in t]) for c in res] + # the chunk with the most tests should have at most one more test + # than the chunk with the least tests + self.assertLessEqual(max(lengths) - min(lengths), 1) + + # chaining all chunks back together should equal the original list + # of tests + self.assertEqual(list(chain.from_iterable(res)), list(tests)) + + if disabled: + lengths = [len(c) for c in res_disabled] + self.assertLessEqual(max(lengths) - min(lengths), 1) + self.assertEqual(list(chain.from_iterable(res_disabled)), list(tests)) + + def test_chunk_by_slice(self): + chunk = chunk_by_slice(1, 1) + self.assertEqual(list(chunk([], {})), []) + + self.run_all_combos(num_tests=1) + self.run_all_combos(num_tests=10, disabled=[1, 2]) + + num_tests = 67 + disabled = list(i for i in range(num_tests) if i % 4 == 0) + self.run_all_combos(num_tests=num_tests, disabled=disabled) + + def test_two_times_more_chunks_than_tests(self): + # test case for bug 1182817 + tests = self.generate_tests(5) + + total_chunks = 10 + for i in range(1, total_chunks + 1): + # ensure IndexError is not raised + chunk_by_slice(i, total_chunks)(tests, {}) + + +class ChunkByDir(TestCase): + """Test chunking related filters""" + + def generate_tests(self, dirs): + """ + :param dirs: dict of the form, + { <dir>: <num tests> } + """ + i = 0 + for d, num in dirs.items(): + for _ in range(num): + i += 1 + name = "test%i" % i + test = {"name": name, "relpath": os.path.join(d, name)} + yield test + + def run_all_combos(self, dirs): + tests = list(self.generate_tests(dirs)) + + deepest = max(len(t["relpath"].split(os.sep)) - 1 for t in tests) + for depth in range(1, deepest + 1): + + def num_groups(tests): + unique = set() + for rp in [t["relpath"] for t in tests]: + p = rp.split(os.sep) + p = p[: min(depth, len(p) - 1)] + unique.add(os.sep.join(p)) + return len(unique) + + for total in range(1, num_groups(tests) + 1): + res = [] + for this in range(1, total + 1): + f = chunk_by_dir(this, total, depth) + res.append(list(f(tests, {}))) + + lengths = list(map(num_groups, res)) + # the chunk with the most dirs should have at most one more + # dir than the chunk with the least dirs + self.assertLessEqual(max(lengths) - min(lengths), 1) + + all_chunks = list(chain.from_iterable(res)) + # chunk_by_dir will mess up order, but chained chunks should + # contain all of the original tests and be the same length + self.assertEqual(len(all_chunks), len(tests)) + for t in tests: + self.assertIn(t, all_chunks) + + def test_chunk_by_dir(self): + chunk = chunk_by_dir(1, 1, 1) + self.assertEqual(list(chunk([], {})), []) + + dirs = { + "a": 2, + } + self.run_all_combos(dirs) + + dirs = { + "": 1, + "foo": 1, + "bar": 0, + "/foobar": 1, + } + self.run_all_combos(dirs) + + dirs = { + "a": 1, + "b": 1, + "a/b": 2, + "a/c": 1, + } + self.run_all_combos(dirs) + + dirs = { + "a": 5, + "a/b": 4, + "a/b/c": 7, + "a/b/c/d": 1, + "a/b/c/e": 3, + "b/c": 2, + "b/d": 5, + "b/d/e": 6, + "c": 8, + "c/d/e/f/g/h/i/j/k/l": 5, + "c/d/e/f/g/i/j/k/l/m/n": 2, + "c/e": 1, + } + self.run_all_combos(dirs) + + +class ChunkByRuntime(TestCase): + """Test chunking related filters""" + + def generate_tests(self, dirs): + """ + :param dirs: dict of the form, + { <dir>: <num tests> } + """ + i = 0 + for d, num in dirs.items(): + for _ in range(num): + i += 1 + name = "test%i" % i + manifest = os.path.join(d, "manifest.toml") + test = { + "name": name, + "relpath": os.path.join(d, name), + "manifest": manifest, + "manifest_relpath": manifest, + } + yield test + + def get_runtimes(self, tests): + runtimes = defaultdict(int) + for test in tests: + runtimes[test["manifest_relpath"]] += random.randint(0, 100) + return runtimes + + def chunk_by_round_robin(self, tests, total, runtimes): + tests_by_manifest = [] + for manifest, runtime in runtimes.items(): + mtests = [t for t in tests if t["manifest_relpath"] == manifest] + tests_by_manifest.append((runtime, mtests)) + tests_by_manifest.sort(key=lambda x: x[0], reverse=False) + + chunks = [[] for i in range(total)] + d = 1 # direction + i = 0 + for runtime, batch in tests_by_manifest: + chunks[i].extend(batch) + + # "draft" style (last pick goes first in the next round) + if (i == 0 and d == -1) or (i == total - 1 and d == 1): + d = -d + else: + i += d + + # make sure this test algorithm is valid + all_chunks = list(chain.from_iterable(chunks)) + self.assertEqual(len(all_chunks), len(tests)) + for t in tests: + self.assertIn(t, all_chunks) + return chunks + + def run_all_combos(self, dirs): + tests = list(self.generate_tests(dirs)) + runtimes = self.get_runtimes(tests) + + for total in range(1, len(dirs) + 1): + chunks = [] + for this in range(1, total + 1): + f = chunk_by_runtime(this, total, runtimes) + ret = list(f(tests, {})) + chunks.append(ret) + + # chunk_by_runtime will mess up order, but chained chunks should + # contain all of the original tests and be the same length + all_chunks = list(chain.from_iterable(chunks)) + self.assertEqual(len(all_chunks), len(tests)) + for t in tests: + self.assertIn(t, all_chunks) + + # calculate delta between slowest and fastest chunks + def runtime_delta(chunks): + totals = [] + for chunk in chunks: + manifests = set([t["manifest_relpath"] for t in chunk]) + total = sum(runtimes[m] for m in manifests) + totals.append(total) + return max(totals) - min(totals) + + delta = runtime_delta(chunks) + + # redo the chunking a second time using a round robin style + # algorithm + chunks = self.chunk_by_round_robin(tests, total, runtimes) + # sanity check the round robin algorithm + all_chunks = list(chain.from_iterable(chunks)) + self.assertEqual(len(all_chunks), len(tests)) + for t in tests: + self.assertIn(t, all_chunks) + + # since chunks will never have exactly equal runtimes, it's hard + # to tell if they were chunked optimally. Make sure it at least + # beats a naive round robin approach. + self.assertLessEqual(delta, runtime_delta(chunks)) + + def test_chunk_by_runtime(self): + random.seed(42) + + chunk = chunk_by_runtime(1, 1, {}) + self.assertEqual(list(chunk([], {})), []) + + dirs = { + "a": 2, + } + self.run_all_combos(dirs) + + dirs = { + "": 1, + "foo": 1, + "bar": 0, + "/foobar": 1, + } + self.run_all_combos(dirs) + + dirs = { + "a": 1, + "b": 1, + "a/b": 2, + "a/c": 1, + } + self.run_all_combos(dirs) + + dirs = { + "a": 5, + "a/b": 4, + "a/b/c": 7, + "a/b/c/d": 1, + "a/b/c/e": 3, + "b/c": 2, + "b/d": 5, + "b/d/e": 6, + "c": 8, + "c/d/e/f/g/h/i/j/k/l": 5, + "c/d/e/f/g/i/j/k/l/m/n": 2, + "c/e": 1, + } + self.run_all_combos(dirs) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/manifestparser/tests/test_convert_directory.py b/testing/mozbase/manifestparser/tests/test_convert_directory.py new file mode 100755 index 0000000000..cebb804ec1 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/test_convert_directory.py @@ -0,0 +1,277 @@ +#!/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/. + +import os +import shutil +import tempfile +import unittest + +import mozunit +from manifestparser import ManifestParser, convert + +here = os.path.dirname(os.path.abspath(__file__)) + +# In some cases tempfile.mkdtemp() may returns a path which contains +# symlinks. Some tests here will then break, as the manifestparser.convert +# function returns paths that does not contains symlinks. +# +# Workaround is to use the following function, if absolute path of temp dir +# must be compared. + + +def create_realpath_tempdir(): + """ + Create a tempdir without symlinks. + """ + return os.path.realpath(tempfile.mkdtemp()) + + +class TestDirectoryConversion(unittest.TestCase): + """test conversion of a directory tree to a manifest structure""" + + def create_stub(self, directory=None): + """stub out a directory with files in it""" + + files = ("foo", "bar", "fleem") + if directory is None: + directory = create_realpath_tempdir() + for i in files: + open(os.path.join(directory, i), "w").write(i) + subdir = os.path.join(directory, "subdir") + os.mkdir(subdir) + open(os.path.join(subdir, "subfile"), "w").write("baz") + return directory + + def test_directory_to_manifest(self): + """ + Test our ability to convert a static directory structure to a + manifest. + """ + + # create a stub directory + stub = self.create_stub() + try: + stub = stub.replace(os.path.sep, "/") + self.assertTrue(os.path.exists(stub) and os.path.isdir(stub)) + + # Make a manifest for it + manifest = convert([stub]) + out_tmpl = """[%(stub)s/bar] + +[%(stub)s/fleem] + +[%(stub)s/foo] + +[%(stub)s/subdir/subfile] + +""" # noqa + self.assertEqual(str(manifest), out_tmpl % dict(stub=stub)) + except BaseException: + raise + finally: + shutil.rmtree(stub) # cleanup + + def test_convert_directory_manifests_in_place(self): + """ + keep the manifests in place + """ + + stub = self.create_stub() + try: + ManifestParser.populate_directory_manifests([stub], filename="manifest.ini") + self.assertEqual( + sorted(os.listdir(stub)), + ["bar", "fleem", "foo", "manifest.ini", "subdir"], + ) + parser = ManifestParser() + parser.read(os.path.join(stub, "manifest.ini")) + self.assertEqual( + [i["name"] for i in parser.tests], ["subfile", "bar", "fleem", "foo"] + ) + parser = ManifestParser() + parser.read(os.path.join(stub, "subdir", "manifest.ini")) + self.assertEqual(len(parser.tests), 1) + self.assertEqual(parser.tests[0]["name"], "subfile") + except BaseException: + raise + finally: + shutil.rmtree(stub) + + def test_convert_directory_manifests_in_place_toml(self): + """ + keep the manifests in place (TOML) + """ + + stub = self.create_stub() + try: + ManifestParser.populate_directory_manifests([stub], filename="manifest.ini") + self.assertEqual( + sorted(os.listdir(stub)), + ["bar", "fleem", "foo", "manifest.ini", "subdir"], + ) + parser = ManifestParser(use_toml=True) + parser.read(os.path.join(stub, "manifest.ini")) + self.assertEqual( + [i["name"] for i in parser.tests], ["subfile", "bar", "fleem", "foo"] + ) + parser = ManifestParser(use_toml=True) + parser.read(os.path.join(stub, "subdir", "manifest.ini")) + self.assertEqual(len(parser.tests), 1) + self.assertEqual(parser.tests[0]["name"], "subfile") + except BaseException: + raise + finally: + shutil.rmtree(stub) + + def test_manifest_ignore(self): + """test manifest `ignore` parameter for ignoring directories""" + + stub = self.create_stub() + try: + ManifestParser.populate_directory_manifests( + [stub], filename="manifest.ini", ignore=("subdir",) + ) + parser = ManifestParser(use_toml=False) + parser.read(os.path.join(stub, "manifest.ini")) + self.assertEqual([i["name"] for i in parser.tests], ["bar", "fleem", "foo"]) + self.assertFalse( + os.path.exists(os.path.join(stub, "subdir", "manifest.ini")) + ) + except BaseException: + raise + finally: + shutil.rmtree(stub) + + def test_manifest_ignore_toml(self): + """test manifest `ignore` parameter for ignoring directories (TOML)""" + + stub = self.create_stub() + try: + ManifestParser.populate_directory_manifests( + [stub], filename="manifest.ini", ignore=("subdir",) + ) + parser = ManifestParser(use_toml=True) + parser.read(os.path.join(stub, "manifest.ini")) + self.assertEqual([i["name"] for i in parser.tests], ["bar", "fleem", "foo"]) + self.assertFalse( + os.path.exists(os.path.join(stub, "subdir", "manifest.ini")) + ) + except BaseException: + raise + finally: + shutil.rmtree(stub) + + def test_pattern(self): + """test directory -> manifest with a file pattern""" + + stub = self.create_stub() + try: + parser = convert([stub], pattern="f*", relative_to=stub) + self.assertEqual([i["name"] for i in parser.tests], ["fleem", "foo"]) + + # test multiple patterns + parser = convert([stub], pattern=("f*", "s*"), relative_to=stub) + self.assertEqual( + [i["name"] for i in parser.tests], ["fleem", "foo", "subdir/subfile"] + ) + except BaseException: + raise + finally: + shutil.rmtree(stub) + + def test_update(self): + """ + Test our ability to update tests from a manifest and a directory of + files + """ + + # boilerplate + tempdir = create_realpath_tempdir() + for i in range(10): + open(os.path.join(tempdir, str(i)), "w").write(str(i)) + + # otherwise empty directory with a manifest file + newtempdir = create_realpath_tempdir() + manifest_file = os.path.join(newtempdir, "manifest.ini") + manifest_contents = str(convert([tempdir], relative_to=tempdir)) + with open(manifest_file, "w") as f: + f.write(manifest_contents) + + # get the manifest + manifest = ManifestParser(manifests=(manifest_file,), use_toml=False) + + # All of the tests are initially missing: + paths = [str(i) for i in range(10)] + self.assertEqual([i["name"] for i in manifest.missing()], paths) + + # But then we copy one over: + self.assertEqual(manifest.get("name", name="1"), ["1"]) + manifest.update(tempdir, name="1") + self.assertEqual(sorted(os.listdir(newtempdir)), ["1", "manifest.ini"]) + + # Update that one file and copy all the "tests": + open(os.path.join(tempdir, "1"), "w").write("secret door") + manifest.update(tempdir) + self.assertEqual( + sorted(os.listdir(newtempdir)), + ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "manifest.ini"], + ) + self.assertEqual( + open(os.path.join(newtempdir, "1")).read().strip(), "secret door" + ) + + # clean up: + shutil.rmtree(tempdir) + shutil.rmtree(newtempdir) + + def test_update_toml(self): + """ + Test our ability to update tests from a manifest and a directory of + files (TOML) + """ + + # boilerplate + tempdir = create_realpath_tempdir() + for i in range(10): + open(os.path.join(tempdir, str(i)), "w").write(str(i)) + + # otherwise empty directory with a manifest file + newtempdir = create_realpath_tempdir() + manifest_file = os.path.join(newtempdir, "manifest.toml") + manifest_contents = str(convert([tempdir], relative_to=tempdir)) + with open(manifest_file, "w") as f: + f.write(manifest_contents) + + # get the manifest + manifest = ManifestParser(manifests=(manifest_file,), use_toml=True) + + # All of the tests are initially missing: + paths = [str(i) for i in range(10)] + self.assertEqual([i["name"] for i in manifest.missing()], paths) + + # But then we copy one over: + self.assertEqual(manifest.get("name", name="1"), ["1"]) + manifest.update(tempdir, name="1") + self.assertEqual(sorted(os.listdir(newtempdir)), ["1", "manifest.toml"]) + + # Update that one file and copy all the "tests": + open(os.path.join(tempdir, "1"), "w").write("secret door") + manifest.update(tempdir) + self.assertEqual( + sorted(os.listdir(newtempdir)), + ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "manifest.toml"], + ) + self.assertEqual( + open(os.path.join(newtempdir, "1")).read().strip(), "secret door" + ) + + # clean up: + shutil.rmtree(tempdir) + shutil.rmtree(newtempdir) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/manifestparser/tests/test_convert_symlinks.py b/testing/mozbase/manifestparser/tests/test_convert_symlinks.py new file mode 100755 index 0000000000..61054c8b78 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/test_convert_symlinks.py @@ -0,0 +1,137 @@ +#!/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/. + +import os +import shutil +import tempfile +import unittest + +import mozunit +from manifestparser import ManifestParser, convert + + +class TestSymlinkConversion(unittest.TestCase): + """ + test conversion of a directory tree with symlinks to a manifest structure + """ + + def create_stub(self, directory=None): + """stub out a directory with files in it""" + + files = ("foo", "bar", "fleem") + if directory is None: + directory = tempfile.mkdtemp() + for i in files: + open(os.path.join(directory, i), "w").write(i) + subdir = os.path.join(directory, "subdir") + os.mkdir(subdir) + open(os.path.join(subdir, "subfile"), "w").write("baz") + return directory + + def test_relpath(self): + """test convert `relative_to` functionality""" + + oldcwd = os.getcwd() + stub = self.create_stub() + try: + # subdir with in-memory manifest + files = ["../bar", "../fleem", "../foo", "subfile"] + subdir = os.path.join(stub, "subdir") + os.chdir(subdir) + parser = convert([stub], relative_to=".") + self.assertEqual([i["name"] for i in parser.tests], files) + except BaseException: + raise + finally: + shutil.rmtree(stub) + os.chdir(oldcwd) + + @unittest.skipIf( + not hasattr(os, "symlink"), "symlinks unavailable on this platform" + ) + def test_relpath_symlink(self): + """ + Ensure `relative_to` works in a symlink. + Not available on windows. + """ + + oldcwd = os.getcwd() + workspace = tempfile.mkdtemp() + try: + tmpdir = os.path.join(workspace, "directory") + os.makedirs(tmpdir) + linkdir = os.path.join(workspace, "link") + os.symlink(tmpdir, linkdir) + self.create_stub(tmpdir) + + # subdir with in-memory manifest + files = ["../bar", "../fleem", "../foo", "subfile"] + subdir = os.path.join(linkdir, "subdir") + os.chdir(os.path.realpath(subdir)) + for directory in (tmpdir, linkdir): + parser = convert([directory], relative_to=".") + self.assertEqual([i["name"] for i in parser.tests], files) + finally: + shutil.rmtree(workspace) + os.chdir(oldcwd) + + # a more complicated example + oldcwd = os.getcwd() + workspace = tempfile.mkdtemp() + try: + tmpdir = os.path.join(workspace, "directory") + os.makedirs(tmpdir) + linkdir = os.path.join(workspace, "link") + os.symlink(tmpdir, linkdir) + self.create_stub(tmpdir) + files = ["../bar", "../fleem", "../foo", "subfile"] + subdir = os.path.join(linkdir, "subdir") + subsubdir = os.path.join(subdir, "sub") + os.makedirs(subsubdir) + linksubdir = os.path.join(linkdir, "linky") + linksubsubdir = os.path.join(subsubdir, "linky") + os.symlink(subdir, linksubdir) + os.symlink(subdir, linksubsubdir) + for dest in (subdir,): + os.chdir(dest) + for directory in (tmpdir, linkdir): + parser = convert([directory], relative_to=".") + self.assertEqual([i["name"] for i in parser.tests], files) + finally: + shutil.rmtree(workspace) + os.chdir(oldcwd) + + @unittest.skipIf( + not hasattr(os, "symlink"), "symlinks unavailable on this platform" + ) + def test_recursion_symlinks(self): + workspace = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, workspace) + + # create two dirs + os.makedirs(os.path.join(workspace, "dir1")) + os.makedirs(os.path.join(workspace, "dir2")) + + # create cyclical symlinks + os.symlink(os.path.join("..", "dir1"), os.path.join(workspace, "dir2", "ldir1")) + os.symlink(os.path.join("..", "dir2"), os.path.join(workspace, "dir1", "ldir2")) + + # create one file in each dir + open(os.path.join(workspace, "dir1", "f1.txt"), "a").close() + open(os.path.join(workspace, "dir1", "ldir2", "f2.txt"), "a").close() + + data = [] + + def callback(rootdirectory, directory, subdirs, files): + for f in files: + data.append(f) + + ManifestParser._walk_directories([workspace], callback) + self.assertEqual(sorted(data), ["f1.txt", "f2.txt"]) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/manifestparser/tests/test_default_overrides.py b/testing/mozbase/manifestparser/tests/test_default_overrides.py new file mode 100755 index 0000000000..8b648cf6cd --- /dev/null +++ b/testing/mozbase/manifestparser/tests/test_default_overrides.py @@ -0,0 +1,138 @@ +#!/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/. + +import os +import re +import unittest + +import mozunit +from manifestparser import ManifestParser, combine_fields + +here = os.path.dirname(os.path.abspath(__file__)) + + +def deepstrip(txt): + "Collapses all repeated blanks to one blank, and strips" + return re.sub(r" +", " ", txt).strip() + + +class TestDefaultSkipif(unittest.TestCase): + """Tests applying a skip-if condition in [DEFAULT] and || with the value for the test""" + + def test_defaults_toml(self): + default = os.path.join(here, "default-skipif.toml") + parser = ManifestParser(manifests=(default,), use_toml=True) + for test in parser.tests: + if test["name"] == "test1": + self.assertEqual( + deepstrip(test["skip-if"]), "os == 'win' && debug\ndebug" + ) + elif test["name"] == "test2": + self.assertEqual( + deepstrip(test["skip-if"]), "os == 'win' && debug\nos == 'linux'" + ) + elif test["name"] == "test3": + self.assertEqual( + deepstrip(test["skip-if"]), "os == 'win' && debug\nos == 'win'" + ) + elif test["name"] == "test4": + self.assertEqual( + deepstrip(test["skip-if"]), + "os == 'win' && debug\nos == 'win' && debug", + ) + elif test["name"] == "test5": + self.assertEqual(deepstrip(test["skip-if"]), "os == 'win' && debug") + elif test["name"] == "test6": + self.assertEqual( + deepstrip(test["skip-if"]), "os == 'win' && debug\ndebug" + ) + + +class TestDefaultSupportFiles(unittest.TestCase): + """Tests combining support-files field in [DEFAULT] with the value for a test""" + + def test_defaults_toml(self): + default = os.path.join(here, "default-suppfiles.toml") + parser = ManifestParser(manifests=(default,), use_toml=True) + expected_supp_files = { + "test7": "foo.js", + "test8": "foo.js bar.js", + "test9": "foo.js", + } + for test in parser.tests: + expected = expected_supp_files[test["name"]] + self.assertEqual(test["support-files"], expected) + + +class TestOmitDefaults(unittest.TestCase): + """Tests passing omit-defaults prevents defaults from propagating to definitions.""" + + def test_defaults_toml(self): + manifests = ( + os.path.join(here, "default-suppfiles.toml"), + os.path.join(here, "default-skipif.toml"), + ) + parser = ManifestParser( + manifests=manifests, handle_defaults=False, use_toml=True + ) + expected_supp_files = { + "test8": "bar.js", + } + expected_skip_ifs = { + "test1": "debug", + "test2": "os == 'linux'", + "test3": "os == 'win'", + "test4": "os == 'win' && debug", + "test6": "debug", + } + for test in parser.tests: + for field, expectations in ( + ("support-files", expected_supp_files), + ("skip-if", expected_skip_ifs), + ): + expected = expectations.get(test["name"]) + if not expected: + self.assertNotIn(field, test) + else: + self.assertEqual(test[field].strip(), expected) + + expected_defaults = { + os.path.join(here, "default-suppfiles.toml"): { + "support-files": "foo.js", + }, + os.path.join(here, "default-skipif.toml"): { + "skip-if": "os == 'win' && debug", + }, + } + for path, defaults in expected_defaults.items(): + self.assertIn(path, parser.manifest_defaults) + actual_defaults = parser.manifest_defaults[path] + for key, value in defaults.items(): + self.assertIn(key, actual_defaults) + self.assertEqual(value, actual_defaults[key].strip()) + + +class TestSubsuiteDefaults(unittest.TestCase): + """Test that subsuites are handled correctly when managing defaults + outside of the manifest parser.""" + + def test_subsuite_defaults_toml(self): + manifest = os.path.join(here, "default-subsuite.toml") + parser = ManifestParser( + manifests=(manifest,), handle_defaults=False, use_toml=True + ) + expected_subsuites = { + "test1": "baz", + "test2": "foo", + } + defaults = parser.manifest_defaults[manifest] + for test in parser.tests: + value = combine_fields(defaults, test) + self.assertEqual(expected_subsuites[value["name"]], value["subsuite"]) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/manifestparser/tests/test_expressionparser.py b/testing/mozbase/manifestparser/tests/test_expressionparser.py new file mode 100755 index 0000000000..2d0eb1be07 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/test_expressionparser.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python + +import unittest + +import mozunit +from manifestparser import parse + + +class ExpressionParserTest(unittest.TestCase): + """Test the conditional expression parser.""" + + def test_basic(self): + self.assertEqual(parse("1"), 1) + self.assertEqual(parse("100"), 100) + self.assertEqual(parse("true"), True) + self.assertEqual(parse("false"), False) + self.assertEqual("", parse('""')) + self.assertEqual(parse('"foo bar"'), "foo bar") + self.assertEqual(parse("'foo bar'"), "foo bar") + self.assertEqual(parse("foo", foo=1), 1) + self.assertEqual(parse("bar", bar=True), True) + self.assertEqual(parse("abc123", abc123="xyz"), "xyz") + + def test_equality(self): + self.assertTrue(parse("true == true")) + self.assertTrue(parse("false == false")) + self.assertTrue(parse("1 == 1")) + self.assertTrue(parse("100 == 100")) + self.assertTrue(parse('"some text" == "some text"')) + self.assertTrue(parse("true != false")) + self.assertTrue(parse("1 != 2")) + self.assertTrue(parse('"text" != "other text"')) + self.assertTrue(parse("foo == true", foo=True)) + self.assertTrue(parse("foo == 1", foo=1)) + self.assertTrue(parse('foo == "bar"', foo="bar")) + self.assertTrue(parse("foo == bar", foo=True, bar=True)) + self.assertTrue(parse("true == foo", foo=True)) + self.assertTrue(parse("foo != true", foo=False)) + self.assertTrue(parse("foo != 2", foo=1)) + self.assertTrue(parse('foo != "bar"', foo="abc")) + self.assertTrue(parse("foo != bar", foo=True, bar=False)) + self.assertTrue(parse("true != foo", foo=False)) + self.assertTrue(parse("!false")) + + def test_conjunctures(self): + self.assertTrue(parse("true && true")) + self.assertTrue(parse("true || false")) + self.assertFalse(parse("false || false")) + self.assertFalse(parse("true && false")) + self.assertTrue(parse("true || false && false")) + + def test_parentheses(self): + self.assertTrue(parse("(true)")) + self.assertEqual(parse("(10)"), 10) + self.assertEqual(parse('("foo")'), "foo") + self.assertEqual(parse("(foo)", foo=1), 1) + self.assertTrue(parse("(true == true)"), True) + self.assertTrue(parse("(true != false)")) + self.assertTrue(parse("(true && true)")) + self.assertTrue(parse("(true || false)")) + self.assertTrue(parse("(true && true || false)")) + self.assertFalse(parse("(true || false) && false")) + self.assertTrue(parse("(true || false) && true")) + self.assertTrue(parse("true && (true || false)")) + self.assertTrue(parse("true && (true || false)")) + self.assertTrue(parse("(true && false) || (true && (true || false))")) + + def test_comments(self): + # comments in expressions work accidentally, via an implementation + # detail - the '#' character doesn't match any of the regular + # expressions we specify as tokens, and thus are ignored. + # However, having explicit tests for them means that should the + # implementation ever change, comments continue to work, even if that + # means a new implementation must handle them explicitly. + self.assertTrue(parse("true == true # it does!")) + self.assertTrue(parse("false == false # it does")) + self.assertTrue(parse("false != true # it doesnt")) + self.assertTrue(parse('"string with #" == "string with #" # really, it does')) + self.assertTrue( + parse('"string with #" != "string with # but not the same" # no match!') + ) + + def test_not(self): + """ + Test the ! operator. + """ + self.assertTrue(parse("!false")) + self.assertTrue(parse("!(false)")) + self.assertFalse(parse("!true")) + self.assertFalse(parse("!(true)")) + self.assertTrue(parse("!true || true)")) + self.assertTrue(parse("true || !true)")) + self.assertFalse(parse("!true && true")) + self.assertFalse(parse("true && !true")) + + def test_lesser_than(self): + """ + Test the < operator. + """ + self.assertTrue(parse("1 < 2")) + self.assertFalse(parse("3 < 2")) + self.assertTrue(parse("false || (1 < 2)")) + self.assertTrue(parse("1 < 2 && true")) + self.assertTrue(parse("true && 1 < 2")) + self.assertTrue(parse("!(5 < 1)")) + self.assertTrue(parse("'abc' < 'def'")) + self.assertFalse(parse("1 < 1")) + self.assertFalse(parse("'abc' < 'abc'")) + + def test_greater_than(self): + """ + Test the > operator. + """ + self.assertTrue(parse("2 > 1")) + self.assertFalse(parse("2 > 3")) + self.assertTrue(parse("false || (2 > 1)")) + self.assertTrue(parse("2 > 1 && true")) + self.assertTrue(parse("true && 2 > 1")) + self.assertTrue(parse("!(1 > 5)")) + self.assertTrue(parse("'def' > 'abc'")) + self.assertFalse(parse("1 > 1")) + self.assertFalse(parse("'abc' > 'abc'")) + + def test_lesser_or_equals_than(self): + """ + Test the <= operator. + """ + self.assertTrue(parse("1 <= 2")) + self.assertFalse(parse("3 <= 2")) + self.assertTrue(parse("false || (1 <= 2)")) + self.assertTrue(parse("1 < 2 && true")) + self.assertTrue(parse("true && 1 <= 2")) + self.assertTrue(parse("!(5 <= 1)")) + self.assertTrue(parse("'abc' <= 'def'")) + self.assertTrue(parse("1 <= 1")) + self.assertTrue(parse("'abc' <= 'abc'")) + + def test_greater_or_equals_than(self): + """ + Test the > operator. + """ + self.assertTrue(parse("2 >= 1")) + self.assertFalse(parse("2 >= 3")) + self.assertTrue(parse("false || (2 >= 1)")) + self.assertTrue(parse("2 >= 1 && true")) + self.assertTrue(parse("true && 2 >= 1")) + self.assertTrue(parse("!(1 >= 5)")) + self.assertTrue(parse("'def' >= 'abc'")) + self.assertTrue(parse("1 >= 1")) + self.assertTrue(parse("'abc' >= 'abc'")) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/manifestparser/tests/test_filters.py b/testing/mozbase/manifestparser/tests/test_filters.py new file mode 100644 index 0000000000..158741205e --- /dev/null +++ b/testing/mozbase/manifestparser/tests/test_filters.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python + +import os +from copy import deepcopy +from pprint import pprint + +import mozpack.path as mozpath +import mozunit +import pytest +from manifestparser.filters import ( + enabled, + fail_if, + failures, + filterlist, + pathprefix, + run_if, + skip_if, + subsuite, + tags, +) + +here = os.path.dirname(os.path.abspath(__file__)) + + +def test_data_model(): + def foo(x, y): + return x + + def bar(x, y): + return x + + def baz(x, y): + return x + + fl = filterlist() + + fl.extend([foo, bar]) + assert len(fl) == 2 + assert foo in fl + + fl.append(baz) + assert fl[2] == baz + + fl.remove(baz) + assert baz not in fl + + item = fl.pop() + assert item == bar + + assert fl.index(foo) == 0 + + del fl[0] + assert foo not in fl + with pytest.raises(IndexError): + fl[0] + + +def test_add_non_callable_to_list(): + fl = filterlist() + with pytest.raises(TypeError): + fl.append("foo") + + +def test_add_duplicates_to_list(): + def foo(x, y): + return x + + def bar(x, y): + return x + + sub = subsuite("foo") + fl = filterlist([foo, bar, sub]) + assert len(fl) == 3 + assert fl[0] == foo + + with pytest.raises(ValueError): + fl.append(foo) + + with pytest.raises(ValueError): + fl.append(subsuite("bar")) + + +def test_add_two_tags_filters(): + tag1 = tags("foo") + tag2 = tags("bar") + fl = filterlist([tag1]) + + with pytest.raises(ValueError): + fl.append(tag1) + + fl.append(tag2) + assert len(fl) == 2 + + +def test_filters_run_in_order(): + def a(x, y): + return x + + def b(x, y): + return x + + def c(x, y): + return x + + def d(x, y): + return x + + def e(x, y): + return x + + def f(x, y): + return x + + fl = filterlist([a, b]) + fl.append(c) + fl.extend([d, e]) + fl += [f] + assert [i for i in fl] == [a, b, c, d, e, f] + + +@pytest.fixture(scope="module") +def create_tests(): + def inner(*paths, **defaults): + tests = [] + for p in paths: + path = p + if isinstance(path, tuple): + path, kwargs = path + else: + kwargs = {} + + path = mozpath.normpath(path) + manifest = kwargs.pop( + "manifest", + defaults.pop( + "manifest", mozpath.join(mozpath.dirname(path), "manifest.ini") + ), + ) + test = { + "name": mozpath.basename(path), + "path": "/root/" + path, + "relpath": path, + "manifest": "/root/" + manifest, + "manifest_relpath": manifest, + } + test.update(**defaults) + test.update(**kwargs) + tests.append(test) + + # dump tests to stdout for easier debugging on failure + print("The 'create_tests' fixture returned:") + pprint(tests, indent=2) + return tests + + return inner + + +@pytest.fixture +def tests(create_tests): + return create_tests( + "test0", + ("test1", {"skip-if": "foo == 'bar'\nintermittent&&!debug"}), + ("test2", {"run-if": "foo == 'bar'"}), + ("test3", {"fail-if": "foo == 'bar'"}), + ("test4", {"disabled": "some reason"}), + ("test5", {"subsuite": "baz"}), + ("test6", {"subsuite": "baz,foo == 'bar'"}), + ("test7", {"tags": "foo bar"}), + ( + "test8", + {"skip-if": "\nbaz\nfoo == 'bar'\nfoo == 'baz'\nintermittent && debug"}, + ), + ) + + +def test_skip_if(tests): + ref = deepcopy(tests) + tests = list(skip_if(tests, {})) + assert len(tests) == len(ref) + + tests = deepcopy(ref) + tests = list(skip_if(tests, {"foo": "bar"})) + assert "disabled" in tests[1] + assert "disabled" in tests[8] + + +def test_run_if(tests): + ref = deepcopy(tests) + tests = list(run_if(tests, {})) + assert "disabled" in tests[2] + + tests = deepcopy(ref) + tests = list(run_if(tests, {"foo": "bar"})) + assert "disabled" not in tests[2] + + +def test_fail_if(tests): + ref = deepcopy(tests) + tests = list(fail_if(tests, {})) + assert "expected" not in tests[3] + + tests = deepcopy(ref) + tests = list(fail_if(tests, {"foo": "bar"})) + assert tests[3]["expected"] == "fail" + + +def test_enabled(tests): + ref = deepcopy(tests) + tests = list(enabled(tests, {})) + assert ref[4] not in tests + + +def test_subsuite(tests): + sub1 = subsuite() + sub2 = subsuite("baz") + + ref = deepcopy(tests) + tests = list(sub1(tests, {})) + assert ref[5] not in tests + assert len(tests) == len(ref) - 1 + + tests = deepcopy(ref) + tests = list(sub2(tests, {})) + assert len(tests) == 1 + assert ref[5] in tests + + +def test_subsuite_condition(tests): + sub1 = subsuite() + sub2 = subsuite("baz") + + ref = deepcopy(tests) + + tests = list(sub1(tests, {"foo": "bar"})) + assert ref[5] not in tests + assert ref[6] not in tests + + tests = deepcopy(ref) + tests = list(sub2(tests, {"foo": "bar"})) + assert len(tests) == 2 + assert tests[0]["name"] == "test5" + assert tests[1]["name"] == "test6" + + +def test_tags(tests): + ftags1 = tags([]) + ftags2 = tags(["bar", "baz"]) + + ref = deepcopy(tests) + tests = list(ftags1(tests, {})) + assert len(tests) == 0 + + tests = deepcopy(ref) + tests = list(ftags2(tests, {})) + assert len(tests) == 1 + assert ref[7] in tests + + +def test_failures(tests): + ref = deepcopy(tests) + fail1 = failures("intermittent") + tests = list(fail1(tests, {"intermittent": True, "debug": True})) + assert len(tests) == 1 + + tests = deepcopy(ref) + tests = list(fail1(tests, {"intermittent": True})) + assert len(tests) == 1 + + tests = deepcopy(ref) + tests = list(fail1(tests, {})) + assert len(tests) == 0 + + tests = deepcopy(ref) + tests = list(fail1(tests, {"intermittent": False, "debug": True})) + assert len(tests) == 0 + + +def test_pathprefix(create_tests): + tests = create_tests( + "test0", + "subdir/test1", + "subdir/test2", + ("subdir/test3", {"manifest": "manifest.ini"}), + ( + "other/test4", + { + "manifest": "manifest-common.toml", + "ancestor_manifest": "other/manifest.ini", + }, + ), + ) + + def names(items): + return sorted(i["name"] for i in items) + + # relative directory + prefix = pathprefix("subdir") + filtered = prefix(tests, {}) + assert names(filtered) == ["test1", "test2", "test3"] + + # absolute directory + prefix = pathprefix(["/root/subdir"]) + filtered = prefix(tests, {}) + assert names(filtered) == ["test1", "test2", "test3"] + + # relative manifest + prefix = pathprefix(["subdir/manifest.ini"]) + filtered = prefix(tests, {}) + assert names(filtered) == ["test1", "test2"] + + # absolute manifest + prefix = pathprefix(["/root/subdir/manifest.ini"]) + filtered = prefix(tests, {}) + assert names(filtered) == ["test1", "test2"] + + # mixed test and manifest + prefix = pathprefix(["subdir/test2", "manifest.ini"]) + filtered = prefix(tests, {}) + assert names(filtered) == ["test0", "test2", "test3"] + + # relative ancestor manifest + prefix = pathprefix(["other/manifest.ini"]) + filtered = prefix(tests, {}) + assert names(filtered) == ["test4"] + + # absolute ancestor manifest + prefix = pathprefix(["/root/other/manifest.ini"]) + filtered = prefix(tests, {}) + assert names(filtered) == ["test4"] + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/manifestparser/tests/test_manifestparser.py b/testing/mozbase/manifestparser/tests/test_manifestparser.py new file mode 100755 index 0000000000..f1774cfffb --- /dev/null +++ b/testing/mozbase/manifestparser/tests/test_manifestparser.py @@ -0,0 +1,627 @@ +#!/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/. + +import os +import shutil +import tempfile +import unittest +from io import StringIO + +import manifestparser.toml +import mozunit +from manifestparser import ManifestParser +from tomlkit import TOMLDocument + +here = os.path.dirname(os.path.abspath(__file__)) + + +class TestManifestParser(unittest.TestCase): + """ + Test the manifest parser + + You must have manifestparser installed before running these tests. + Run ``python manifestparser.py setup develop`` with setuptools installed. + """ + + def test_sanity_toml(self): + """Ensure basic parser is sane (TOML)""" + + parser = ManifestParser(use_toml=True) + mozmill_example = os.path.join(here, "mozmill-example.toml") + parser.read(mozmill_example) + tests = parser.tests + self.assertEqual( + len(tests), len(open(mozmill_example).read().strip().splitlines()) + ) + + # Ensure that capitalization and order aren't an issue: + lines = ['["%s"]' % test["name"] for test in tests] + self.assertEqual(lines, open(mozmill_example).read().strip().splitlines()) + + # Show how you select subsets of tests: + mozmill_restart_example = os.path.join(here, "mozmill-restart-example.toml") + parser.read(mozmill_restart_example) + restart_tests = parser.get(type="restart") + self.assertTrue(len(restart_tests) < len(parser.tests)) + self.assertEqual( + len(restart_tests), len(parser.get(manifest=mozmill_restart_example)) + ) + self.assertFalse( + [ + test + for test in restart_tests + if test["manifest"] + != os.path.join(here, "mozmill-restart-example.toml") + ] + ) + self.assertEqual( + parser.get("name", tags=["foo"]), + [ + "restartTests/testExtensionInstallUninstall/test2.js", + "restartTests/testExtensionInstallUninstall/test1.js", + ], + ) + self.assertEqual( + parser.get("name", foo="bar"), + ["restartTests/testExtensionInstallUninstall/test2.js"], + ) + + def test_include(self): + """Illustrate how include works""" + + include_example = os.path.join(here, "include-example.toml") + parser = ManifestParser(manifests=(include_example,), use_toml=False) + + # All of the tests should be included, in order: + self.assertEqual(parser.get("name"), ["crash-handling", "fleem", "flowers"]) + self.assertEqual( + [ + (test["name"], os.path.basename(test["manifest"])) + for test in parser.tests + ], + [ + ("crash-handling", "bar.toml"), + ("fleem", "include-example.toml"), + ("flowers", "foo.toml"), + ], + ) + + # The including manifest is always reported as a part of the generated test object. + self.assertTrue( + all( + [ + t["ancestor_manifest"] == "include-example.toml" + for t in parser.tests + if t["name"] != "fleem" + ] + ) + ) + + # The manifests should be there too: + self.assertEqual(len(parser.manifests()), 3) + + # We already have the root directory: + self.assertEqual(here, parser.rootdir) + + # DEFAULT values should persist across includes, unless they're + # overwritten. In this example, include-example.toml sets foo=bar, but + # it's overridden to fleem in bar.toml + self.assertEqual(parser.get("name", foo="bar"), ["fleem", "flowers"]) + self.assertEqual(parser.get("name", foo="fleem"), ["crash-handling"]) + + # Passing parameters in the include section allows defining variables in + # the submodule scope: + self.assertEqual(parser.get("name", tags=["red"]), ["flowers"]) + + # However, this should be overridable from the DEFAULT section in the + # included file and that overridable via the key directly connected to + # the test: + self.assertEqual(parser.get(name="flowers")[0]["blue"], "ocean") + self.assertEqual(parser.get(name="flowers")[0]["yellow"], "submarine") + + # You can query multiple times if you need to: + flowers = parser.get(foo="bar") + self.assertEqual(len(flowers), 2) + + # Using the inverse flag should invert the set of tests returned: + self.assertEqual( + parser.get("name", inverse=True, tags=["red"]), ["crash-handling", "fleem"] + ) + + # All of the included tests actually exist: + self.assertEqual([i["name"] for i in parser.missing()], []) + + # Write the output to a manifest: + buffer = StringIO() + parser.write(fp=buffer, global_kwargs={"foo": "bar"}) + expected_output = """[DEFAULT] +foo = bar + +[fleem] + +[include/flowers] +blue = ocean +red = roses +yellow = submarine""" # noqa + + self.assertEqual(buffer.getvalue().strip(), expected_output) + + def test_include_toml(self): + """Illustrate how include works (TOML)""" + + include_example = os.path.join(here, "include-example.toml") + parser = ManifestParser(manifests=(include_example,), use_toml=True) + + # All of the tests should be included, in order: + self.assertEqual(parser.get("name"), ["crash-handling", "fleem", "flowers"]) + self.assertEqual( + [ + (test["name"], os.path.basename(test["manifest"])) + for test in parser.tests + ], + [ + ("crash-handling", "bar.toml"), + ("fleem", "include-example.toml"), + ("flowers", "foo.toml"), + ], + ) + + # The including manifest is always reported as a part of the generated test object. + self.assertTrue( + all( + [ + t["ancestor_manifest"] == "include-example.toml" + for t in parser.tests + if t["name"] != "fleem" + ] + ) + ) + + # The manifests should be there too: + self.assertEqual(len(parser.manifests()), 3) + + # We already have the root directory: + self.assertEqual(here, parser.rootdir) + + # DEFAULT values should persist across includes, unless they're + # overwritten. In this example, include-example.toml sets foo=bar, but + # it's overridden to fleem in bar.toml + self.assertEqual(parser.get("name", foo="bar"), ["fleem", "flowers"]) + self.assertEqual(parser.get("name", foo="fleem"), ["crash-handling"]) + + # Passing parameters in the include section allows defining variables in + # the submodule scope: + self.assertEqual(parser.get("name", tags=["red"]), ["flowers"]) + + # However, this should be overridable from the DEFAULT section in the + # included file and that overridable via the key directly connected to + # the test: + self.assertEqual(parser.get(name="flowers")[0]["blue"], "ocean") + self.assertEqual(parser.get(name="flowers")[0]["yellow"], "submarine") + + # You can query multiple times if you need to: + flowers = parser.get(foo="bar") + self.assertEqual(len(flowers), 2) + + # Using the inverse flag should invert the set of tests returned: + self.assertEqual( + parser.get("name", inverse=True, tags=["red"]), ["crash-handling", "fleem"] + ) + + # All of the included tests actually exist: + self.assertEqual([i["name"] for i in parser.missing()], []) + + # Write the output to a manifest: + buffer = StringIO() + parser.write(fp=buffer, global_kwargs={"foo": "bar"}) + expected_output = """[DEFAULT] +foo = bar + +[fleem] + +[include/flowers] +blue = ocean +red = roses +yellow = submarine""" # noqa + + self.assertEqual(buffer.getvalue().strip(), expected_output) + + def test_include_manifest_defaults_toml(self): + """ + Test that manifest_defaults and manifests() are correctly populated + when includes are used. (TOML) + """ + + include_example = os.path.join(here, "include-example.toml") + noinclude_example = os.path.join(here, "just-defaults.toml") + bar_path = os.path.join(here, "include", "bar.toml") + foo_path = os.path.join(here, "include", "foo.toml") + + parser = ManifestParser( + manifests=(include_example, noinclude_example), rootdir=here, use_toml=True + ) + + # Standalone manifests must be appear as-is. + self.assertTrue(include_example in parser.manifest_defaults) + self.assertTrue(noinclude_example in parser.manifest_defaults) + + # Included manifests must only appear together with the parent manifest + # that included the manifest. + self.assertFalse(bar_path in parser.manifest_defaults) + self.assertFalse(foo_path in parser.manifest_defaults) + ancestor_toml = os.path.relpath(include_example, parser.rootdir) + self.assertTrue((ancestor_toml, bar_path) in parser.manifest_defaults) + self.assertTrue((ancestor_toml, foo_path) in parser.manifest_defaults) + + # manifests() must only return file paths (strings). + manifests = parser.manifests() + self.assertEqual(len(manifests), 4) + self.assertIn(foo_path, manifests) + self.assertIn(bar_path, manifests) + self.assertIn(include_example, manifests) + self.assertIn(noinclude_example, manifests) + + def test_include_handle_defaults_False_toml(self): + """ + Test that manifest_defaults and manifests() are correct even when + handle_defaults is set to False. (TOML) + """ + manifest = os.path.join(here, "include-example.toml") + foo_path = os.path.join(here, "include", "foo.toml") + + parser = ManifestParser( + manifests=(manifest,), handle_defaults=False, rootdir=here, use_toml=True + ) + ancestor_ini = os.path.relpath(manifest, parser.rootdir) + + self.assertIn(manifest, parser.manifest_defaults) + self.assertNotIn(foo_path, parser.manifest_defaults) + self.assertIn((ancestor_ini, foo_path), parser.manifest_defaults) + self.assertEqual( + parser.manifest_defaults[manifest], + { + "foo": "bar", + "here": here, + }, + ) + self.assertEqual( + parser.manifest_defaults[(ancestor_ini, foo_path)], + { + "here": os.path.join(here, "include"), + "red": "roses", + "blue": "ocean", + "yellow": "daffodils", + }, + ) + + def test_include_repeated_toml(self): + """ + Test that repeatedly included manifests are independent of each other. (TOML) + """ + include_example = os.path.join(here, "include-example.toml") + included_foo = os.path.join(here, "include", "foo.toml") + + # In the expected output, blue and yellow have the values from foo.toml + # (ocean, submarine) instead of the ones from include-example.toml + # (violets, daffodils), because the defaults in the included file take + # precedence over the values from the parent. + include_output = """[include/crash-handling] +foo = fleem + +[fleem] +foo = bar + +[include/flowers] +blue = ocean +foo = bar +red = roses +yellow = submarine + +""" + included_output = """[include/flowers] +blue = ocean +yellow = submarine + +""" + + parser = ManifestParser( + manifests=(include_example, included_foo), rootdir=here, use_toml=True + ) + self.assertEqual( + parser.get("name"), ["crash-handling", "fleem", "flowers", "flowers"] + ) + self.assertEqual( + [ + (test["name"], os.path.basename(test["manifest"])) + for test in parser.tests + ], + [ + ("crash-handling", "bar.toml"), + ("fleem", "include-example.toml"), + ("flowers", "foo.toml"), + ("flowers", "foo.toml"), + ], + ) + self.check_included_repeat( + parser, + parser.tests[3], + parser.tests[2], + "%s%s" % (include_output, included_output), + True, + ) + + # Same tests, but with the load order of the manifests swapped. + parser = ManifestParser( + manifests=(included_foo, include_example), rootdir=here, use_toml=True + ) + self.assertEqual( + parser.get("name"), ["flowers", "crash-handling", "fleem", "flowers"] + ) + self.assertEqual( + [ + (test["name"], os.path.basename(test["manifest"])) + for test in parser.tests + ], + [ + ("flowers", "foo.toml"), + ("crash-handling", "bar.toml"), + ("fleem", "include-example.toml"), + ("flowers", "foo.toml"), + ], + ) + self.check_included_repeat( + parser, + parser.tests[0], + parser.tests[3], + "%s%s" % (included_output, include_output), + True, + ) + + def check_included_repeat( + self, parser, isolated_test, included_test, expected_output, use_toml=False + ): + if use_toml: + include_example_filename = "include-example.toml" + foo_filename = "foo.toml" + else: + include_example_filename = "include-example.toml" + foo_filename = "foo.toml" + include_example = os.path.join(here, include_example_filename) + included_foo = os.path.join(here, "include", foo_filename) + ancestor_ini = os.path.relpath(include_example, parser.rootdir) + manifest_default_key = (ancestor_ini, included_foo) + + self.assertFalse("ancestor_manifest" in isolated_test) + self.assertEqual(included_test["ancestor_manifest"], include_example_filename) + + self.assertTrue(include_example in parser.manifest_defaults) + self.assertTrue(included_foo in parser.manifest_defaults) + self.assertTrue(manifest_default_key in parser.manifest_defaults) + self.assertEqual( + parser.manifest_defaults[manifest_default_key], + { + "foo": "bar", + "here": os.path.join(here, "include"), + "red": "roses", + "blue": "ocean", + "yellow": "daffodils", + }, + ) + + buffer = StringIO() + parser.write(fp=buffer) + self.assertEqual(buffer.getvalue(), expected_output) + + def test_invalid_path_toml(self): + """ + Test invalid path should not throw when not strict (TOML) + """ + manifest = os.path.join(here, "include-invalid.toml") + ManifestParser(manifests=(manifest,), strict=False, use_toml=True) + + def test_copy_toml(self): + """Test our ability to copy a set of manifests (TOML)""" + + tempdir = tempfile.mkdtemp() + include_example = os.path.join(here, "include-example.toml") + manifest = ManifestParser(manifests=(include_example,), use_toml=True) + manifest.copy(tempdir) + self.assertEqual( + sorted(os.listdir(tempdir)), ["fleem", "include", "include-example.toml"] + ) + self.assertEqual( + sorted(os.listdir(os.path.join(tempdir, "include"))), + ["bar.toml", "crash-handling", "flowers", "foo.toml"], + ) + from_manifest = ManifestParser(manifests=(include_example,), use_toml=True) + to_manifest = os.path.join(tempdir, "include-example.toml") + to_manifest = ManifestParser(manifests=(to_manifest,), use_toml=True) + self.assertEqual(to_manifest.get("name"), from_manifest.get("name")) + shutil.rmtree(tempdir) + + def test_path_override_toml(self): + """You can override the path in the section too. + This shows that you can use a relative path""" + path_example = os.path.join(here, "path-example.toml") + manifest = ManifestParser(manifests=(path_example,), use_toml=True) + self.assertEqual(manifest.tests[0]["path"], os.path.join(here, "fleem")) + + def test_relative_path_toml(self): + """ + Relative test paths are correctly calculated. (TOML) + """ + relative_path = os.path.join(here, "relative-path.toml") + manifest = ManifestParser(manifests=(relative_path,), use_toml=True) + self.assertEqual( + manifest.tests[0]["path"], os.path.join(os.path.dirname(here), "fleem") + ) + self.assertEqual(manifest.tests[0]["relpath"], os.path.join("..", "fleem")) + self.assertEqual( + manifest.tests[1]["relpath"], os.path.join("..", "testsSIBLING", "example") + ) + + def test_path_from_fd(self): + """ + Test paths are left untouched when manifest is a file-like object. + """ + fp = StringIO("[section]\npath=fleem") + manifest = ManifestParser(manifests=(fp,)) + self.assertEqual(manifest.tests[0]["path"], "fleem") + self.assertEqual(manifest.tests[0]["relpath"], "fleem") + self.assertEqual(manifest.tests[0]["manifest"], None) + + def test_comments_toml(self): + """ + ensure comments work, see + https://bugzilla.mozilla.org/show_bug.cgi?id=813674 + (TOML) + """ + comment_example = os.path.join(here, "comment-example.toml") + manifest = ManifestParser(manifests=(comment_example,), use_toml=True) + self.assertEqual(len(manifest.tests), 8) + names = [i["name"] for i in manifest.tests] + self.assertFalse("test_0202_app_launch_apply_update_dirlocked.js" in names) + + def test_verifyDirectory_toml(self): + directory = os.path.join(here, "verifyDirectory") + + # correct manifest + manifest_path = os.path.join(directory, "verifyDirectory.toml") + manifest = ManifestParser(manifests=(manifest_path,), use_toml=True) + missing = manifest.verifyDirectory(directory, extensions=(".js",)) + self.assertEqual(missing, (set(), set())) + + # manifest is missing test_1.js + test_1 = os.path.join(directory, "test_1.js") + manifest_path = os.path.join(directory, "verifyDirectory_incomplete.toml") + manifest = ManifestParser(manifests=(manifest_path,), use_toml=True) + missing = manifest.verifyDirectory(directory, extensions=(".js",)) + self.assertEqual(missing, (set(), set([test_1]))) + + # filesystem is missing test_notappearinginthisfilm.js + missing_test = os.path.join(directory, "test_notappearinginthisfilm.js") + manifest_path = os.path.join(directory, "verifyDirectory_toocomplete.toml") + manifest = ManifestParser(manifests=(manifest_path,), use_toml=True) + missing = manifest.verifyDirectory(directory, extensions=(".js",)) + self.assertEqual(missing, (set([missing_test]), set())) + + def test_just_defaults_toml(self): + """Ensure a manifest with just a DEFAULT section exposes that data. (TOML)""" + + parser = ManifestParser(use_toml=True) + manifest = os.path.join(here, "just-defaults.toml") + parser.read(manifest) + self.assertEqual(len(parser.tests), 0) + self.assertTrue(manifest in parser.manifest_defaults) + self.assertEqual(parser.manifest_defaults[manifest]["foo"], "bar") + + def test_manifest_list_toml(self): + """ + Ensure a manifest with just a DEFAULT section still returns + itself from the manifests() method. (TOML) + """ + + parser = ManifestParser(use_toml=True) + manifest = os.path.join(here, "no-tests.toml") + parser.read(manifest) + self.assertEqual(len(parser.tests), 0) + self.assertTrue(len(parser.manifests()) == 1) + + def test_manifest_with_invalid_condition_toml(self): + """ + Ensure a skip-if or similar condition with an assignment in it + causes errors. (TOML) + """ + + parser = ManifestParser(use_toml=True) + manifest = os.path.join(here, "broken-skip-if.toml") + with self.assertRaisesRegex( + Exception, "Should not assign in skip-if condition for DEFAULT" + ): + parser.read(manifest) + + def test_parse_error_toml(self): + """ + Verify handling of a mal-formed TOML file + """ + + parser = ManifestParser(use_toml=True) + manifest = os.path.join(here, "parse-error.toml") + with self.assertRaisesRegex( + Exception, + r".*'str' object has no attribute 'keys'.*", + ): + parser.read(manifest) + + def test_parse_error_tomlkit(self): + """ + Verify handling of a mal-formed TOML file + """ + + parser = ManifestParser(use_toml=True, document=True) + manifest = os.path.join(here, "parse-error.toml") + with self.assertRaisesRegex( + Exception, + r".*'String' object has no attribute 'keys'.*", + ): + parser.read(manifest) + + def test_edit_manifest(self): + """ + Verify reading and writing TOML manifest with tomlkit + """ + parser = ManifestParser(use_toml=True, document=True) + before = "edit-manifest-before.toml" + before_path = os.path.join(here, before) + parser.read(before_path) + assert before_path in parser.source_documents + manifest = parser.source_documents[before_path] + assert manifest is not None + assert isinstance(manifest, TOMLDocument) + + filename = "bug_20.js" + assert filename in manifest + condition1a = "os == 'mac'" + bug = "Bug 20" + manifestparser.toml.add_skip_if(manifest, filename, condition1a, bug) + condition1b = "os == 'windows'" + manifestparser.toml.add_skip_if(manifest, filename, condition1b, bug) + + filename2 = "test_foo.html" + assert filename2 in manifest + condition2 = "os == 'mac' && debug" + manifestparser.toml.add_skip_if(manifest, filename2, condition2) + + filename3 = "test_bar.html" + assert filename3 in manifest + condition3a = "tsan" + bug3a = "Bug 444" + manifestparser.toml.add_skip_if(manifest, filename3, condition3a, bug3a) + condition3b = "os == 'linux'" # pre-existing, should be ignored + bug3b = "Bug 555" + manifestparser.toml.add_skip_if(manifest, filename3, condition3b, bug3b) + + filename4 = "bug_100.js" + assert filename4 in manifest + condition4 = "apple_catalina" + bug4 = "Bug 200" + manifestparser.toml.add_skip_if(manifest, filename4, condition4, bug4) + + filename5 = "bug_3.js" + assert filename5 in manifest + condition5 = "verify" + bug5 = "Bug 33333" + manifestparser.toml.add_skip_if(manifest, filename5, condition5, bug5) + + manifest_str = manifestparser.toml.alphabetize_toml_str(manifest) + after = "edit-manifest-after.toml" + after_path = os.path.join(here, after) + after_str = open(after_path, "r", encoding="utf-8").read() + assert manifest_str == after_str + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/manifestparser/tests/test_read_ini.py b/testing/mozbase/manifestparser/tests/test_read_ini.py new file mode 100755 index 0000000000..0d5a3ee250 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/test_read_ini.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python + +""" +test .ini parsing + +ensure our .ini parser is doing what we want; to be deprecated for +python's standard ConfigParser when 2.7 is reality so OrderedDict +is the default: + +http://docs.python.org/2/library/configparser.html +""" + +from io import StringIO +from textwrap import dedent + +import mozunit +import pytest +from manifestparser import read_ini + + +@pytest.fixture(scope="module") +def parse_manifest(): + def inner(string, **kwargs): + buf = StringIO() + buf.write(dedent(string)) + buf.seek(0) + return read_ini(buf, **kwargs)[0] + + return inner + + +def test_inline_comments(parse_manifest): + result = parse_manifest( + """ + [test_felinicity.py] + kittens = true # This test requires kittens + cats = false#but not cats + """ + )[0][1] + + # make sure inline comments get stripped out, but comments without a space in front don't + assert result["kittens"] == "true" + assert result["cats"] == "false#but not cats" + + +def test_line_continuation(parse_manifest): + result = parse_manifest( + """ + [test_caninicity.py] + breeds = + sheppard + retriever + terrier + + [test_cats_and_dogs.py] + cats=yep + dogs= + yep + yep + birds=nope + fish=nope + """ + ) + assert result[0][1]["breeds"].split() == ["sheppard", "retriever", "terrier"] + assert result[1][1]["cats"] == "yep" + assert result[1][1]["dogs"].split() == ["yep", "yep"] + assert result[1][1]["birds"].split() == ["nope", "fish=nope"] + + +def test_dupes_error(parse_manifest): + dupes = """ + [test_dupes.py] + foo = bar + foo = baz + """ + with pytest.raises(AssertionError): + parse_manifest(dupes, strict=True) + + with pytest.raises(AssertionError): + parse_manifest(dupes, strict=False) + + +def test_defaults_handling(parse_manifest): + manifest = """ + [DEFAULT] + flower = rose + skip-if = true + + [test_defaults] + """ + + result = parse_manifest(manifest)[0][1] + assert result["flower"] == "rose" + assert result["skip-if"] == "true" + + result = parse_manifest( + manifest, + defaults={ + "flower": "tulip", + "colour": "pink", + "skip-if": "false", + }, + )[0][1] + assert result["flower"] == "rose" + assert result["colour"] == "pink" + assert result["skip-if"] == "false\ntrue" + + result = parse_manifest(manifest.replace("DEFAULT", "default"))[0][1] + assert result["flower"] == "rose" + assert result["skip-if"] == "true" + + +def test_multiline_skip(parse_manifest): + manifest = """ + [test_multiline_skip] + skip-if = + os == "mac" # bug 123 + os == "linux" && debug # bug 456 + """ + + result = parse_manifest(manifest)[0][1] + assert ( + result["skip-if"].replace("\r\n", "\n") + == dedent( + """ + os == "mac" + os == "linux" && debug + """ + ).rstrip() + ) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/manifestparser/tests/test_testmanifest.py b/testing/mozbase/manifestparser/tests/test_testmanifest.py new file mode 100644 index 0000000000..d51ec6c088 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/test_testmanifest.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python + +import os +import shutil +import tempfile +import unittest + +import mozunit +from manifestparser import ParseError, TestManifest +from manifestparser.filters import subsuite + +here = os.path.dirname(os.path.abspath(__file__)) + + +class TestTestManifest(unittest.TestCase): + """Test the Test Manifest""" + + def test_testmanifest_toml(self): + # Test filtering based on platform: + filter_example = os.path.join(here, "filter-example.toml") + manifest = TestManifest( + manifests=(filter_example,), strict=False, use_toml=True + ) + self.assertEqual( + [ + i["name"] + for i in manifest.active_tests(os="win", disabled=False, exists=False) + ], + ["windowstest", "fleem"], + ) + self.assertEqual( + [ + i["name"] + for i in manifest.active_tests(os="linux", disabled=False, exists=False) + ], + ["fleem", "linuxtest"], + ) + + # Look for existing tests. There is only one: + self.assertEqual([i["name"] for i in manifest.active_tests()], ["fleem"]) + + # You should be able to expect failures: + last = manifest.active_tests(exists=False, os="linux")[-1] + self.assertEqual(last["name"], "linuxtest") + self.assertEqual(last["expected"], "pass") + last = manifest.active_tests(exists=False, os="mac")[-1] + self.assertEqual(last["expected"], "fail") + + def test_missing_paths_toml(self): + """ + Test paths that don't exist raise an exception in strict mode. (TOML) + """ + tempdir = tempfile.mkdtemp() + + missing_path = os.path.join(here, "missing-path.toml") + manifest = TestManifest(manifests=(missing_path,), strict=True, use_toml=True) + self.assertRaises(IOError, manifest.active_tests) + self.assertRaises(IOError, manifest.copy, tempdir) + self.assertRaises(IOError, manifest.update, tempdir) + + shutil.rmtree(tempdir) + + def test_comments_toml(self): + """ + ensure comments work, see + https://bugzilla.mozilla.org/show_bug.cgi?id=813674 + (TOML) + """ + comment_example = os.path.join(here, "comment-example.toml") + manifest = TestManifest(manifests=(comment_example,), use_toml=True) + self.assertEqual(len(manifest.tests), 8) + names = [i["name"] for i in manifest.tests] + self.assertFalse("test_0202_app_launch_apply_update_dirlocked.js" in names) + + def test_manifest_subsuites_toml(self): + """ + test subsuites and conditional subsuites (TOML) + """ + relative_path = os.path.join(here, "subsuite.toml") + manifest = TestManifest(manifests=(relative_path,), use_toml=True) + info = {"foo": "bar"} + + # 6 tests total + tests = manifest.active_tests(exists=False, **info) + self.assertEqual(len(tests), 6) + + # only 3 tests for subsuite bar when foo==bar + tests = manifest.active_tests(exists=False, filters=[subsuite("bar")], **info) + self.assertEqual(len(tests), 3) + + # only 1 test for subsuite baz, regardless of conditions + other = {"something": "else"} + tests = manifest.active_tests(exists=False, filters=[subsuite("baz")], **info) + self.assertEqual(len(tests), 1) + tests = manifest.active_tests(exists=False, filters=[subsuite("baz")], **other) + self.assertEqual(len(tests), 1) + + # 4 tests match when the condition doesn't match (all tests except + # the unconditional subsuite) + info = {"foo": "blah"} + tests = manifest.active_tests(exists=False, filters=[subsuite()], **info) + self.assertEqual(len(tests), 5) + + # test for illegal subsuite value + manifest.tests[0]["subsuite"] = 'subsuite=bar,foo=="bar",type="nothing"' + with self.assertRaises(ParseError): + manifest.active_tests(exists=False, filters=[subsuite("foo")], **info) + + def test_none_and_empty_manifest_toml(self): + """ + Test TestManifest for None and empty manifest, see + https://bugzilla.mozilla.org/show_bug.cgi?id=1087682 + (TOML) + """ + none_manifest = TestManifest(manifests=None, strict=False, use_toml=True) + self.assertEqual(len(none_manifest.test_paths()), 0) + self.assertEqual(len(none_manifest.active_tests()), 0) + + empty_manifest = TestManifest(manifests=[], strict=False) + self.assertEqual(len(empty_manifest.test_paths()), 0) + self.assertEqual(len(empty_manifest.active_tests()), 0) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/manifestparser/tests/test_util.py b/testing/mozbase/manifestparser/tests/test_util.py new file mode 100644 index 0000000000..f2b37de43c --- /dev/null +++ b/testing/mozbase/manifestparser/tests/test_util.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python + +""" +Test how our utility functions are working. +""" + +from io import StringIO +from textwrap import dedent + +import mozunit +import pytest +from manifestparser import read_ini +from manifestparser.util import evaluate_list_from_string + + +@pytest.fixture(scope="module") +def parse_manifest(): + def inner(string, **kwargs): + buf = StringIO() + buf.write(dedent(string)) + buf.seek(0) + return read_ini(buf, **kwargs)[0] + + return inner + + +@pytest.mark.parametrize( + "test_manifest, expected_list", + [ + [ + """ + [test_felinicity.py] + kittens = true + cats = + "I", + "Am", + "A", + "Cat", + """, + ["I", "Am", "A", "Cat"], + ], + [ + """ + [test_felinicity.py] + kittens = true + cats = + ["I", 1], + ["Am", 2], + ["A", 3], + ["Cat", 4], + """, + [ + ["I", 1], + ["Am", 2], + ["A", 3], + ["Cat", 4], + ], + ], + ], +) +def test_string_to_list_conversion(test_manifest, expected_list, parse_manifest): + parsed_tests = parse_manifest(test_manifest) + assert evaluate_list_from_string(parsed_tests[0][1]["cats"]) == expected_list + + +@pytest.mark.parametrize( + "test_manifest, failure", + [ + [ + """ + # This will fail since the elements are not enlosed in quotes + [test_felinicity.py] + kittens = true + cats = + I, + Am, + A, + Cat, + """, + ValueError, + ], + [ + """ + # This will fail since the syntax is incorrect + [test_felinicity.py] + kittens = true + cats = + ["I", 1, + ["Am", 2, + ["A", 3], + ["Cat", 4], + """, + SyntaxError, + ], + ], +) +def test_string_to_list_conversion_failures(test_manifest, failure, parse_manifest): + parsed_tests = parse_manifest(test_manifest) + with pytest.raises(failure): + evaluate_list_from_string(parsed_tests[0][1]["cats"]) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/manifestparser/tests/verifyDirectory/subdir/manifest.ini b/testing/mozbase/manifestparser/tests/verifyDirectory/subdir/manifest.ini new file mode 100644 index 0000000000..509ebd62ef --- /dev/null +++ b/testing/mozbase/manifestparser/tests/verifyDirectory/subdir/manifest.ini @@ -0,0 +1 @@ +[test_sub.js] diff --git a/testing/mozbase/manifestparser/tests/verifyDirectory/subdir/manifest.toml b/testing/mozbase/manifestparser/tests/verifyDirectory/subdir/manifest.toml new file mode 100644 index 0000000000..54519cc275 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/verifyDirectory/subdir/manifest.toml @@ -0,0 +1 @@ +["test_sub.js"] diff --git a/testing/mozbase/manifestparser/tests/verifyDirectory/subdir/test_sub.js b/testing/mozbase/manifestparser/tests/verifyDirectory/subdir/test_sub.js new file mode 100644 index 0000000000..df48720d9d --- /dev/null +++ b/testing/mozbase/manifestparser/tests/verifyDirectory/subdir/test_sub.js @@ -0,0 +1 @@ +// test_sub.js diff --git a/testing/mozbase/manifestparser/tests/verifyDirectory/test_1.js b/testing/mozbase/manifestparser/tests/verifyDirectory/test_1.js new file mode 100644 index 0000000000..c5a966f46a --- /dev/null +++ b/testing/mozbase/manifestparser/tests/verifyDirectory/test_1.js @@ -0,0 +1 @@ +// test_1.js diff --git a/testing/mozbase/manifestparser/tests/verifyDirectory/test_2.js b/testing/mozbase/manifestparser/tests/verifyDirectory/test_2.js new file mode 100644 index 0000000000..d8648599c5 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/verifyDirectory/test_2.js @@ -0,0 +1 @@ +// test_2.js diff --git a/testing/mozbase/manifestparser/tests/verifyDirectory/test_3.js b/testing/mozbase/manifestparser/tests/verifyDirectory/test_3.js new file mode 100644 index 0000000000..794bc2c341 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/verifyDirectory/test_3.js @@ -0,0 +1 @@ +// test_3.js diff --git a/testing/mozbase/manifestparser/tests/verifyDirectory/verifyDirectory.ini b/testing/mozbase/manifestparser/tests/verifyDirectory/verifyDirectory.ini new file mode 100644 index 0000000000..10e0c79c81 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/verifyDirectory/verifyDirectory.ini @@ -0,0 +1,4 @@ +[test_1.js] +[test_2.js] +[test_3.js] +[include:subdir/manifest.ini] diff --git a/testing/mozbase/manifestparser/tests/verifyDirectory/verifyDirectory.toml b/testing/mozbase/manifestparser/tests/verifyDirectory/verifyDirectory.toml new file mode 100644 index 0000000000..b18ec4e482 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/verifyDirectory/verifyDirectory.toml @@ -0,0 +1,4 @@ +["test_1.js"] +["test_2.js"] +["test_3.js"] +["include:subdir/manifest.toml"] diff --git a/testing/mozbase/manifestparser/tests/verifyDirectory/verifyDirectory_incomplete.ini b/testing/mozbase/manifestparser/tests/verifyDirectory/verifyDirectory_incomplete.ini new file mode 100644 index 0000000000..cde526acfc --- /dev/null +++ b/testing/mozbase/manifestparser/tests/verifyDirectory/verifyDirectory_incomplete.ini @@ -0,0 +1,3 @@ +[test_2.js] +[test_3.js] +[include:subdir/manifest.ini] diff --git a/testing/mozbase/manifestparser/tests/verifyDirectory/verifyDirectory_incomplete.toml b/testing/mozbase/manifestparser/tests/verifyDirectory/verifyDirectory_incomplete.toml new file mode 100644 index 0000000000..d29be9b125 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/verifyDirectory/verifyDirectory_incomplete.toml @@ -0,0 +1,3 @@ +["test_2.js"] +["test_3.js"] +["include:subdir/manifest.toml"] diff --git a/testing/mozbase/manifestparser/tests/verifyDirectory/verifyDirectory_toocomplete.ini b/testing/mozbase/manifestparser/tests/verifyDirectory/verifyDirectory_toocomplete.ini new file mode 100644 index 0000000000..88994ae26f --- /dev/null +++ b/testing/mozbase/manifestparser/tests/verifyDirectory/verifyDirectory_toocomplete.ini @@ -0,0 +1,5 @@ +[test_1.js] +[test_2.js] +[test_3.js] +[test_notappearinginthisfilm.js] +[include:subdir/manifest.ini] diff --git a/testing/mozbase/manifestparser/tests/verifyDirectory/verifyDirectory_toocomplete.toml b/testing/mozbase/manifestparser/tests/verifyDirectory/verifyDirectory_toocomplete.toml new file mode 100644 index 0000000000..4c3cd3bb37 --- /dev/null +++ b/testing/mozbase/manifestparser/tests/verifyDirectory/verifyDirectory_toocomplete.toml @@ -0,0 +1,5 @@ +["test_1.js"] +["test_2.js"] +["test_3.js"] +["test_notappearinginthisfilm.js"] +["include:subdir/manifest.toml"] diff --git a/testing/mozbase/moz.build b/testing/mozbase/moz.build new file mode 100644 index 0000000000..8d44dbc852 --- /dev/null +++ b/testing/mozbase/moz.build @@ -0,0 +1,70 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=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/. + +PYTHON_UNITTEST_MANIFESTS += [ + "manifestparser/tests/manifest.toml", + "mozcrash/tests/manifest.toml", + "mozdebug/tests/manifest.toml", + "mozdevice/tests/manifest.toml", + "mozfile/tests/manifest.toml", + "mozgeckoprofiler/tests/manifest.toml", + "mozhttpd/tests/manifest.toml", + "mozinfo/tests/manifest.toml", + "mozinstall/tests/manifest.toml", + "mozleak/tests/manifest.toml", + "mozlog/tests/manifest.toml", + "moznetwork/tests/manifest.toml", + "mozpower/tests/manifest.toml", + "mozprocess/tests/manifest.toml", + "mozprofile/tests/manifest.toml", + "mozproxy/tests/manifest.toml", + "mozrunner/tests/manifest.toml", + "mozsystemmonitor/tests/manifest.toml", + "moztest/tests/manifest.toml", + "mozversion/tests/manifest.toml", +] + +python_modules = [ + "manifestparser", + "mozcrash", + "mozdebug", + "mozdevice", + "mozfile", + "mozgeckoprofiler", + "mozhttpd", + "mozinfo", + "mozinstall", + "mozleak", + "mozlog", + "moznetwork", + "mozpower", + "mozprocess", + "mozprofile", + "mozproxy", + "mozrunner", + "mozscreenshot", + "mozserve", + "mozsystemmonitor", + "moztest", + "mozversion", +] + +TEST_HARNESS_FILES.mozbase += [m + "/**" for m in python_modules] + +TEST_HARNESS_FILES.mozbase += [ + "setup_development.py", +] + +SPHINX_TREES["/mozbase"] = "docs" + +with Files("docs/**"): + SCHEDULES.exclusive = ["docs"] + +with Files("**"): + BUG_COMPONENT = ("Testing", "Mozbase") + +with Files("rust/**"): + BUG_COMPONENT = ("Testing", "Mozbase Rust") diff --git a/testing/mozbase/mozcrash/mozcrash/__init__.py b/testing/mozbase/mozcrash/mozcrash/__init__.py new file mode 100644 index 0000000000..a6dfab2b24 --- /dev/null +++ b/testing/mozbase/mozcrash/mozcrash/__init__.py @@ -0,0 +1,9 @@ +# flake8: noqa +# 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/. +""" +mozcrash is a library for getting a stack trace out of processes that have crashed +and left behind a minidump file using the Google Breakpad library. +""" +from .mozcrash import * diff --git a/testing/mozbase/mozcrash/mozcrash/mozcrash.py b/testing/mozbase/mozcrash/mozcrash/mozcrash.py new file mode 100644 index 0000000000..0589600019 --- /dev/null +++ b/testing/mozbase/mozcrash/mozcrash/mozcrash.py @@ -0,0 +1,865 @@ +# 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/. + +import glob +import json +import os +import re +import shutil +import signal +import subprocess +import sys +import tempfile +import traceback +import zipfile +from collections import namedtuple + +import mozfile +import mozinfo +import mozlog +import six +from redo import retriable + +__all__ = [ + "check_for_crashes", + "check_for_java_exception", + "kill_and_get_minidump", + "log_crashes", + "cleanup_pending_crash_reports", +] + + +StackInfo = namedtuple( + "StackInfo", + [ + "minidump_path", + "signature", + "stackwalk_stdout", + "stackwalk_stderr", + "stackwalk_retcode", + "stackwalk_errors", + "extra", + "process_type", + "pid", + "reason", + "java_stack", + ], +) + + +def get_logger(): + structured_logger = mozlog.get_default_logger("mozcrash") + if structured_logger is None: + return mozlog.unstructured.getLogger("mozcrash") + return structured_logger + + +def check_for_crashes( + dump_directory, + symbols_path=None, + stackwalk_binary=None, + dump_save_path=None, + test_name=None, + quiet=False, + keep=False, +): + """ + Print a stack trace for minidump files left behind by a crashing program. + + `dump_directory` will be searched for minidump files. Any minidump files found will + have `stackwalk_binary` executed on them, with `symbols_path` passed as an extra + argument. + + `stackwalk_binary` should be a path to the minidump-stackwalk binary. + If `stackwalk_binary` is not set, the MINIDUMP_STACKWALK environment variable + will be checked and its value used if it is not empty. If neither is set, then + ~/.mozbuild/minidump-stackwalk/minidump-stackwalk will be used. + + `symbols_path` should be a path to a directory containing symbols to use for + dump processing. This can either be a path to a directory containing Breakpad-format + symbols, or a URL to a zip file containing a set of symbols. + + If `dump_save_path` is set, it should be a path to a directory in which to copy minidump + files for safekeeping after a stack trace has been printed. If not set, the environment + variable MINIDUMP_SAVE_PATH will be checked and its value used if it is not empty. + + If `test_name` is set it will be used as the test name in log output. If not set the + filename of the calling function will be used. + + If `quiet` is set, no PROCESS-CRASH message will be printed to stdout if a + crash is detected. + + If `keep` is set, minidump files will not be removed after processing. + + Returns number of minidump files found. + """ + + # try to get the caller's filename if no test name is given + if test_name is None: + try: + test_name = os.path.basename(sys._getframe(1).f_code.co_filename) + except Exception: + test_name = "unknown" + + if not quiet: + print("mozcrash checking %s for minidumps..." % dump_directory) + + crash_info = CrashInfo( + dump_directory, + symbols_path, + dump_save_path=dump_save_path, + stackwalk_binary=stackwalk_binary, + keep=keep, + ) + + crash_count = 0 + for info in crash_info: + crash_count += 1 + output = None + if info.java_stack: + output = "PROCESS-CRASH | {name} | {stack}".format( + name=test_name, stack=info.java_stack + ) + elif not quiet: + stackwalk_output = ["Crash dump filename: {}".format(info.minidump_path)] + stackwalk_output.append("Process type: {}".format(info.process_type)) + stackwalk_output.append("Process pid: {}".format(info.pid or "unknown")) + if info.reason: + stackwalk_output.append("Mozilla crash reason: %s" % info.reason) + if info.stackwalk_stderr: + stackwalk_output.append("stderr from minidump-stackwalk:") + stackwalk_output.append(info.stackwalk_stderr) + elif info.stackwalk_stdout is not None: + stackwalk_output.append(info.stackwalk_stdout) + if info.stackwalk_retcode is not None and info.stackwalk_retcode != 0: + stackwalk_output.append( + "minidump-stackwalk exited with return code {}".format( + info.stackwalk_retcode + ) + ) + signature = info.signature if info.signature else "unknown top frame" + + output = "PROCESS-CRASH | {reason} [{sig}] | {name}\n{out}\n{err}".format( + reason=info.reason, + name=test_name, + sig=signature, + out="\n".join(stackwalk_output), + err="\n".join(info.stackwalk_errors), + ) + if output is not None: + if six.PY2 and sys.stdout.encoding != "UTF-8": + output = output.encode("utf-8") + print(output) + + return crash_count + + +def log_crashes( + logger, + dump_directory, + symbols_path, + process=None, + test=None, + stackwalk_binary=None, + dump_save_path=None, + quiet=False, +): + """Log crashes using a structured logger""" + crash_count = 0 + for info in CrashInfo( + dump_directory, + symbols_path, + dump_save_path=dump_save_path, + stackwalk_binary=stackwalk_binary, + ): + crash_count += 1 + if not quiet: + kwargs = info._asdict() + kwargs.pop("extra") + logger.crash(process=process, test=test, **kwargs) + return crash_count + + +# Function signatures of abort functions which should be ignored when +# determining the appropriate frame for the crash signature. +ABORT_SIGNATURES = ( + "Abort(char const*)", + "RustMozCrash", + "NS_DebugBreak", + # This signature is part of Rust panic stacks on some platforms. On + # others, it includes a template parameter containing "core::panic::" and + # is automatically filtered out by that pattern. + "core::ops::function::Fn::call", + "gkrust_shared::panic_hook", + "mozglue_static::panic_hook", + "intentional_panic", + "mozalloc_abort", + "mozalloc_abort(char const* const)", + "static void Abort(const char *)", + "std::sys_common::backtrace::__rust_end_short_backtrace", + "rust_begin_unwind", + # This started showing up when we enabled dumping inlined functions + "MOZ_Crash(char const*, int, char const*)", + "<alloc::boxed::Box<F,A> as core::ops::function::Fn<Args>>::call", +) + +# Similar to above, but matches if the substring appears anywhere in the +# frame's signature. +ABORT_SUBSTRINGS = ( + # On some platforms, Rust panic frames unfortunately appear without the + # std::panicking or core::panic namespaces. + "_panic_", + "core::panic::", + "core::panicking::", + "core::result::unwrap_failed", + "std::panicking::", +) + + +class CrashInfo(object): + """Get information about a crash based on dump files. + + Typical usage is to iterate over the CrashInfo object. This returns StackInfo + objects, one for each crash dump file that is found in the dump_directory. + + :param dump_directory: Path to search for minidump files + :param symbols_path: Path to a path to a directory containing symbols to use for + dump processing. This can either be a path to a directory + containing Breakpad-format symbols, or a URL to a zip file + containing a set of symbols. + :param dump_save_path: Path to which to save the dump files. If this is None, + the MINIDUMP_SAVE_PATH environment variable will be used. + :param stackwalk_binary: Path to the minidump-stackwalk binary. If this is None, + the MINIDUMP_STACKWALK environment variable will be used + as the path to the minidump binary. If neither is set, + then ~/.mozbuild/minidump-stackwalk/minidump-stackwalk + will be used.""" + + def __init__( + self, + dump_directory, + symbols_path, + dump_save_path=None, + stackwalk_binary=None, + keep=False, + ): + self.dump_directory = dump_directory + self.symbols_path = symbols_path + self.remove_symbols = False + self.brief_output = False + self.keep = keep + + if dump_save_path is None: + dump_save_path = os.environ.get("MINIDUMP_SAVE_PATH", None) + self.dump_save_path = dump_save_path + + if stackwalk_binary is None: + stackwalk_binary = os.environ.get("MINIDUMP_STACKWALK", None) + if stackwalk_binary is None: + # Location of minidump-stackwalk installed by "mach bootstrap". + executable_name = "minidump-stackwalk" + state_dir = os.environ.get( + "MOZBUILD_STATE_PATH", + os.path.expanduser(os.path.join("~", ".mozbuild")), + ) + stackwalk_binary = os.path.join(state_dir, executable_name, executable_name) + if mozinfo.isWin and not stackwalk_binary.endswith(".exe"): + stackwalk_binary += ".exe" + if os.path.exists(stackwalk_binary): + # If we reach this point, then we're almost certainly + # running on a local user's machine. Full minidump-stackwalk + # output is a bit noisy and verbose for that use-case, + # so we should use the --brief output. + self.brief_output = True + + self.stackwalk_binary = stackwalk_binary + + self.logger = get_logger() + self._dump_files = None + + @retriable(attempts=5, sleeptime=5, sleepscale=2) + def _get_symbols(self): + if not self.symbols_path: + self.logger.warning( + "No local symbols_path provided, only http symbols will be used." + ) + + # This updates self.symbols_path so we only download once. + if mozfile.is_url(self.symbols_path): + self.remove_symbols = True + self.logger.info("Downloading symbols from: %s" % self.symbols_path) + # Get the symbols and write them to a temporary zipfile + data = six.moves.urllib.request.urlopen(self.symbols_path) + with tempfile.TemporaryFile() as symbols_file: + symbols_file.write(data.read()) + # extract symbols to a temporary directory (which we'll delete after + # processing all crashes) + self.symbols_path = tempfile.mkdtemp() + with zipfile.ZipFile(symbols_file, "r") as zfile: + mozfile.extract_zip(zfile, self.symbols_path) + + @property + def dump_files(self): + """List of tuple (path_to_dump_file, path_to_extra_file) for each dump + file in self.dump_directory. The extra files may not exist.""" + if self._dump_files is None: + paths = [self.dump_directory] + if mozinfo.isWin: + # Add the hard-coded paths used for minidumps recorded by + # Windows Error Reporting in automation + paths += [ + "C:\\error-dumps\\", + "Z:\\error-dumps\\", + ] + self._dump_files = [] + for path in paths: + self._dump_files += [ + (minidump_path, os.path.splitext(minidump_path)[0] + ".extra") + for minidump_path in reversed( + sorted(glob.glob(os.path.join(path, "*.dmp"))) + ) + ] + max_dumps = 10 + if len(self._dump_files) > max_dumps: + self.logger.warning( + "Found %d dump files -- limited to %d!" + % (len(self._dump_files), max_dumps) + ) + del self._dump_files[max_dumps:] + + return self._dump_files + + @property + def has_dumps(self): + """Boolean indicating whether any crash dump files were found in the + current directory""" + return len(self.dump_files) > 0 + + def __iter__(self): + for path, extra in self.dump_files: + rv = self._process_dump_file(path, extra) + yield rv + + if self.remove_symbols: + mozfile.remove(self.symbols_path) + + def _process_dump_file(self, path, extra): + """Process a single dump file using self.stackwalk_binary, and return a + tuple containing properties of the crash dump. + + :param path: Path to the minidump file to analyse + :return: A StackInfo tuple with the fields:: + minidump_path: Path of the dump file + signature: The top frame of the stack trace, or None if it + could not be determined. + stackwalk_stdout: String of stdout data from stackwalk + stackwalk_stderr: String of stderr data from stackwalk or + None if it succeeded + stackwalk_retcode: Return code from stackwalk + stackwalk_errors: List of errors in human-readable form that prevented + stackwalk being launched. + reason: The reason provided by a MOZ_CRASH() invokation (optional) + java_stack: The stack trace of a Java exception (optional) + process_type: The type of process that crashed + pid: The PID of the crashed process + """ + self._get_symbols() + + errors = [] + signature = None + out = None + err = None + retcode = None + reason = None + java_stack = None + annotations = None + pid = None + process_type = "unknown" + if ( + self.stackwalk_binary + and os.path.exists(self.stackwalk_binary) + and os.access(self.stackwalk_binary, os.X_OK) + ): + # Now build up the actual command + command = [self.stackwalk_binary] + + # Fallback to the symbols server for unknown symbols on automation + # (mostly for system libraries). + if ( + "MOZ_AUTOMATION" in os.environ + or "MOZ_STACKWALK_SYMBOLS_SERVER" in os.environ + ): + command.append("--symbols-url=https://symbols.mozilla.org/") + + with tempfile.TemporaryDirectory() as json_dir: + crash_id = os.path.basename(path)[:-4] + json_output = os.path.join(json_dir, "{}.trace".format(crash_id)) + # Specify the kind of output + command.append("--cyborg={}".format(json_output)) + if self.brief_output: + command.append("--brief") + + # The minidump path and symbols_path values are positional and come last + # (in practice the CLI parsers are more permissive, but best not to + # unecessarily play with fire). + command.append(path) + + if self.symbols_path: + command.append(self.symbols_path) + + self.logger.info("Copy/paste: {}".format(" ".join(command))) + # run minidump-stackwalk + p = subprocess.Popen( + command, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + (out, err) = p.communicate() + retcode = p.returncode + if six.PY3: + out = six.ensure_str(out) + err = six.ensure_str(err) + + if retcode == 0: + processed_crash = self._process_json_output(json_output) + signature = processed_crash.get("signature") + pid = processed_crash.get("pid") + + else: + if not self.stackwalk_binary: + errors.append( + "MINIDUMP_STACKWALK not set, can't process dump. Either set " + "MINIDUMP_STACKWALK or use mach bootstrap --no-system-changes " + "to install minidump-stackwalk." + ) + elif self.stackwalk_binary and not os.path.exists(self.stackwalk_binary): + errors.append( + "MINIDUMP_STACKWALK binary not found: %s. Use mach bootstrap " + "--no-system-changes to install minidump-stackwalk." + % self.stackwalk_binary + ) + elif not os.access(self.stackwalk_binary, os.X_OK): + errors.append("This user cannot execute the MINIDUMP_STACKWALK binary.") + + if os.path.exists(extra): + annotations = self._parse_extra_file(extra) + + if annotations: + reason = annotations.get("MozCrashReason") + java_stack = annotations.get("JavaStackTrace") + process_type = annotations.get("ProcessType") or "main" + + if self.dump_save_path: + self._save_dump_file(path, extra) + + if os.path.exists(path) and not self.keep: + mozfile.remove(path) + if os.path.exists(extra) and not self.keep: + mozfile.remove(extra) + + return StackInfo( + path, + signature, + out, + err, + retcode, + errors, + extra, + process_type, + pid, + reason, + java_stack, + ) + + def _process_json_output(self, json_path): + signature = None + pid = None + + try: + json_file = open(json_path, "r") + crash_json = json.load(json_file) + json_file.close() + + signature = self._generate_signature(crash_json) + pid = crash_json.get("pid") + + except Exception as e: + traceback.print_exc() + signature = "an error occurred while processing JSON output: {}".format(e) + + return { + "pid": pid, + "signature": signature, + } + + def _generate_signature(self, crash_json): + signature = None + + try: + crashing_thread = crash_json.get("crashing_thread") or {} + frames = crashing_thread.get("frames") or [] + + flattened_frames = [] + for frame in frames: + for inline in frame.get("inlines") or []: + flattened_frames.append(inline.get("function")) + + flattened_frames.append( + frame.get("function") + or "{} + {}".format(frame.get("module"), frame.get("module_offset")) + ) + + for func in flattened_frames: + if not func: + continue + + signature = "@ %s" % func + + if not ( + func in ABORT_SIGNATURES + or any(pat in func for pat in ABORT_SUBSTRINGS) + ): + break + except Exception as e: + traceback.print_exc() + signature = "an error occurred while generating the signature: {}".format(e) + + # Strip parameters from signature + if signature: + pmatch = re.search(r"(.*)\(.*\)", signature) + if pmatch: + signature = pmatch.group(1) + + return signature + + def _parse_extra_file(self, path): + with open(path) as file: + try: + return json.load(file) + except ValueError: + self.logger.warning(".extra file does not contain proper json") + return None + + def _save_dump_file(self, path, extra): + if os.path.isfile(self.dump_save_path): + os.unlink(self.dump_save_path) + if not os.path.isdir(self.dump_save_path): + try: + os.makedirs(self.dump_save_path) + except OSError: + pass + + shutil.move(path, self.dump_save_path) + self.logger.info( + "Saved minidump as {}".format( + os.path.join(self.dump_save_path, os.path.basename(path)) + ) + ) + + if os.path.isfile(extra): + shutil.move(extra, self.dump_save_path) + self.logger.info( + "Saved app info as {}".format( + os.path.join(self.dump_save_path, os.path.basename(extra)) + ) + ) + + +def check_for_java_exception(logcat, test_name=None, quiet=False): + """ + Print a summary of a fatal Java exception, if present in the provided + logcat output. + + Today, exceptions in geckoview are usually noted in the minidump .extra file, allowing + java exceptions to be reported by the "normal" minidump processing, like log_crashes(); + therefore, this function may be extraneous (but maintained for now, while exception + handling is evolving). + + Example: + PROCESS-CRASH | <test-name> | java-exception java.lang.NullPointerException at org.mozilla.gecko.GeckoApp$21.run(GeckoApp.java:1833) # noqa + + `logcat` should be a list of strings. + + If `test_name` is set it will be used as the test name in log output. If not set the + filename of the calling function will be used. + + If `quiet` is set, no PROCESS-CRASH message will be printed to stdout if a + crash is detected. + + Returns True if a fatal Java exception was found, False otherwise. + """ + + # try to get the caller's filename if no test name is given + if test_name is None: + try: + test_name = os.path.basename(sys._getframe(1).f_code.co_filename) + except Exception: + test_name = "unknown" + + found_exception = False + + for i, line in enumerate(logcat): + # Logs will be of form: + # + # 01-30 20:15:41.937 E/GeckoAppShell( 1703): >>> REPORTING UNCAUGHT EXCEPTION FROM THREAD 9 ("GeckoBackgroundThread") # noqa + # 01-30 20:15:41.937 E/GeckoAppShell( 1703): java.lang.NullPointerException + # 01-30 20:15:41.937 E/GeckoAppShell( 1703): at org.mozilla.gecko.GeckoApp$21.run(GeckoApp.java:1833) # noqa + # 01-30 20:15:41.937 E/GeckoAppShell( 1703): at android.os.Handler.handleCallback(Handler.java:587) # noqa + if "REPORTING UNCAUGHT EXCEPTION" in line: + # Strip away the date, time, logcat tag and pid from the next two lines and + # concatenate the remainder to form a concise summary of the exception. + found_exception = True + if len(logcat) >= i + 3: + logre = re.compile(r".*\): \t?(.*)") + m = logre.search(logcat[i + 1]) + if m and m.group(1): + exception_type = m.group(1) + m = logre.search(logcat[i + 2]) + if m and m.group(1): + exception_location = m.group(1) + if not quiet: + output = ( + "PROCESS-CRASH | {name} | java-exception {type} {loc}".format( + name=test_name, type=exception_type, loc=exception_location + ) + ) + print(output.encode("utf-8")) + else: + print( + "Automation Error: java exception in logcat at line " + "{0} of {1}: {2}".format(i, len(logcat), line) + ) + break + + return found_exception + + +if mozinfo.isWin: + import ctypes + import uuid + + kernel32 = ctypes.windll.kernel32 + OpenProcess = kernel32.OpenProcess + CloseHandle = kernel32.CloseHandle + + def write_minidump(pid, dump_directory, utility_path): + """ + Write a minidump for a process. + + :param pid: PID of the process to write a minidump for. + :param dump_directory: Directory in which to write the minidump. + """ + PROCESS_QUERY_INFORMATION = 0x0400 + PROCESS_VM_READ = 0x0010 + GENERIC_READ = 0x80000000 + GENERIC_WRITE = 0x40000000 + CREATE_ALWAYS = 2 + FILE_ATTRIBUTE_NORMAL = 0x80 + INVALID_HANDLE_VALUE = -1 + + log = get_logger() + file_name = os.path.join(dump_directory, str(uuid.uuid4()) + ".dmp") + + if not os.path.exists(dump_directory): + # `kernal32.CreateFileW` can fail to create the dmp file if the dump + # directory was deleted or doesn't exist (error code 3). + os.makedirs(dump_directory) + + if mozinfo.info["bits"] != ctypes.sizeof(ctypes.c_voidp) * 8 and utility_path: + # We're not going to be able to write a minidump with ctypes if our + # python process was compiled for a different architecture than + # firefox, so we invoke the minidumpwriter utility program. + + minidumpwriter = os.path.normpath( + os.path.join(utility_path, "minidumpwriter.exe") + ) + log.info( + "Using {} to write a dump to {} for [{}]".format( + minidumpwriter, file_name, pid + ) + ) + if not os.path.exists(minidumpwriter): + log.error("minidumpwriter not found in {}".format(utility_path)) + return + + status = subprocess.Popen([minidumpwriter, str(pid), file_name]).wait() + if status: + log.error("minidumpwriter exited with status: %d" % status) + return + + log.info("Writing a dump to {} for [{}]".format(file_name, pid)) + + proc_handle = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, 0, pid) + if not proc_handle: + err = kernel32.GetLastError() + log.warning("unable to get handle for pid %d: %d" % (pid, err)) + return + + if not isinstance(file_name, six.text_type): + # Convert to unicode explicitly so our path will be valid as input + # to CreateFileW + file_name = six.text_type(file_name, sys.getfilesystemencoding()) + + file_handle = kernel32.CreateFileW( + file_name, + GENERIC_READ | GENERIC_WRITE, + 0, + None, + CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, + None, + ) + if file_handle != INVALID_HANDLE_VALUE: + if not ctypes.windll.dbghelp.MiniDumpWriteDump( + proc_handle, + pid, + file_handle, + # Dump type - MiniDumpNormal + 0, + # Exception parameter + None, + # User stream parameter + None, + # Callback parameter + None, + ): + err = kernel32.GetLastError() + log.warning("unable to dump minidump file for pid %d: %d" % (pid, err)) + CloseHandle(file_handle) + else: + err = kernel32.GetLastError() + log.warning("unable to create minidump file for pid %d: %d" % (pid, err)) + CloseHandle(proc_handle) + + def kill_pid(pid): + """ + Terminate a process with extreme prejudice. + + :param pid: PID of the process to terminate. + """ + PROCESS_TERMINATE = 0x0001 + SYNCHRONIZE = 0x00100000 + WAIT_OBJECT_0 = 0x0 + WAIT_FAILED = -1 + logger = get_logger() + handle = OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, 0, pid) + if handle: + if kernel32.TerminateProcess(handle, 1): + # TerminateProcess is async; wait up to 30 seconds for process to + # actually terminate, then give up so that clients are not kept + # waiting indefinitely for hung processes. + status = kernel32.WaitForSingleObject(handle, 30000) + if status == WAIT_FAILED: + err = kernel32.GetLastError() + logger.warning( + "kill_pid(): wait failed (%d) terminating pid %d: error %d" + % (status, pid, err) + ) + elif status != WAIT_OBJECT_0: + logger.warning( + "kill_pid(): wait failed (%d) terminating pid %d" + % (status, pid) + ) + else: + err = kernel32.GetLastError() + logger.warning( + "kill_pid(): unable to terminate pid %d: %d" % (pid, err) + ) + CloseHandle(handle) + else: + err = kernel32.GetLastError() + logger.warning( + "kill_pid(): unable to get handle for pid %d: %d" % (pid, err) + ) + +else: + + def kill_pid(pid): + """ + Terminate a process with extreme prejudice. + + :param pid: PID of the process to terminate. + """ + os.kill(pid, signal.SIGKILL) + + +def kill_and_get_minidump(pid, dump_directory, utility_path=None): + """ + Attempt to kill a process and leave behind a minidump describing its + execution state. + + :param pid: The PID of the process to kill. + :param dump_directory: The directory where a minidump should be written on + Windows, where the dump will be written from outside the process. + + On Windows a dump will be written using the MiniDumpWriteDump function + from DbgHelp.dll. On Linux and OS X the process will be sent a SIGABRT + signal to trigger minidump writing via a Breakpad signal handler. On other + platforms the process will simply be killed via SIGKILL. + + If the process is hung in such a way that it cannot respond to SIGABRT + it may still be running after this function returns. In that case it + is the caller's responsibility to deal with killing it. + """ + needs_killing = True + if mozinfo.isWin: + write_minidump(pid, dump_directory, utility_path) + elif mozinfo.isLinux or mozinfo.isMac: + os.kill(pid, signal.SIGABRT) + needs_killing = False + if needs_killing: + kill_pid(pid) + + +def cleanup_pending_crash_reports(): + """ + Delete any pending crash reports. + + The presence of pending crash reports may be reported by the browser, + affecting test results; it is best to ensure that these are removed + before starting any browser tests. + + Firefox stores pending crash reports in "<UAppData>/Crash Reports". + If the browser is not running, it cannot provide <UAppData>, so this + code tries to anticipate its value. + + See dom/system/OSFileConstants.cpp for platform variations of <UAppData>. + """ + if mozinfo.isWin: + location = os.path.expanduser( + "~\\AppData\\Roaming\\Mozilla\\Firefox\\Crash Reports" + ) + elif mozinfo.isMac: + location = os.path.expanduser( + "~/Library/Application Support/firefox/Crash Reports" + ) + else: + location = os.path.expanduser("~/.mozilla/firefox/Crash Reports") + logger = get_logger() + if os.path.exists(location): + try: + mozfile.remove(location) + logger.info("Removed pending crash reports at '%s'" % location) + except Exception: + pass + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("--stackwalk-binary", "-b") + parser.add_argument("--dump-save-path", "-o") + parser.add_argument("--test-name", "-n") + parser.add_argument("--keep", action="store_true") + parser.add_argument("dump_directory") + parser.add_argument("symbols_path") + args = parser.parse_args() + + check_for_crashes( + args.dump_directory, + args.symbols_path, + stackwalk_binary=args.stackwalk_binary, + dump_save_path=args.dump_save_path, + test_name=args.test_name, + keep=args.keep, + ) diff --git a/testing/mozbase/mozcrash/setup.cfg b/testing/mozbase/mozcrash/setup.cfg new file mode 100644 index 0000000000..3c6e79cf31 --- /dev/null +++ b/testing/mozbase/mozcrash/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 diff --git a/testing/mozbase/mozcrash/setup.py b/testing/mozbase/mozcrash/setup.py new file mode 100644 index 0000000000..d67060149b --- /dev/null +++ b/testing/mozbase/mozcrash/setup.py @@ -0,0 +1,33 @@ +# 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 setuptools import setup + +PACKAGE_NAME = "mozcrash" +PACKAGE_VERSION = "2.2.0" + +# dependencies +deps = ["mozfile >= 1.0", "mozlog >= 6.0"] + +setup( + name=PACKAGE_NAME, + version=PACKAGE_VERSION, + description="Library for printing stack traces from minidumps " + "left behind by crashed processes", + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + classifiers=[ + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.5", + ], + # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + keywords="mozilla", + author="Mozilla Automation and Tools team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + license="MPL", + packages=["mozcrash"], + include_package_data=True, + zip_safe=False, + install_requires=deps, +) diff --git a/testing/mozbase/mozcrash/tests/conftest.py b/testing/mozbase/mozcrash/tests/conftest.py new file mode 100644 index 0000000000..7b6dcea496 --- /dev/null +++ b/testing/mozbase/mozcrash/tests/conftest.py @@ -0,0 +1,127 @@ +# coding=UTF-8 + +import uuid + +import mozcrash +import pytest +from py._path.common import fspath + + +@pytest.fixture(scope="session") +def stackwalk(tmpdir_factory): + stackwalk = tmpdir_factory.mktemp("stackwalk_binary").join("stackwalk") + stackwalk.write("fake binary") + stackwalk.chmod(0o744) + return stackwalk + + +@pytest.fixture +def check_for_crashes(tmpdir, stackwalk, monkeypatch): + monkeypatch.delenv("MINIDUMP_SAVE_PATH", raising=False) + + def wrapper( + dump_directory=fspath(tmpdir), + symbols_path="symbols_path", + stackwalk_binary=fspath(stackwalk), + dump_save_path=None, + test_name=None, + quiet=True, + ): + return mozcrash.check_for_crashes( + dump_directory, + symbols_path, + stackwalk_binary, + dump_save_path, + test_name, + quiet, + ) + + return wrapper + + +@pytest.fixture +def check_for_java_exception(): + def wrapper(logcat=None, test_name=None, quiet=True): + return mozcrash.check_for_java_exception(logcat, test_name, quiet) + + return wrapper + + +def minidump_files(request, tmpdir): + files = [] + + for i in range(getattr(request, "param", 1)): + name = uuid.uuid4() + + dmp = tmpdir.join("{}.dmp".format(name)) + dmp.write("foo") + + extra = tmpdir.join("{}.extra".format(name)) + + extra.write_text( + """ +{ + "ContentSandboxLevel":"2", + "TelemetryEnvironment":"{🍪}", + "EMCheckCompatibility":"true", + "ProductName":"Firefox", + "ContentSandboxCapabilities":"119", + "TelemetryClientId":"", + "Vendor":"Mozilla", + "InstallTime":"1000000000", + "Theme":"classic/1.0", + "ReleaseChannel":"default", + "ServerURL":"https://crash-reports.mozilla.com", + "SafeMode":"0", + "ContentSandboxCapable":"1", + "useragent_locale":"en-US", + "Version":"55.0a1", + "BuildID":"20170512114708", + "ProductID":"{ec8030f7-c20a-464f-9b0e-13a3a9e97384}", + "MozCrashReason": "MOZ_CRASH()", + "TelemetryServerURL":"", + "DOMIPCEnabled":"1", + "Add-ons":"", + "CrashTime":"1494582646", + "UptimeTS":"14.9179586", + "ContentSandboxEnabled":"1", + "ProcessType":"content", + "StartupTime":"1000000000", + "URL":"about:home" +} + + """, + encoding="utf-8", + ) + + files.append({"dmp": dmp, "extra": extra}) + + return files + + +@pytest.fixture(name="minidump_files") +def minidump_files_fixture(request, tmpdir): + return minidump_files(request, tmpdir) + + +@pytest.fixture(autouse=True) +def mock_popen(monkeypatch): + """Generate a class that can mock subprocess.Popen. + + :param stdouts: Iterable that should return an iterable for the + stdout of each process in turn. + """ + + class MockPopen(object): + def __init__(self, args, *args_rest, **kwargs): + # all_popens.append(self) + self.args = args + self.returncode = 0 + + def communicate(self): + return ("Stackwalk command: {}".format(" ".join(self.args)), "") + + def wait(self): + return self.returncode + + monkeypatch.setattr(mozcrash.mozcrash.subprocess, "Popen", MockPopen) diff --git a/testing/mozbase/mozcrash/tests/manifest.toml b/testing/mozbase/mozcrash/tests/manifest.toml new file mode 100644 index 0000000000..20e31fbc9d --- /dev/null +++ b/testing/mozbase/mozcrash/tests/manifest.toml @@ -0,0 +1,12 @@ +[DEFAULT] +subsuite = "mozbase" + +["test_basic.py"] + +["test_java_exception.py"] + +["test_save_path.py"] + +["test_stackwalk.py"] + +["test_symbols_path.py"] diff --git a/testing/mozbase/mozcrash/tests/test_basic.py b/testing/mozbase/mozcrash/tests/test_basic.py new file mode 100644 index 0000000000..384aba62dc --- /dev/null +++ b/testing/mozbase/mozcrash/tests/test_basic.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +# coding=UTF-8 + +import mozunit +import pytest +from conftest import fspath + + +def test_no_dump_files(check_for_crashes): + """Test that check_for_crashes returns 0 if no dumps are present.""" + assert 0 == check_for_crashes() + + +@pytest.mark.parametrize("minidump_files", [3], indirect=True) +def test_dump_count(check_for_crashes, minidump_files): + """Test that check_for_crashes returns the number of crash dumps.""" + assert 3 == check_for_crashes() + + +def test_dump_directory_unicode(request, check_for_crashes, tmpdir, capsys): + """Test that check_for_crashes can handle unicode in dump_directory.""" + from conftest import minidump_files + + tmpdir = tmpdir.ensure("🍪", dir=1) + minidump_files = minidump_files(request, tmpdir) + + assert 1 == check_for_crashes(dump_directory=fspath(tmpdir), quiet=False) + + out, _ = capsys.readouterr() + assert fspath(minidump_files[0]["dmp"]) in out + assert "🍪" in out + + +def test_test_name_unicode(check_for_crashes, minidump_files, capsys): + """Test that check_for_crashes can handle unicode in dump_directory.""" + assert 1 == check_for_crashes(test_name="🍪", quiet=False) + + out, err = capsys.readouterr() + assert "| 🍪" in out + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozcrash/tests/test_java_exception.py b/testing/mozbase/mozcrash/tests/test_java_exception.py new file mode 100644 index 0000000000..00c8d3c46a --- /dev/null +++ b/testing/mozbase/mozcrash/tests/test_java_exception.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python +# coding=UTF-8 + +import mozunit +import pytest + + +@pytest.fixture +def test_log(): + return [ + "01-30 20:15:41.937 E/GeckoAppShell( 1703): >>> " + 'REPORTING UNCAUGHT EXCEPTION FROM THREAD 9 ("GeckoBackgroundThread")', + "01-30 20:15:41.937 E/GeckoAppShell( 1703): java.lang.NullPointerException", + "01-30 20:15:41.937 E/GeckoAppShell( 1703):" + " at org.mozilla.gecko.GeckoApp$21.run(GeckoApp.java:1833)", + "01-30 20:15:41.937 E/GeckoAppShell( 1703):" + " at android.os.Handler.handleCallback(Handler.java:587)", + ] + + +def test_uncaught_exception(check_for_java_exception, test_log): + """Test for an exception which should be caught.""" + assert 1 == check_for_java_exception(test_log) + + +def test_truncated_exception(check_for_java_exception, test_log): + """Test for an exception which should be caught which was truncated.""" + truncated_log = list(test_log) + truncated_log[0], truncated_log[1] = truncated_log[1], truncated_log[0] + + assert 1 == check_for_java_exception(truncated_log) + + +def test_unchecked_exception(check_for_java_exception, test_log): + """Test for an exception which should not be caught.""" + passable_log = list(test_log) + passable_log[0] = ( + "01-30 20:15:41.937 E/GeckoAppShell( 1703):" + ' >>> NOT-SO-BAD EXCEPTION FROM THREAD 9 ("GeckoBackgroundThread")' + ) + + assert 0 == check_for_java_exception(passable_log) + + +def test_test_name_unicode(check_for_java_exception, test_log): + """Test that check_for_crashes can handle unicode in dump_directory.""" + assert 1 == check_for_java_exception(test_log, test_name="🍪", quiet=False) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozcrash/tests/test_save_path.py b/testing/mozbase/mozcrash/tests/test_save_path.py new file mode 100644 index 0000000000..fad83ab71b --- /dev/null +++ b/testing/mozbase/mozcrash/tests/test_save_path.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python + +import os + +import mozunit +import pytest +from conftest import fspath + + +def test_save_path_not_present(check_for_crashes, minidump_files, tmpdir): + """Test that dump_save_path works when the directory doesn't exist.""" + save_path = tmpdir.join("saved") + + assert 1 == check_for_crashes(dump_save_path=fspath(save_path)) + + assert save_path.join(minidump_files[0]["dmp"].basename).check() + assert save_path.join(minidump_files[0]["extra"].basename).check() + + +def test_save_path(check_for_crashes, minidump_files, tmpdir): + """Test that dump_save_path works.""" + save_path = tmpdir.mkdir("saved") + + assert 1 == check_for_crashes(dump_save_path=fspath(save_path)) + + assert save_path.join(minidump_files[0]["dmp"].basename).check() + assert save_path.join(minidump_files[0]["extra"].basename).check() + + +def test_save_path_isfile(check_for_crashes, minidump_files, tmpdir): + """Test that dump_save_path works when the path is a file and not a directory.""" + save_path = tmpdir.join("saved") + save_path.write("junk") + + assert 1 == check_for_crashes(dump_save_path=fspath(save_path)) + + assert save_path.join(minidump_files[0]["dmp"].basename).check() + assert save_path.join(minidump_files[0]["extra"].basename).check() + + +def test_save_path_envvar(check_for_crashes, minidump_files, tmpdir): + """Test that the MINDUMP_SAVE_PATH environment variable works.""" + save_path = tmpdir.mkdir("saved") + + os.environ["MINIDUMP_SAVE_PATH"] = fspath(save_path) + try: + assert 1 == check_for_crashes(dump_save_path=None) + finally: + del os.environ["MINIDUMP_SAVE_PATH"] + + assert save_path.join(minidump_files[0]["dmp"].basename).check() + assert save_path.join(minidump_files[0]["extra"].basename).check() + + +@pytest.mark.parametrize("minidump_files", [3], indirect=True) +def test_save_multiple(check_for_crashes, minidump_files, tmpdir): + """Test that all minidumps are saved.""" + save_path = tmpdir.mkdir("saved") + + assert 3 == check_for_crashes(dump_save_path=fspath(save_path)) + + for i in range(3): + assert save_path.join(minidump_files[i]["dmp"].basename).check() + assert save_path.join(minidump_files[i]["extra"].basename).check() + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozcrash/tests/test_stackwalk.py b/testing/mozbase/mozcrash/tests/test_stackwalk.py new file mode 100644 index 0000000000..3292e4fdf1 --- /dev/null +++ b/testing/mozbase/mozcrash/tests/test_stackwalk.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python +# coding=UTF-8 + +import os + +import mozunit +from conftest import fspath + + +def test_stackwalk_not_found(check_for_crashes, minidump_files, tmpdir, capsys): + """Test that check_for_crashes can handle unicode in dump_directory.""" + stackwalk = tmpdir.join("stackwalk") + + assert 1 == check_for_crashes(stackwalk_binary=fspath(stackwalk), quiet=False) + + out, _ = capsys.readouterr() + assert "MINIDUMP_STACKWALK binary not found" in out + + +def test_stackwalk_envvar(check_for_crashes, minidump_files, stackwalk): + """Test that check_for_crashes uses the MINIDUMP_STACKWALK environment var.""" + os.environ["MINIDUMP_STACKWALK"] = fspath(stackwalk) + try: + assert 1 == check_for_crashes(stackwalk_binary=None) + finally: + del os.environ["MINIDUMP_STACKWALK"] + + +def test_stackwalk_unicode(check_for_crashes, minidump_files, tmpdir, capsys): + """Test that check_for_crashes can handle unicode in dump_directory.""" + stackwalk = tmpdir.mkdir("🍪").join("stackwalk") + stackwalk.write("fake binary") + stackwalk.chmod(0o744) + + assert 1 == check_for_crashes(stackwalk_binary=fspath(stackwalk), quiet=False) + + out, err = capsys.readouterr() + assert fspath(stackwalk) in out + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozcrash/tests/test_symbols_path.py b/testing/mozbase/mozcrash/tests/test_symbols_path.py new file mode 100644 index 0000000000..644302c947 --- /dev/null +++ b/testing/mozbase/mozcrash/tests/test_symbols_path.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python +# coding=UTF-8 + +import zipfile + +import mozhttpd +import mozunit +from conftest import fspath +from six import BytesIO +from six.moves.urllib.parse import urlunsplit + + +def test_symbols_path_not_present(check_for_crashes, minidump_files): + """Test that no symbols path let mozcrash try to find the symbols.""" + assert 1 == check_for_crashes(symbols_path=None) + + +def test_symbols_path_unicode(check_for_crashes, minidump_files, tmpdir, capsys): + """Test that check_for_crashes can handle unicode in dump_directory.""" + symbols_path = tmpdir.mkdir("🍪") + + assert 1 == check_for_crashes(symbols_path=fspath(symbols_path), quiet=False) + + out, _ = capsys.readouterr() + assert fspath(symbols_path) in out + + +def test_symbols_path_url(check_for_crashes, minidump_files): + """Test that passing a URL as symbols_path correctly fetches the URL.""" + data = {"retrieved": False} + + def make_zipfile(): + zdata = BytesIO() + z = zipfile.ZipFile(zdata, "w") + z.writestr("symbols.txt", "abc/xyz") + z.close() + return zdata.getvalue() + + def get_symbols(req): + data["retrieved"] = True + + headers = {} + return (200, headers, make_zipfile()) + + httpd = mozhttpd.MozHttpd( + port=0, + urlhandlers=[{"method": "GET", "path": "/symbols", "function": get_symbols}], + ) + httpd.start() + symbol_url = urlunsplit( + ("http", "%s:%d" % httpd.httpd.server_address, "/symbols", "", "") + ) + + assert 1 == check_for_crashes(symbols_path=symbol_url) + assert data["retrieved"] + + +def test_symbols_retry(check_for_crashes, minidump_files): + """Test that passing a URL as symbols_path succeeds on retry after temporary HTTP failure.""" + data = {"retrieved": False} + get_symbols_calls = 0 + + def make_zipfile(): + zdata = BytesIO() + z = zipfile.ZipFile(zdata, "w") + z.writestr("symbols.txt", "abc/xyz") + z.close() + return zdata.getvalue() + + def get_symbols(req): + nonlocal get_symbols_calls + data["retrieved"] = True + if get_symbols_calls > 0: + ret = 200 + else: + ret = 504 + get_symbols_calls += 1 + + headers = {} + return (ret, headers, make_zipfile()) + + httpd = mozhttpd.MozHttpd( + port=0, + urlhandlers=[{"method": "GET", "path": "/symbols", "function": get_symbols}], + ) + httpd.start() + symbol_url = urlunsplit( + ("http", "%s:%d" % httpd.httpd.server_address, "/symbols", "", "") + ) + + assert 1 == check_for_crashes(symbols_path=symbol_url) + assert data["retrieved"] + assert 2 == get_symbols_calls + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozdebug/mozdebug/__init__.py b/testing/mozbase/mozdebug/mozdebug/__init__.py new file mode 100644 index 0000000000..bb8711e2c4 --- /dev/null +++ b/testing/mozbase/mozdebug/mozdebug/__init__.py @@ -0,0 +1,30 @@ +# flake8: noqa +# 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/. + +""" +This module contains a set of function to gather information about the +debugging capabilities of the platform. It allows to look for a specific +debugger or to query the system for a compatible/default debugger. + +The following simple example looks for the default debugger on the +current platform and launches a debugger process with the correct +debugger-specific arguments: + +:: + + import mozdebug + + debugger = mozdebug.get_default_debugger_name() + debuggerInfo = mozdebug.get_debugger_info(debugger) + + debuggeePath = "toDebug" + + processArgs = [self.debuggerInfo.path] + self.debuggerInfo.args + processArgs.append(debuggeePath) + + run_process(args, ...) + +""" +from .mozdebug import * diff --git a/testing/mozbase/mozdebug/mozdebug/mozdebug.py b/testing/mozbase/mozdebug/mozdebug/mozdebug.py new file mode 100755 index 0000000000..beecc2cd9d --- /dev/null +++ b/testing/mozbase/mozdebug/mozdebug/mozdebug.py @@ -0,0 +1,315 @@ +#!/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/. + +import json +import os +import sys +from collections import namedtuple +from distutils.spawn import find_executable +from subprocess import check_output + +import mozinfo + +__all__ = [ + "get_debugger_info", + "get_default_debugger_name", + "DebuggerSearch", + "get_default_valgrind_args", + "DebuggerInfo", +] + +""" +Map of debugging programs to information about them, like default arguments +and whether or not they are interactive. + +To add support for a new debugger, simply add the relative entry in +_DEBUGGER_INFO and optionally update the _DEBUGGER_PRIORITIES. +""" +_DEBUGGER_INFO = { + # gdb requires that you supply the '--args' flag in order to pass arguments + # after the executable name to the executable. + "gdb": {"interactive": True, "args": ["-q", "--args"]}, + "cgdb": {"interactive": True, "args": ["-q", "--args"]}, + "rust-gdb": {"interactive": True, "args": ["-q", "--args"]}, + "lldb": {"interactive": True, "args": ["--"], "requiresEscapedArgs": True}, + # Visual Studio Debugger Support. + "devenv.exe": {"interactive": True, "args": ["-debugexe"]}, + # Visual C++ Express Debugger Support. + "wdexpress.exe": {"interactive": True, "args": ["-debugexe"]}, + # Windows Development Kit super-debugger. + "windbg.exe": { + "interactive": True, + }, +} + +# Maps each OS platform to the preferred debugger programs found in _DEBUGGER_INFO. +_DEBUGGER_PRIORITIES = { + "win": ["devenv.exe", "wdexpress.exe"], + "linux": ["gdb", "cgdb", "lldb"], + "mac": ["lldb", "gdb"], + "android": ["lldb"], + "unknown": ["gdb"], +} + + +DebuggerInfo = namedtuple( + "DebuggerInfo", ["path", "interactive", "args", "requiresEscapedArgs"] +) + + +def _windbg_installation_paths(): + programFilesSuffixes = ["", " (x86)"] + programFiles = "C:/Program Files" + # Try the most recent versions first. + windowsKitsVersions = ["10", "8.1", "8"] + + for suffix in programFilesSuffixes: + windowsKitsPrefix = os.path.join(programFiles + suffix, "Windows Kits") + for version in windowsKitsVersions: + yield os.path.join( + windowsKitsPrefix, version, "Debuggers", "x64", "windbg.exe" + ) + + +def _vswhere_path(): + try: + import buildconfig + + path = os.path.join(buildconfig.topsrcdir, "build", "win32", "vswhere.exe") + if os.path.isfile(path): + return path + except ImportError: + pass + # Hope it's available on PATH! + return "vswhere.exe" + + +def get_debugger_path(debugger): + """ + Get the full path of the debugger. + + :param debugger: The name of the debugger. + """ + + if mozinfo.os == "mac" and debugger == "lldb": + # On newer OSX versions System Integrity Protections prevents us from + # setting certain env vars for a process such as DYLD_LIBRARY_PATH if + # it's in a protected directory such as /usr/bin. This is the case for + # lldb, so we try to find an instance under the Xcode install instead. + + # Attempt to use the xcrun util to find the path. + try: + path = check_output( + ["xcrun", "--find", "lldb"], universal_newlines=True + ).strip() + if path: + return path + except Exception: + # Just default to find_executable instead. + pass + + if mozinfo.os == "win" and debugger == "devenv.exe": + # Attempt to use vswhere to find the path. + try: + encoding = "mbcs" if sys.platform == "win32" else "utf-8" + vswhere = _vswhere_path() + vsinfo = check_output([vswhere, "-format", "json", "-latest"]) + vsinfo = json.loads(vsinfo.decode(encoding, "replace")) + return os.path.join( + vsinfo[0]["installationPath"], "Common7", "IDE", "devenv.exe" + ) + except Exception: + # Just default to find_executable instead. + pass + + return find_executable(debugger) + + +def get_debugger_info(debugger, debuggerArgs=None, debuggerInteractive=False): + """ + Get the information about the requested debugger. + + Returns a dictionary containing the ``path`` of the debugger executable, + if it will run in ``interactive`` mode, its arguments and whether it needs + to escape arguments it passes to the debugged program (``requiresEscapedArgs``). + If the debugger cannot be found in the system, returns ``None``. + + :param debugger: The name of the debugger. + :param debuggerArgs: If specified, it's the arguments to pass to the debugger, + as a string. Any debugger-specific separator arguments are appended after + these arguments. + :param debuggerInteractive: If specified, forces the debugger to be interactive. + """ + + debuggerPath = None + + if debugger: + # Append '.exe' to the debugger on Windows if it's not present, + # so things like '--debugger=devenv' work. + if os.name == "nt" and not debugger.lower().endswith(".exe"): + debugger += ".exe" + + debuggerPath = get_debugger_path(debugger) + + if not debuggerPath: + # windbg is not installed with the standard set of tools, and it's + # entirely possible that the user hasn't added the install location to + # PATH, so we have to be a little more clever than normal to locate it. + # Just try to look for it in the standard installed location(s). + if debugger == "windbg.exe": + for candidate in _windbg_installation_paths(): + if os.path.exists(candidate): + debuggerPath = candidate + break + else: + if os.path.exists(debugger): + debuggerPath = debugger + + if not debuggerPath: + print("Error: Could not find debugger %s." % debugger) + print("Is it installed? Is it in your PATH?") + return None + + debuggerName = os.path.basename(debuggerPath).lower() + + def get_debugger_info(type, default): + if debuggerName in _DEBUGGER_INFO and type in _DEBUGGER_INFO[debuggerName]: + return _DEBUGGER_INFO[debuggerName][type] + return default + + # Define a namedtuple to access the debugger information from the outside world. + debugger_arguments = [] + + if debuggerArgs: + # Append the provided debugger arguments at the end of the arguments list. + debugger_arguments += debuggerArgs.split() + + debugger_arguments += get_debugger_info("args", []) + + # Override the default debugger interactive mode if needed. + debugger_interactive = get_debugger_info("interactive", False) + if debuggerInteractive: + debugger_interactive = debuggerInteractive + + d = DebuggerInfo( + debuggerPath, + debugger_interactive, + debugger_arguments, + get_debugger_info("requiresEscapedArgs", False), + ) + + return d + + +# Defines the search policies to use in get_default_debugger_name. + + +class DebuggerSearch: + OnlyFirst = 1 + KeepLooking = 2 + + +def get_default_debugger_name(search=DebuggerSearch.OnlyFirst): + """ + Get the debugger name for the default debugger on current platform. + + :param search: If specified, stops looking for the debugger if the + default one is not found (``DebuggerSearch.OnlyFirst``) or keeps + looking for other compatible debuggers (``DebuggerSearch.KeepLooking``). + """ + + mozinfo.find_and_update_from_json() + os = mozinfo.info["os"] + + # Find out which debuggers are preferred for use on this platform. + debuggerPriorities = _DEBUGGER_PRIORITIES[ + os if os in _DEBUGGER_PRIORITIES else "unknown" + ] + + # Finally get the debugger information. + for debuggerName in debuggerPriorities: + debuggerPath = get_debugger_path(debuggerName) + if debuggerPath: + return debuggerName + elif not search == DebuggerSearch.KeepLooking: + return None + + return None + + +# Defines default values for Valgrind flags. +# +# --smc-check=all-non-file is required to deal with code generation and +# patching by the various JITS. Note that this is only necessary on +# x86 and x86_64, but not on ARM. This flag is only necessary for +# Valgrind versions prior to 3.11. +# +# --vex-iropt-register-updates=allregs-at-mem-access is required so that +# Valgrind generates correct register values whenever there is a +# segfault that is caught and handled. In particular OdinMonkey +# requires this. More recent Valgrinds (3.11 and later) provide +# --px-default=allregs-at-mem-access and +# --px-file-backed=unwindregs-at-mem-access +# which provide a significantly cheaper alternative, by restricting the +# precise exception behaviour to JIT generated code only. +# +# --trace-children=yes is required to get Valgrind to follow into +# content and other child processes. The resulting output can be +# difficult to make sense of, and --child-silent-after-fork=yes +# helps by causing Valgrind to be silent for the child in the period +# after fork() but before its subsequent exec(). +# +# --trace-children-skip lists processes that we are not interested +# in tracing into. +# +# --leak-check=full requests full stack traces for all leaked blocks +# detected at process exit. +# +# --show-possibly-lost=no requests blocks for which only an interior +# pointer was found to be considered not leaked. +# +# +# TODO: pass in the user supplied args for V (--valgrind-args=) and +# use this to detect if a different tool has been selected. If so +# adjust tool-specific args appropriately. +# +# TODO: pass in the path to the Valgrind to be used (--valgrind=), and +# check what flags it accepts. Possible args that might be beneficial: +# +# --num-transtab-sectors=24 [reduces re-jitting overheads in long runs] +# --px-default=allregs-at-mem-access +# --px-file-backed=unwindregs-at-mem-access +# [these reduce PX overheads as described above] +# + + +def get_default_valgrind_args(): + return [ + "--fair-sched=yes", + "--smc-check=all-non-file", + "--vex-iropt-register-updates=allregs-at-mem-access", + "--trace-children=yes", + "--child-silent-after-fork=yes", + ( + "--trace-children-skip=" + + "/usr/bin/hg,/bin/rm,*/bin/certutil,*/bin/pk12util," + + "*/bin/ssltunnel,*/bin/uname,*/bin/which,*/bin/ps," + + "*/bin/grep,*/bin/java,*/bin/lsb_release" + ), + ] + get_default_valgrind_tool_specific_args() + + +# The default tool is Memcheck. Feeding these arguments to a different +# Valgrind tool will cause it to fail at startup, so don't do that! + + +def get_default_valgrind_tool_specific_args(): + return [ + "--partial-loads-ok=yes", + "--leak-check=summary", + "--show-possibly-lost=no", + "--show-mismatched-frees=no", + ] diff --git a/testing/mozbase/mozdebug/setup.cfg b/testing/mozbase/mozdebug/setup.cfg new file mode 100644 index 0000000000..3c6e79cf31 --- /dev/null +++ b/testing/mozbase/mozdebug/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 diff --git a/testing/mozbase/mozdebug/setup.py b/testing/mozbase/mozdebug/setup.py new file mode 100644 index 0000000000..2e28924fad --- /dev/null +++ b/testing/mozbase/mozdebug/setup.py @@ -0,0 +1,31 @@ +# 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 setuptools import setup + +PACKAGE_VERSION = "0.3.0" +DEPS = ["mozinfo"] + + +setup( + name="mozdebug", + version=PACKAGE_VERSION, + description="Utilities for running applications under native code debuggers " + "intended for use in Mozilla testing", + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + classifiers=[ + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.6", + ], + # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + keywords="mozilla", + author="Mozilla Automation and Testing Team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + license="MPL", + packages=["mozdebug"], + include_package_data=True, + zip_safe=False, + install_requires=DEPS, +) diff --git a/testing/mozbase/mozdebug/tests/fake_debuggers/cgdb/cgdb b/testing/mozbase/mozdebug/tests/fake_debuggers/cgdb/cgdb new file mode 100755 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/mozbase/mozdebug/tests/fake_debuggers/cgdb/cgdb diff --git a/testing/mozbase/mozdebug/tests/fake_debuggers/devenv/devenv.exe b/testing/mozbase/mozdebug/tests/fake_debuggers/devenv/devenv.exe new file mode 100755 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/mozbase/mozdebug/tests/fake_debuggers/devenv/devenv.exe diff --git a/testing/mozbase/mozdebug/tests/fake_debuggers/gdb/gdb b/testing/mozbase/mozdebug/tests/fake_debuggers/gdb/gdb new file mode 100755 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/mozbase/mozdebug/tests/fake_debuggers/gdb/gdb diff --git a/testing/mozbase/mozdebug/tests/fake_debuggers/lldb/lldb b/testing/mozbase/mozdebug/tests/fake_debuggers/lldb/lldb new file mode 100755 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/mozbase/mozdebug/tests/fake_debuggers/lldb/lldb diff --git a/testing/mozbase/mozdebug/tests/fake_debuggers/wdexpress/wdexpress.exe b/testing/mozbase/mozdebug/tests/fake_debuggers/wdexpress/wdexpress.exe new file mode 100755 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/mozbase/mozdebug/tests/fake_debuggers/wdexpress/wdexpress.exe diff --git a/testing/mozbase/mozdebug/tests/manifest.toml b/testing/mozbase/mozdebug/tests/manifest.toml new file mode 100644 index 0000000000..147e23872e --- /dev/null +++ b/testing/mozbase/mozdebug/tests/manifest.toml @@ -0,0 +1,4 @@ +[DEFAULT] +subsuite = "mozbase" + +["test.py"] diff --git a/testing/mozbase/mozdebug/tests/test.py b/testing/mozbase/mozdebug/tests/test.py new file mode 100644 index 0000000000..57bbfec95d --- /dev/null +++ b/testing/mozbase/mozdebug/tests/test.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python + +import os + +import mozunit +import pytest +from mozdebug.mozdebug import ( + _DEBUGGER_PRIORITIES, + DebuggerSearch, + get_default_debugger_name, +) + +here = os.path.abspath(os.path.dirname(__file__)) + + +@pytest.fixture +def set_debuggers(monkeypatch): + debugger_dir = os.path.join(here, "fake_debuggers") + + def _set_debuggers(*debuggers): + dirs = [] + for d in debuggers: + if d.endswith(".exe"): + d = d[: -len(".exe")] + dirs.append(os.path.join(debugger_dir, d)) + monkeypatch.setenv("PATH", os.pathsep.join(dirs)) + + return _set_debuggers + + +@pytest.mark.parametrize("os_name", ["android", "linux", "mac", "win", "unknown"]) +def test_default_debugger_name(os_name, set_debuggers, monkeypatch): + import sys + + import mozinfo + + def update_os_name(*args, **kwargs): + mozinfo.info["os"] = os_name + + monkeypatch.setattr(mozinfo, "find_and_update_from_json", update_os_name) + + if sys.platform == "win32": + # This is used so distutils.spawn.find_executable doesn't add '.exe' + # suffixes to all our dummy binaries on Windows. + monkeypatch.setattr(sys, "platform", "linux") + + debuggers = _DEBUGGER_PRIORITIES[os_name][:] + debuggers.reverse() + first = True + while len(debuggers) > 0: + set_debuggers(*debuggers) + + if first: + assert get_default_debugger_name() == debuggers[-1] + first = False + else: + assert get_default_debugger_name() is None + assert ( + get_default_debugger_name(DebuggerSearch.KeepLooking) == debuggers[-1] + ) + debuggers = debuggers[:-1] + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozdevice/mozdevice/__init__.py b/testing/mozbase/mozdevice/mozdevice/__init__.py new file mode 100644 index 0000000000..e8e4965b92 --- /dev/null +++ b/testing/mozbase/mozdevice/mozdevice/__init__.py @@ -0,0 +1,181 @@ +# 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/. + +"""mozdevice provides a Python interface to the Android Debug Bridge (adb) for Android Devices. + +mozdevice exports the following classes: + +ADBProcess is a class which is used by ADBCommand to execute commands +via subprocess.Popen. + +ADBCommand is an internal only class which provides the basics of +the interfaces for connecting to a device, and executing commands +either on the host or device using ADBProcess. + +ADBHost is a Python class used to execute commands which are not +necessarily associated with a specific device. It is intended to be +used directly. + +ADBDevice is a Python class used to execute commands which will +interact with a specific connected Android device. + +ADBAndroid inherits directly from ADBDevice and is essentially a +synonym for ADBDevice. It is included for backwards compatibility only +and should not be used in new code. + +ADBDeviceFactory is a Python function used to create instances of +ADBDevice. ADBDeviceFactory is preferred over using ADBDevice to +create new instances of ADBDevice since it will only create one +instance of ADBDevice for each connected device. + +mozdevice exports the following exceptions: + +:: + + Exception - + |- ADBTimeoutError + |- ADBDeviceFactoryError + |- ADBError + |- ADBProcessError + |- ADBListDevicesError + +ADBTimeoutError is a special exception that is not part of the +ADBError class hierarchy. It is raised when a command has failed to +complete within the specified timeout period. Since this typically is +due to a failure in the usb connection to the device and is not +recoverable, it is implemented separately from ADBError so that it +will not be caught by normal except clause handling of expected error +conditions and is considered to be treated as a *fatal* error. + +ADBDeviceFactoryError is also a special exception that is not part +of the ADBError class hierarchy. It is raised by ADBDeviceFactory +when the state of the internal ADBDevices object is in an +inconsistent state and is considered to be a *fatal* error. + +ADBListDevicesError is an instance of ADBError which is +raised only by the ADBHost.devices() method to signify that +``adb devices`` reports that the device state has no permissions and can +not be contacted via adb. + +ADBProcessError is an instance of ADBError which is raised when a +process executed via ADBProcess has exited with a non-zero exit +code. It is raised by the ADBCommand.command method and the methods +that call it. + +ADBError is a generic exception class to signify that some error +condition has occured which may be handled depending on the semantics +of the executing code. + +Example: + +:: + + from mozdevice import ADBHost, ADBDeviceFactory, ADBError + + adbhost = ADBHost() + try: + adbhost.kill_server() + adbhost.start_server() + except ADBError as e: + print('Unable to restart the adb server: {}'.format(str(e))) + + device = ADBDeviceFactory() + try: + sdcard_contents = device.ls('/sdcard/') # List the contents of the sdcard on the device. + print('sdcard contains {}'.format(' '.join(sdcard_contents)) + except ADBError as e: + print('Unable to list the sdcard: {}'.format(str(e))) + +Android devices use a security model based upon user permissions much +like that used in Linux upon which it is based. The adb shell executes +commands on the device as the shell user whose access to the files and +directories on the device are limited by the directory and file +permissions set in the device's file system. + +Android apps run under their own user accounts and are restricted by +the app's requested permissions in terms of what commands and files +and directories they may access. + +Like Linux, Android supports a root user who has unrestricted access +to the command and content stored on the device. + +Most commercially released Android devices do not allow adb to run +commands as the root user. Typically, only Android emulators running +certain system images, devices which have AOSP debug or engineering +Android builds or devices which have been *rooted* can run commands as +the root user. + +ADBDevice supports using both unrooted and rooted devices by laddering +its capabilities depending on the specific circumstances where it is +used. + +ADBDevice uses a special location on the device, called the +*test_root*, where it places content to be tested. This can include +binary executables and libraries, configuration files and log +files. Since the special location /data/local/tmp is usually +accessible by the shell user, the test_root is located at +/data/local/tmp/test_root by default. /data/local/tmp is used instead +of the sdcard due to recent Scoped Storage restrictions on access to +the sdcard in Android 10 and later. + +If the device supports running adbd as root, or if the device has been +rooted and supports the use of the su command to run commands as root, +ADBDevice will default to running all shell commands under the root +user and the test_root will remain set to /data/local/tmp/test_root +unless changed. + +If the device does not support running shell commands under the root +user, and a *debuggable* app is set in ADBDevice property +run_as_package, then ADBDevice will set the test_root to +/data/data/<app-package-name>/test_root and will run shell commands as +the app user when accessing content located in the app's data +directory. Content can be pushed to the app's data directory or pulled +from the app's data directory by using the command run-as to access +the app's data. + +If a device does not support running commands as root and a +*debuggable* app is not being used, command line programs can still be +executed by pushing them to the /data/local/tmp directory which is +accessible to the shell user. + +If for some reason, the device is not rooted and /data/local/tmp is +not acccessible to the shell user, then ADBDevice will fail to +initialize and will not be useable for that device. + +NOTE: ADBFactory will clear the contents of the test_root when it +first creates an instance of ADBDevice. + +When the run_as_package property is set in an ADBDevice instance, it +will clear the contents of the current test_root before changing the +test_root to point to the new location +/data/data/<app-package-name>/test_root which will then be cleared of +any existing content. + +""" + +from .adb import ( + ADBCommand, + ADBDevice, + ADBDeviceFactory, + ADBError, + ADBHost, + ADBProcess, + ADBProcessError, + ADBTimeoutError, +) +from .adb_android import ADBAndroid +from .remote_process_monitor import RemoteProcessMonitor + +__all__ = [ + "ADBError", + "ADBProcessError", + "ADBTimeoutError", + "ADBProcess", + "ADBCommand", + "ADBHost", + "ADBDevice", + "ADBAndroid", + "ADBDeviceFactory", + "RemoteProcessMonitor", +] diff --git a/testing/mozbase/mozdevice/mozdevice/adb.py b/testing/mozbase/mozdevice/mozdevice/adb.py new file mode 100644 index 0000000000..bf3029c2f4 --- /dev/null +++ b/testing/mozbase/mozdevice/mozdevice/adb.py @@ -0,0 +1,4438 @@ +# 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/. + +import io +import os +import pipes +import posixpath +import re +import shlex +import shutil +import signal +import subprocess +import sys +import tempfile +import time +import traceback +from shutil import copytree +from threading import Thread + +import six +from six.moves import range + +from . import version_codes + +_TEST_ROOT = None + + +class ADBProcess(object): + """ADBProcess encapsulates the data related to executing the adb process.""" + + def __init__(self, args, use_stdout_pipe=False, timeout=None): + #: command argument list. + self.args = args + Popen_args = {} + + #: Temporary file handle to be used for stdout. + if use_stdout_pipe: + self.stdout_file = subprocess.PIPE + # Reading utf-8 from the stdout pipe + if sys.version_info >= (3, 6): + Popen_args["encoding"] = "utf-8" + else: + Popen_args["universal_newlines"] = True + else: + self.stdout_file = tempfile.NamedTemporaryFile(mode="w+b") + Popen_args["stdout"] = self.stdout_file + + #: boolean indicating if the command timed out. + self.timedout = None + + #: exitcode of the process. + self.exitcode = None + + #: subprocess Process object used to execute the command. + Popen_args["stderr"] = subprocess.STDOUT + self.proc = subprocess.Popen(args, **Popen_args) + + # If a timeout is set, then create a thread responsible for killing the + # process, as well as updating the exitcode and timedout status. + def timeout_thread(adb_process, timeout): + start_time = time.time() + polling_interval = 0.001 + adb_process.exitcode = adb_process.proc.poll() + while (time.time() - start_time) <= float( + timeout + ) and adb_process.exitcode is None: + time.sleep(polling_interval) + adb_process.exitcode = adb_process.proc.poll() + + if adb_process.exitcode is None: + adb_process.proc.kill() + adb_process.timedout = True + adb_process.exitcode = adb_process.proc.poll() + + if timeout: + Thread(target=timeout_thread, args=(self, timeout), daemon=True).start() + + @property + def stdout(self): + """Return the contents of stdout.""" + assert not self.stdout_file == subprocess.PIPE + if not self.stdout_file or self.stdout_file.closed: + content = "" + else: + self.stdout_file.seek(0, os.SEEK_SET) + content = six.ensure_str(self.stdout_file.read().rstrip()) + return content + + def __str__(self): + # Remove -s <serialno> from the error message to allow bug suggestions + # to be independent of the individual failing device. + arg_string = " ".join(self.args) + arg_string = re.sub(r" -s [\w-]+", "", arg_string) + return "args: %s, exitcode: %s, stdout: %s" % ( + arg_string, + self.exitcode, + self.stdout, + ) + + def __iter__(self): + assert self.stdout_file == subprocess.PIPE + return self + + def __next__(self): + assert self.stdout_file == subprocess.PIPE + try: + return next(self.proc.stdout) + except StopIteration: + # Wait until the process ends. + while self.exitcode is None or self.timedout: + time.sleep(0.001) + raise StopIteration + + +# ADBError and ADBTimeoutError are treated differently in order that +# ADBTimeoutErrors can be handled distinctly from ADBErrors. + + +class ADBError(Exception): + """ADBError is raised in situations where a command executed on a + device either exited with a non-zero exitcode or when an + unexpected error condition has occurred. Generally, ADBErrors can + be handled and the device can continue to be used. + """ + + pass + + +class ADBProcessError(ADBError): + """ADBProcessError is raised when an associated ADBProcess is + available and relevant. + """ + + def __init__(self, adb_process): + ADBError.__init__(self, str(adb_process)) + self.adb_process = adb_process + + +class ADBListDevicesError(ADBError): + """ADBListDevicesError is raised when errors are found listing the + devices, typically not any permissions. + + The devices information is stocked with the *devices* member. + """ + + def __init__(self, msg, devices): + ADBError.__init__(self, msg) + self.devices = devices + + +class ADBTimeoutError(Exception): + """ADBTimeoutError is raised when either a host command or shell + command takes longer than the specified timeout to execute. The + timeout value is set in the ADBCommand constructor and is 300 seconds by + default. This error is typically fatal since the host is having + problems communicating with the device. You may be able to recover + by rebooting, but this is not guaranteed. + + Recovery options are: + + * Killing and restarting the adb server via + :: + + adb kill-server; adb start-server + + * Rebooting the device manually. + * Rebooting the host. + """ + + pass + + +class ADBDeviceFactoryError(Exception): + """ADBDeviceFactoryError is raised when the ADBDeviceFactory is in + an inconsistent state. + """ + + pass + + +class ADBCommand(object): + """ADBCommand provides a basic interface to adb commands + which is used to provide the 'command' methods for the + classes ADBHost and ADBDevice. + + ADBCommand should only be used as the base class for other + classes and should not be instantiated directly. To enforce this + restriction calling ADBCommand's constructor will raise a + NonImplementedError exception. + + :param str adb: path to adb executable. Defaults to 'adb'. + :param str adb_host: host of the adb server. + :param int adb_port: port of the adb server. + :param str logger_name: logging logger name. Defaults to 'adb'. + :param int timeout: The default maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. This timeout is per adb call. The + total time spent may exceed this value. If it is not + specified, the value defaults to 300. + :param bool verbose: provide verbose output + :param bool use_root: Use root if available on device + :raises: :exc:`ADBError` + :exc:`ADBTimeoutError` + + :: + + from mozdevice import ADBCommand + + try: + adbcommand = ADBCommand() + except NotImplementedError: + print "ADBCommand can not be instantiated." + """ + + def __init__( + self, + adb="adb", + adb_host=None, + adb_port=None, + logger_name="adb", + timeout=300, + verbose=False, + use_root=True, + ): + if self.__class__ == ADBCommand: + raise NotImplementedError + + self._logger = self._get_logger(logger_name, verbose) + self._verbose = verbose + self._use_root = use_root + self._adb_path = adb + self._adb_host = adb_host + self._adb_port = adb_port + self._timeout = timeout + self._polling_interval = 0.001 + self._adb_version = "" + + self._logger.debug("%s: %s" % (self.__class__.__name__, self.__dict__)) + + # catch early a missing or non executable adb command + # and get the adb version while we are at it. + try: + output = subprocess.Popen( + [adb, "version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE + ).communicate() + re_version = re.compile(r"Android Debug Bridge version (.*)") + if isinstance(output[0], six.binary_type): + self._adb_version = re_version.match( + output[0].decode("utf-8", "replace") + ).group(1) + else: + self._adb_version = re_version.match(output[0]).group(1) + + if self._adb_version < "1.0.36": + raise ADBError( + "adb version %s less than minimum 1.0.36" % self._adb_version + ) + + except Exception as exc: + raise ADBError("%s: %s is not executable." % (exc, adb)) + + def _get_logger(self, logger_name, verbose): + logger = None + level = "DEBUG" if verbose else "INFO" + try: + import mozlog + + logger = mozlog.get_default_logger(logger_name) + if not logger: + if sys.__stdout__.isatty(): + defaults = {"mach": sys.stdout} + else: + defaults = {"tbpl": sys.stdout} + logger = mozlog.commandline.setup_logging( + logger_name, {}, defaults, formatter_defaults={"level": level} + ) + except ImportError: + pass + + if logger is None: + import logging + + logger = logging.getLogger(logger_name) + logger.setLevel(level) + return logger + + # Host Command methods + + def command(self, cmds, device_serial=None, timeout=None): + """Executes an adb command on the host. + + :param list cmds: The command and its arguments to be + executed. + :param str device_serial: The device's + serial number if the adb command is to be executed against + a specific device. If it is not specified, ANDROID_SERIAL + from the environment will be used if it is set. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. This timeout is per adb call. The + total time spent may exceed this value. If it is not + specified, the value set in the ADBCommand constructor is used. + :return: :class:`ADBProcess` + + command() provides a low level interface for executing + commands on the host via adb. + + command() executes on the host in such a fashion that stdout + of the adb process is a file handle on the host and + the exit code is available as the exit code of the adb + process. + + The caller provides a list containing commands, as well as a + timeout period in seconds. + + A subprocess is spawned to execute adb with stdout and stderr + directed to a temporary file. If the process takes longer than + the specified timeout, the process is terminated. + + It is the caller's responsibilty to clean up by closing + the stdout temporary file. + """ + args = [self._adb_path] + device_serial = device_serial or os.environ.get("ANDROID_SERIAL") + if self._adb_host: + args.extend(["-H", self._adb_host]) + if self._adb_port: + args.extend(["-P", str(self._adb_port)]) + if device_serial: + args.extend(["-s", device_serial, "wait-for-device"]) + args.extend(cmds) + + adb_process = ADBProcess(args) + + if timeout is None: + timeout = self._timeout + + start_time = time.time() + adb_process.exitcode = adb_process.proc.poll() + while (time.time() - start_time) <= float( + timeout + ) and adb_process.exitcode is None: + time.sleep(self._polling_interval) + adb_process.exitcode = adb_process.proc.poll() + if adb_process.exitcode is None: + adb_process.proc.kill() + adb_process.timedout = True + adb_process.exitcode = adb_process.proc.poll() + + adb_process.stdout_file.seek(0, os.SEEK_SET) + + return adb_process + + def command_output(self, cmds, device_serial=None, timeout=None): + """Executes an adb command on the host returning stdout. + + :param list cmds: The command and its arguments to be + executed. + :param str device_serial: The device's + serial number if the adb command is to be executed against + a specific device. If it is not specified, ANDROID_SERIAL + from the environment will be used if it is set. + :param int timeout: The maximum time in seconds + for any spawned adb process to complete before throwing + an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBCommand constructor is used. + :return: str - content of stdout. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + adb_process = None + try: + # Need to force the use of the ADBCommand class's command + # since ADBDevice will redefine command and call its + # own version otherwise. + adb_process = ADBCommand.command( + self, cmds, device_serial=device_serial, timeout=timeout + ) + if adb_process.timedout: + raise ADBTimeoutError("%s" % adb_process) + if adb_process.exitcode: + raise ADBProcessError(adb_process) + output = adb_process.stdout + if self._verbose: + self._logger.debug( + "command_output: %s, " + "timeout: %s, " + "timedout: %s, " + "exitcode: %s, output: %s" + % ( + " ".join(adb_process.args), + timeout, + adb_process.timedout, + adb_process.exitcode, + output, + ) + ) + + return output + finally: + if adb_process and isinstance(adb_process.stdout_file, io.IOBase): + adb_process.stdout_file.close() + + +class ADBHost(ADBCommand): + """ADBHost provides a basic interface to adb host commands + which do not target a specific device. + + :param str adb: path to adb executable. Defaults to 'adb'. + :param str adb_host: host of the adb server. + :param int adb_port: port of the adb server. + :param logger_name: logging logger name. Defaults to 'adb'. + :param int timeout: The default maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. This timeout is per adb call. The + total time spent may exceed this value. If it is not + specified, the value defaults to 300. + :param bool verbose: provide verbose output + :raises: :exc:`ADBError` + :exc:`ADBTimeoutError` + + :: + + from mozdevice import ADBHost + + adbhost = ADBHost() + adbhost.start_server() + """ + + def __init__( + self, + adb="adb", + adb_host=None, + adb_port=None, + logger_name="adb", + timeout=300, + verbose=False, + ): + ADBCommand.__init__( + self, + adb=adb, + adb_host=adb_host, + adb_port=adb_port, + logger_name=logger_name, + timeout=timeout, + verbose=verbose, + use_root=True, + ) + + def command(self, cmds, timeout=None): + """Executes an adb command on the host. + + :param list cmds: The command and its arguments to be + executed. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. This timeout is per adb call. The + total time spent may exceed this value. If it is not + specified, the value set in the ADBHost constructor is used. + :return: :class:`ADBProcess` + + command() provides a low level interface for executing + commands on the host via adb. + + command() executes on the host in such a fashion that stdout + of the adb process is a file handle on the host and + the exit code is available as the exit code of the adb + process. + + The caller provides a list containing commands, as well as a + timeout period in seconds. + + A subprocess is spawned to execute adb with stdout and stderr + directed to a temporary file. If the process takes longer than + the specified timeout, the process is terminated. + + It is the caller's responsibilty to clean up by closing + the stdout temporary file. + """ + return ADBCommand.command(self, cmds, timeout=timeout) + + def command_output(self, cmds, timeout=None): + """Executes an adb command on the host returning stdout. + + :param list cmds: The command and its arguments to be + executed. + :param int timeout: The maximum time in seconds + for any spawned adb process to complete before throwing + an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBHost constructor is used. + :return: str - content of stdout. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + return ADBCommand.command_output(self, cmds, timeout=timeout) + + def start_server(self, timeout=None): + """Starts the adb server. + + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. This timeout is per adb call. The + total time spent may exceed this value. If it is not + specified, the value set in the ADBHost constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + + Attempting to use start_server with any adb_host value other than None + will fail with an ADBError exception. + + You will need to start the server on the remote host via the command: + + .. code-block:: shell + + adb -a fork-server server + + If you wish the remote adb server to restart automatically, you can + enclose the command in a loop as in: + + .. code-block:: shell + + while true; do + adb -a fork-server server + done + """ + self.command_output(["start-server"], timeout=timeout) + + def kill_server(self, timeout=None): + """Kills the adb server. + + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. This timeout is per adb call. The + total time spent may exceed this value. If it is not + specified, the value set in the ADBHost constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + self.command_output(["kill-server"], timeout=timeout) + + def devices(self, timeout=None): + """Executes adb devices -l and returns a list of objects describing attached devices. + + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. This timeout is per adb call. The + total time spent may exceed this value. If it is not + specified, the value set in the ADBHost constructor is used. + :return: an object contain + :raises: :exc:`ADBTimeoutError` + :exc:`ADBListDevicesError` + :exc:`ADBError` + + The output of adb devices -l + + :: + + $ adb devices -l + List of devices attached + b313b945 device usb:1-7 product:d2vzw model:SCH_I535 device:d2vzw + + is parsed and placed into an object as in + + :: + + [{'device_serial': 'b313b945', 'state': 'device', 'product': 'd2vzw', + 'usb': '1-7', 'device': 'd2vzw', 'model': 'SCH_I535' }] + """ + # b313b945 device usb:1-7 product:d2vzw model:SCH_I535 device:d2vzw + # from Android system/core/adb/transport.c statename() + re_device_info = re.compile( + r"([^\s]+)\s+(offline|bootloader|device|host|recovery|sideload|" + "no permissions|unauthorized|unknown)" + ) + devices = [] + lines = self.command_output(["devices", "-l"], timeout=timeout).splitlines() + for line in lines: + if line == "List of devices attached ": + continue + match = re_device_info.match(line) + if match: + device = {"device_serial": match.group(1), "state": match.group(2)} + remainder = line[match.end(2) :].strip() + if remainder: + try: + device.update( + dict([j.split(":") for j in remainder.split(" ")]) + ) + except ValueError: + self._logger.warning( + "devices: Unable to parse " "remainder for device %s" % line + ) + devices.append(device) + for device in devices: + if device["state"] == "no permissions": + raise ADBListDevicesError( + "No permissions to detect devices. You should restart the" + " adb server as root:\n" + "\n# adb kill-server\n# adb start-server\n" + "\nor maybe configure your udev rules.", + devices, + ) + return devices + + +ADBDEVICES = {} + + +def ADBDeviceFactory( + device=None, + adb="adb", + adb_host=None, + adb_port=None, + test_root=None, + logger_name="adb", + timeout=300, + verbose=False, + device_ready_retry_wait=20, + device_ready_retry_attempts=3, + use_root=True, + share_test_root=True, + run_as_package=None, +): + """ADBDeviceFactory provides a factory for :class:`ADBDevice` + instances that enforces the requirement that only one + :class:`ADBDevice` be created for each attached device. It uses + the identical arguments as the :class:`ADBDevice` + constructor. This is also used to ensure that the device's + test_root is initialized to an empty directory before tests are + run on the device. + + :return: :class:`ADBDevice` + :raises: :exc:`ADBDeviceFactoryError` + :exc:`ADBError` + :exc:`ADBTimeoutError` + + """ + device = device or os.environ.get("ANDROID_SERIAL") + if device is not None and device in ADBDEVICES: + # We have already created an ADBDevice for this device, just re-use it. + adbdevice = ADBDEVICES[device] + elif device is None and ADBDEVICES: + # We did not specify the device serial number and we have + # already created an ADBDevice which means we must only have + # one device connected and we can re-use the existing ADBDevice. + devices = list(ADBDEVICES.keys()) + assert ( + len(devices) == 1 + ), "Only one device may be connected if the device serial number is not specified." + adbdevice = ADBDEVICES[devices[0]] + elif ( + device is not None + and device not in ADBDEVICES + or device is None + and not ADBDEVICES + ): + # The device has not had an ADBDevice created yet. + adbdevice = ADBDevice( + device=device, + adb=adb, + adb_host=adb_host, + adb_port=adb_port, + test_root=test_root, + logger_name=logger_name, + timeout=timeout, + verbose=verbose, + device_ready_retry_wait=device_ready_retry_wait, + device_ready_retry_attempts=device_ready_retry_attempts, + use_root=use_root, + share_test_root=share_test_root, + run_as_package=run_as_package, + ) + ADBDEVICES[adbdevice._device_serial] = adbdevice + else: + raise ADBDeviceFactoryError( + "Inconsistent ADBDeviceFactory: device: %s, ADBDEVICES: %s" + % (device, ADBDEVICES) + ) + # Clean the test root before testing begins. + if test_root: + adbdevice.rm( + posixpath.join(adbdevice.test_root, "*"), + recursive=True, + force=True, + timeout=timeout, + ) + # Sync verbose and update the logger configuration in case it has + # changed since the initial initialization + if verbose != adbdevice._verbose: + adbdevice._verbose = verbose + adbdevice._logger = adbdevice._get_logger(adbdevice._logger.name, verbose) + return adbdevice + + +class ADBDevice(ADBCommand): + """ADBDevice provides methods which can be used to interact with the + associated Android-based device. + + :param str device: When a string is passed in device, it + is interpreted as the device serial number. This form is not + compatible with devices containing a ":" in the serial; in + this case ValueError will be raised. When a dictionary is + passed it must have one or both of the keys "device_serial" + and "usb". This is compatible with the dictionaries in the + list returned by ADBHost.devices(). If the value of + device_serial is a valid serial not containing a ":" it will + be used to identify the device, otherwise the value of the usb + key, prefixed with "usb:" is used. If None is passed and + there is exactly one device attached to the host, that device + is used. If None is passed and ANDROID_SERIAL is set in the environment, + that device is used. If there is more than one device attached and + device is None and ANDROID_SERIAL is not set in the environment, ValueError + is raised. If no device is attached the constructor will block + until a device is attached or the timeout is reached. + :param str adb_host: host of the adb server to connect to. + :param int adb_port: port of the adb server to connect to. + :param str test_root: value containing the test root to be + used on the device. This value will be shared among all + instances of ADBDevice if share_test_root is True. + :param str logger_name: logging logger name. Defaults to 'adb' + :param int timeout: The default maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. This timeout is per adb call. The + total time spent may exceed this value. If it is not + specified, the value defaults to 300. + :param bool verbose: provide verbose output + :param int device_ready_retry_wait: number of seconds to wait + between attempts to check if the device is ready after a + reboot. + :param integer device_ready_retry_attempts: number of attempts when + checking if a device is ready. + :param bool use_root: Use root if it is available on device + :param bool share_test_root: True if instance should share the + same test_root value with other ADBInstances. Defaults to True. + :param str run_as_package: Name of package to be used in run-as in liew of + using su. + :raises: :exc:`ADBError` + :exc:`ADBTimeoutError` + :exc:`ValueError` + + :: + + from mozdevice import ADBDevice + + adbdevice = ADBDevice() + print(adbdevice.list_files("/mnt/sdcard")) + if adbdevice.process_exist("org.mozilla.geckoview.test_runner"): + print("org.mozilla.geckoview.test_runner is running") + """ + + SOCKET_DIRECTION_REVERSE = "reverse" + + SOCKET_DIRECTION_FORWARD = "forward" + + # BUILTINS is used to determine which commands can not be executed + # via su or run-as. This set of possible builtin commands was + # obtained from `man builtin` on Linux. + BUILTINS = set( + [ + "alias", + "bg", + "bind", + "break", + "builtin", + "caller", + "cd", + "command", + "compgen", + "complete", + "compopt", + "continue", + "declare", + "dirs", + "disown", + "echo", + "enable", + "eval", + "exec", + "exit", + "export", + "false", + "fc", + "fg", + "getopts", + "hash", + "help", + "history", + "jobs", + "kill", + "let", + "local", + "logout", + "mapfile", + "popd", + "printf", + "pushd", + "pwd", + "read", + "readonly", + "return", + "set", + "shift", + "shopt", + "source", + "suspend", + "test", + "times", + "trap", + "true", + "type", + "typeset", + "ulimit", + "umask", + "unalias", + "unset", + "wait", + ] + ) + + def __init__( + self, + device=None, + adb="adb", + adb_host=None, + adb_port=None, + test_root=None, + logger_name="adb", + timeout=300, + verbose=False, + device_ready_retry_wait=20, + device_ready_retry_attempts=3, + use_root=True, + share_test_root=True, + run_as_package=None, + ): + global _TEST_ROOT + + ADBCommand.__init__( + self, + adb=adb, + adb_host=adb_host, + adb_port=adb_port, + logger_name=logger_name, + timeout=timeout, + verbose=verbose, + use_root=use_root, + ) + self._logger.info("Using adb %s" % self._adb_version) + self._device_serial = self._get_device_serial(device) + self._initial_test_root = test_root + self._share_test_root = share_test_root + if share_test_root and not _TEST_ROOT: + _TEST_ROOT = test_root + self._test_root = None + self._run_as_package = None + # Cache packages debuggable state. + self._debuggable_packages = {} + self._device_ready_retry_wait = device_ready_retry_wait + self._device_ready_retry_attempts = device_ready_retry_attempts + self._have_root_shell = False + self._have_su = False + self._have_android_su = False + self._selinux = None + self._re_internal_storage = None + + self._wait_for_boot_completed(timeout=timeout) + + # Record the start time of the ADBDevice initialization so we can + # determine if we should abort with an ADBTimeoutError if it is + # taking too long. + start_time = time.time() + + # Attempt to get the Android version as early as possible in order + # to work around differences in determining adb command exit codes + # in Android before and after Android 7. + self.version = 0 + while self.version < 1 and (time.time() - start_time) <= float(timeout): + try: + version = self.get_prop("ro.build.version.sdk", timeout=timeout) + self.version = int(version) + except ValueError: + self._logger.info("unexpected ro.build.version.sdk: '%s'" % version) + time.sleep(2) + if self.version < 1: + # note slightly different meaning to the ADBTimeoutError here (and above): + # failed to get valid (numeric) version string in all attempts in allowed time + raise ADBTimeoutError( + "ADBDevice: unable to determine ro.build.version.sdk." + ) + + self._mkdir_p = None + # Force the use of /system/bin/ls or /system/xbin/ls in case + # there is /sbin/ls which embeds ansi escape codes to colorize + # the output. Detect if we are using busybox ls. We want each + # entry on a single line and we don't want . or .. + ls_dir = "/system" + + # Using self.is_file is problematic on emulators either + # using ls or test to check for their existence. + # Executing the command to detect its existence works around + # any issues with ls or test. + boot_completed = False + while not boot_completed and (time.time() - start_time) <= float(timeout): + try: + self.shell_output("/system/bin/ls /system/bin/ls", timeout=timeout) + boot_completed = True + self._ls = "/system/bin/ls" + except ADBError as e1: + self._logger.debug("detect /system/bin/ls {}".format(e1)) + try: + self.shell_output( + "/system/xbin/ls /system/xbin/ls", timeout=timeout + ) + boot_completed = True + self._ls = "/system/xbin/ls" + except ADBError as e2: + self._logger.debug("detect /system/xbin/ls : {}".format(e2)) + if not boot_completed: + time.sleep(2) + if not boot_completed: + raise ADBError("ADBDevice.__init__: ls could not be found") + + # A race condition can occur especially with emulators where + # the device appears to be available but it has not completed + # mounting the sdcard. We can work around this by checking if + # the sdcard is missing when we attempt to ls it and retrying + # if it is not yet available. + boot_completed = False + while not boot_completed and (time.time() - start_time) <= float(timeout): + try: + self.shell_output("{} -1A {}".format(self._ls, ls_dir), timeout=timeout) + boot_completed = True + self._ls += " -1A" + except ADBError as e: + self._logger.debug("detect ls -1A: {}".format(e)) + if "No such file or directory" not in str(e): + boot_completed = True + self._ls += " -a" + if not boot_completed: + time.sleep(2) + if not boot_completed: + raise ADBTimeoutError("ADBDevice: /sdcard not found.") + + self._logger.info("%s supported" % self._ls) + + # builtin commands which do not exist as separate programs can + # not be executed using su or run-as. Remove builtin commands + # from self.BUILTINS which also exist as separate programs so + # that we will be able to execute them using su or run-as if + # necessary. + remove_builtins = set() + for builtin in self.BUILTINS: + try: + self.ls("/system/*bin/%s" % builtin, timeout=timeout) + self._logger.debug("Removing %s from BUILTINS" % builtin) + remove_builtins.add(builtin) + except ADBError: + pass + self.BUILTINS.difference_update(remove_builtins) + + # Do we have cp? + boot_completed = False + while not boot_completed and (time.time() - start_time) <= float(timeout): + try: + self.shell_output("cp --help", timeout=timeout) + boot_completed = True + self._have_cp = True + except ADBError as e: + if "not found" in str(e): + self._have_cp = False + boot_completed = True + elif "known option" in str(e): + self._have_cp = True + boot_completed = True + elif "invalid option" in str(e): + self._have_cp = True + boot_completed = True + if not boot_completed: + time.sleep(2) + if not boot_completed: + raise ADBTimeoutError("ADBDevice: cp not found.") + self._logger.info("Native cp support: %s" % self._have_cp) + + # Do we have chmod -R? + try: + self._chmod_R = False + re_recurse = re.compile(r"[-]R") + chmod_output = self.shell_output("chmod --help", timeout=timeout) + match = re_recurse.search(chmod_output) + if match: + self._chmod_R = True + except ADBError as e: + self._logger.debug("Check chmod -R: {}".format(e)) + match = re_recurse.search(str(e)) + if match: + self._chmod_R = True + self._logger.info("Native chmod -R support: {}".format(self._chmod_R)) + + # Do we have chown -R? + try: + self._chown_R = False + chown_output = self.shell_output("chown --help", timeout=timeout) + match = re_recurse.search(chown_output) + if match: + self._chown_R = True + except ADBError as e: + self._logger.debug("Check chown -R: {}".format(e)) + self._logger.info("Native chown -R support: {}".format(self._chown_R)) + + try: + cleared = self.shell_bool('logcat -P ""', timeout=timeout) + except ADBError: + cleared = False + if not cleared: + self._logger.info("Unable to turn off logcat chatty") + + # Do we have pidof? + if self.version < version_codes.N: + # unexpected pidof behavior observed on Android 6 in bug 1514363 + self._have_pidof = False + else: + boot_completed = False + while not boot_completed and (time.time() - start_time) <= float(timeout): + try: + self.shell_output("pidof --help", timeout=timeout) + boot_completed = True + self._have_pidof = True + except ADBError as e: + if "not found" in str(e): + self._have_pidof = False + boot_completed = True + elif "known option" in str(e): + self._have_pidof = True + boot_completed = True + if not boot_completed: + time.sleep(2) + if not boot_completed: + raise ADBTimeoutError("ADBDevice: pidof not found.") + # Bug 1529960 observed pidof intermittently returning no results for a + # running process on the 7.0 x86_64 emulator. + + characteristics = self.get_prop("ro.build.characteristics", timeout=timeout) + + abi = self.get_prop("ro.product.cpu.abi", timeout=timeout) + self._have_flaky_pidof = ( + self.version == version_codes.N + and abi == "x86_64" + and "emulator" in characteristics + ) + self._logger.info( + "Native {} pidof support: {}".format( + "flaky" if self._have_flaky_pidof else "normal", self._have_pidof + ) + ) + + if self._use_root: + # Detect if root is available, but do not fail if it is not. + # Catch exceptions due to the potential for segfaults + # calling su when using an improperly rooted device. + + self._check_adb_root(timeout=timeout) + + if not self._have_root_shell: + # To work around bug 1525401 where su -c id will return an + # exitcode of 1 if selinux permissive is not already in effect, + # we need su to turn off selinux prior to checking for su. + # We can use shell() directly to prevent the non-zero exitcode + # from raising an ADBError. + # Note: We are assuming su -c is supported and do not attempt to + # use su 0. + adb_process = self.shell("su -c setenforce 0") + self._logger.info( + "su -c setenforce 0 exitcode %s, stdout: %s" + % (adb_process.proc.poll(), adb_process.proc.stdout) + ) + + uid = "uid=0" + # Do we have a 'Superuser' sh like su? + try: + if self.shell_output("su -c id", timeout=timeout).find(uid) != -1: + self._have_su = True + self._logger.info("su -c supported") + except ADBError as e: + self._logger.debug("Check for su -c failed: {}".format(e)) + + # Check if Android's su 0 command works. + # If we already have detected su -c support, we can skip this check. + try: + if ( + not self._have_su + and self.shell_output("su 0 id", timeout=timeout).find(uid) + != -1 + ): + self._have_android_su = True + self._logger.info("su 0 supported") + except ADBError as e: + self._logger.debug("Check for su 0 failed: {}".format(e)) + + # Guarantee that /data/local/tmp exists and is accessible to all. + # It is a fatal error if /data/local/tmp does not exist and can not be created. + if not self.exists("/data/local/tmp", timeout=timeout): + # parents=True is required on emulator, where exist() may be flaky + self.mkdir("/data/local/tmp", parents=True, timeout=timeout) + + # Beginning in Android 8.1 /data/anr/traces.txt no longer contains + # a single file traces.txt but instead will contain individual files + # for each stack. + # See https://github.com/aosp-mirror/platform_build/commit/ + # fbba7fe06312241c7eb8c592ec2ac630e4316d55 + stack_trace_dir = self.shell_output( + "getprop dalvik.vm.stack-trace-dir", timeout=timeout + ) + if not stack_trace_dir: + stack_trace_file = self.shell_output( + "getprop dalvik.vm.stack-trace-file", timeout=timeout + ) + if stack_trace_file: + stack_trace_dir = posixpath.dirname(stack_trace_file) + else: + stack_trace_dir = "/data/anr" + self.stack_trace_dir = stack_trace_dir + self.enforcing = "Permissive" + self.run_as_package = run_as_package + + self._logger.debug("ADBDevice: %s" % self.__dict__) + + @property + def is_rooted(self): + return self._have_root_shell or self._have_su or self._have_android_su + + def _wait_for_boot_completed(self, timeout=None): + """Internal method to wait for boot to complete. + + Wait for sys.boot_completed=1 and raise ADBError if boot does + not complete within retry attempts. + + :param int timeout: The default maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. This timeout is per adb call. The + total time spent may exceed this value. If it is not + specified, the value defaults to 300. + :raises: :exc:`ADBError` + """ + for attempt in range(self._device_ready_retry_attempts): + sys_boot_completed = self.shell_output( + "getprop sys.boot_completed", timeout=timeout + ) + if sys_boot_completed == "1": + break + time.sleep(self._device_ready_retry_wait) + if sys_boot_completed != "1": + raise ADBError("Failed to complete boot in time") + + def _get_device_serial(self, device): + device = device or os.environ.get("ANDROID_SERIAL") + if device is None: + devices = ADBHost( + adb=self._adb_path, adb_host=self._adb_host, adb_port=self._adb_port + ).devices() + if len(devices) > 1: + raise ValueError( + "ADBDevice called with multiple devices " + "attached and no device specified" + ) + if len(devices) == 0: + raise ADBError("No connected devices found.") + device = devices[0] + + # Allow : in device serial if it matches a tcpip device serial. + re_device_serial_tcpip = re.compile(r"[^:]+:[0-9]+$") + + def is_valid_serial(serial): + return ( + serial.startswith("usb:") + or re_device_serial_tcpip.match(serial) is not None + or ":" not in serial + ) + + if isinstance(device, six.string_types): + # Treat this as a device serial + if not is_valid_serial(device): + raise ValueError( + "Device serials containing ':' characters are " + "invalid. Pass the output from " + "ADBHost.devices() for the device instead" + ) + return device + + serial = device.get("device_serial") + if serial is not None and is_valid_serial(serial): + return serial + usb = device.get("usb") + if usb is not None: + return "usb:%s" % usb + + raise ValueError("Unable to get device serial") + + def _check_root_user(self, timeout=None): + uid = "uid=0" + # Is shell already running as root? + try: + if self.shell_output("id", timeout=timeout).find(uid) != -1: + self._logger.info("adbd running as root") + return True + except ADBError: + self._logger.debug("Check for root user failed") + return False + + def _check_adb_root(self, timeout=None): + self._have_root_shell = self._check_root_user(timeout=timeout) + + # Exclude these devices from checking for a root shell due to + # potential hangs. + exclude_set = set() + exclude_set.add("E5823") # Sony Xperia Z5 Compact (E5823) + # Do we need to run adb root to get a root shell? + if not self._have_root_shell: + if self.get_prop("ro.product.model") in exclude_set: + self._logger.warning( + "your device was excluded from attempting adb root." + ) + else: + try: + self.command_output(["root"], timeout=timeout) + self._have_root_shell = self._check_root_user(timeout=timeout) + if self._have_root_shell: + self._logger.info("adbd restarted as root") + else: + self._logger.info("adbd not restarted as root") + except ADBError: + self._logger.debug("Check for root adbd failed") + + def pidof(self, app_name, timeout=None): + """ + Return a list of pids for all extant processes running within the + specified application package. + + :param str app_name: The name of the application package to examine + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. This timeout is per + adb call. The total time spent may exceed this + value. If it is not specified, the value set + in the ADBDevice constructor is used. + :return: List of integers containing the pid(s) of the various processes. + :raises: :exc:`ADBTimeoutError` + """ + if self._have_pidof: + try: + pid_output = self.shell_output("pidof %s" % app_name, timeout=timeout) + re_pids = re.compile(r"[0-9]+") + pids = re_pids.findall(pid_output) + if self._have_flaky_pidof and not pids: + time.sleep(0.1) + pid_output = self.shell_output( + "pidof %s" % app_name, timeout=timeout + ) + pids = re_pids.findall(pid_output) + except ADBError: + pids = [] + else: + procs = self.get_process_list(timeout=timeout) + # limit the comparion to the first 75 characters due to a + # limitation in processname length in android. + pids = [proc[0] for proc in procs if proc[1] == app_name[:75]] + + return [int(pid) for pid in pids] + + def _sync(self, timeout=None): + """Sync the file system using shell_output in order that exceptions + are raised to the caller.""" + self.shell_output("sync", timeout=timeout) + + @staticmethod + def _should_quote(arg): + """Utility function if command argument should be quoted.""" + if not arg: + return False + if arg[0] == "'" and arg[-1] == "'" or arg[0] == '"' and arg[-1] == '"': + # Already quoted + return False + re_quotable_chars = re.compile(r"[ ()\"&'\];]") + return re_quotable_chars.search(arg) + + @staticmethod + def _quote(arg): + """Utility function to return quoted version of command argument.""" + if hasattr(shlex, "quote"): + quote = shlex.quote + elif hasattr(pipes, "quote"): + quote = pipes.quote + else: + + def quote(arg): + arg = arg or "" + re_unsafe = re.compile(r"[^\w@%+=:,./-]") + if re_unsafe.search(arg): + arg = "'" + arg.replace("'", "'\"'\"'") + "'" + return arg + + return quote(arg) + + @staticmethod + def _escape_command_line(cmds): + """Utility function which takes a list of command arguments and returns + escaped and quoted version of the command as a string. + """ + assert isinstance(cmds, list) + # This is identical to shlex.join in Python 3.8. We can + # replace it should we ever get Python 3.8 as a minimum. + quoted_cmd = " ".join([ADBDevice._quote(arg) for arg in cmds]) + + return quoted_cmd + + @staticmethod + def _get_exitcode(file_obj): + """Get the exitcode from the last line of the file_obj for shell + commands executed on Android prior to Android 7. + """ + re_returncode = re.compile(r"adb_returncode=([0-9]+)") + file_obj.seek(0, os.SEEK_END) + + line = "" + length = file_obj.tell() + offset = 1 + while length - offset >= 0: + file_obj.seek(-offset, os.SEEK_END) + char = six.ensure_str(file_obj.read(1)) + if not char: + break + if char != "\r" and char != "\n": + line = char + line + elif line: + # we have collected everything up to the beginning of the line + break + offset += 1 + match = re_returncode.match(line) + if match: + exitcode = int(match.group(1)) + # Set the position in the file to the position of the + # adb_returncode and truncate it from the output. + file_obj.seek(-1, os.SEEK_CUR) + file_obj.truncate() + else: + exitcode = None + # We may have a situation where the adb_returncode= is not + # at the end of the output. This happens at least in the + # failure jit-tests on arm. To work around this + # possibility, we can search the entire output for the + # appropriate match. + file_obj.seek(0, os.SEEK_SET) + for line in file_obj: + line = six.ensure_str(line) + match = re_returncode.search(line) + if match: + exitcode = int(match.group(1)) + break + # Reset the position in the file to the end. + file_obj.seek(0, os.SEEK_END) + + return exitcode + + def is_path_internal_storage(self, path, timeout=None): + """ + Return True if the path matches an internal storage path + as defined by either '/sdcard', '/mnt/sdcard', or any of the + .*_STORAGE environment variables on the device otherwise False. + + :param str path: The path to test. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. This timeout is per adb call. The + total time spent may exceed this value. If it is not + specified, the value set in the ADBDevice constructor is used. + :return: boolean + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + if not self._re_internal_storage: + storage_dirs = set(["/mnt/sdcard", "/sdcard"]) + re_STORAGE = re.compile("([^=]+STORAGE)=(.*)") + lines = self.shell_output("set", timeout=timeout).split() + for line in lines: + m = re_STORAGE.match(line.strip()) + if m and m.group(2): + storage_dirs.add(m.group(2)) + self._re_internal_storage = re.compile("/|".join(list(storage_dirs)) + "/") + return self._re_internal_storage.match(path) is not None + + def is_package_debuggable(self, package): + if not package: + return False + + if not self.is_app_installed(package): + self._logger.warning( + "Can not check if package %s is debuggable as it is not installed." + % package + ) + return False + + if package in self._debuggable_packages: + return self._debuggable_packages[package] + + try: + self.shell_output("run-as %s ls /system" % package) + self._debuggable_packages[package] = True + except ADBError as e: + self._debuggable_packages[package] = False + self._logger.warning("Package %s is not debuggable: %s" % (package, str(e))) + return self._debuggable_packages[package] + + @property + def package_dir(self): + if not self._run_as_package: + return None + # If we have a debuggable app and can use its directory to + # locate the test_root, this returns the location of the app's + # directory. If it is not located in the default location this + # will not be correct. + return "/data/data/%s" % self._run_as_package + + @property + def run_as_package(self): + """Returns the name of the package which will be used in run-as to change + the effective user executing a command.""" + return self._run_as_package + + @run_as_package.setter + def run_as_package(self, value): + if self._have_root_shell or self._have_su or self._have_android_su: + # When we have root available, use that instead of run-as. + return + + if self._run_as_package == value: + # Do nothing if the value doesn't change. + return + + if not value: + if self._test_root: + # Make sure the old test_root is clean without using + # the test_root property getter. + self.rm( + posixpath.join(self._test_root, "*"), recursive=True, force=True + ) + self._logger.info( + "Setting run_as_package to None. Resetting test root from %s to %s" + % (self._test_root, self._initial_test_root) + ) + self._run_as_package = None + # We must set _run_as_package to None before assigning to + # self.test_root in order to prevent attempts to use + # run-as. + self.test_root = self._initial_test_root + if self._test_root: + # Make sure the new test_root is clean. + self.rm( + posixpath.join(self._test_root, "*"), recursive=True, force=True + ) + return + + if not self.is_package_debuggable(value): + self._logger.warning( + "Can not set run_as_package to %s since it is not debuggable." % value + ) + # Since we are attempting to set run_as_package assume + # that we are not rooted and do not include + # /data/local/tmp as an option when checking for possible + # test_root paths using external storage. + paths = [ + "/storage/emulated/0/Android/data/%s/test_root" % value, + "/sdcard/test_root", + "/mnt/sdcard/test_root", + ] + self._try_test_root_candidates(paths) + return + + # Require these devices to have Verify bytecode turned off due to failures with run-as. + include_set = set() + include_set.add("SM-G973F") # Samsung S10g SM-G973F + + if ( + self.get_prop("ro.product.model") in include_set + and self.shell_output("settings get global art_verifier_verify_debuggable") + == "1" + ): + self._logger.warning( + """Your device has Verify bytecode of debuggable apps set which + causes problems attempting to use run-as to delegate command execution to debuggable + apps. You must turn this setting off in Developer options on your device. + """ + ) + raise ADBError( + "Verify bytecode of debuggable apps must be turned off to use run-as" + ) + + self._logger.info("Setting run_as_package to %s" % value) + + self._run_as_package = value + old_test_root = self._test_root + new_test_root = posixpath.join(self.package_dir, "test_root") + if old_test_root != new_test_root: + try: + # Make sure the old test_root is clean. + if old_test_root: + self.rm( + posixpath.join(old_test_root, "*"), recursive=True, force=True + ) + self.test_root = posixpath.join(self.package_dir, "test_root") + # Make sure the new test_root is clean. + self.rm(posixpath.join(self.test_root, "*"), recursive=True, force=True) + except ADBError as e: + # There was a problem using run-as to initialize + # the new test_root in the app's directory. + # Restore the old test root and raise an ADBError. + self._run_as_package = None + self.test_root = old_test_root + self._logger.warning( + "Exception %s setting test_root to %s. " + "Resetting test_root to %s." + % (str(e), new_test_root, old_test_root) + ) + raise ADBError( + "Unable to initialize test root while setting run_as_package %s" + % value + ) + + def enable_run_as_for_path(self, path): + return self._run_as_package is not None and path.startswith(self.package_dir) + + @property + def test_root(self): + """ + The test_root property returns the directory on the device where + temporary test files are stored. + + The first time test_root it is called it determines and caches a value + for the test root on the device. It determines the appropriate test + root by attempting to create a 'proof' directory on each of a list of + directories and returning the first successful directory as the + test_root value. The cached value for the test_root will be shared + by subsequent instances of ADBDevice if self._share_test_root is True. + + The default list of directories checked by test_root are: + + If the device is rooted: + - /data/local/tmp/test_root + + If run_as_package is not available and the device is not rooted: + + - /data/local/tmp/test_root + - /sdcard/test_root + - /storage/sdcard/test_root + - /mnt/sdcard/test_root + + You may override the default list by providing a test_root argument to + the :class:`ADBDevice` constructor which will then be used when + attempting to create the 'proof' directory. + + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + if self._test_root is not None: + self._logger.debug("Using cached test_root %s" % self._test_root) + return self._test_root + + if self.run_as_package is not None: + raise ADBError( + "run_as_package is %s however test_root is None" % self.run_as_package + ) + + if self._share_test_root and _TEST_ROOT: + self._logger.debug( + "Attempting to use shared test_root %s" % self._test_root + ) + paths = [_TEST_ROOT] + elif self._initial_test_root is not None: + self._logger.debug( + "Attempting to use initial test_root %s" % self._test_root + ) + paths = [self._initial_test_root] + else: + # Android 10's scoped storage means we can no longer + # reliably host profiles and tests on the sdcard though it + # depends on the device. See + # https://developer.android.com/training/data-storage#scoped-storage + # Also see RunProgram in + # python/mozbuild/mozbuild/mach_commands.py where they + # choose /data/local/tmp as the default location for the + # profile because GeckoView only takes its configuration + # file from /data/local/tmp. Since we have not specified + # a run_as_package yet, assume we may be attempting to use + # a shell program which creates files owned by the shell + # user and which would work using /data/local/tmp/ even if + # the device is not rooted. Fall back to external storage + # if /data/local/tmp is not available. + paths = ["/data/local/tmp/test_root"] + if not self.is_rooted: + # Note that /sdcard may be accessible while + # /mnt/sdcard is not. + paths.extend( + [ + "/sdcard/test_root", + "/storage/sdcard/test_root", + "/mnt/sdcard/test_root", + ] + ) + + return self._try_test_root_candidates(paths) + + @test_root.setter + def test_root(self, value): + # Cache the requested test root so that + # other invocations of ADBDevice will pick + # up the same value. + global _TEST_ROOT + if self._test_root == value: + return + self._logger.debug("Setting test_root from %s to %s" % (self._test_root, value)) + old_test_root = self._test_root + self._test_root = value + if self._share_test_root: + _TEST_ROOT = value + if not value: + return + if not self._try_test_root(value): + self._test_root = old_test_root + raise ADBError("Unable to set test_root to %s" % value) + readme = posixpath.join(value, "README") + if not self.is_file(readme): + tmpf = tempfile.NamedTemporaryFile(mode="w", delete=False) + tmpf.write( + "This directory is used by mozdevice to contain all content " + "related to running tests on this device.\n" + ) + tmpf.close() + try: + self.push(tmpf.name, readme) + finally: + if tmpf: + os.unlink(tmpf.name) + + def _try_test_root_candidates(self, paths): + max_attempts = 3 + for test_root in paths: + for attempt in range(1, max_attempts + 1): + self._logger.debug( + "Setting test root to %s attempt %d of %d" + % (test_root, attempt, max_attempts) + ) + + if self._try_test_root(test_root): + if not self._test_root: + # Cache the detected test_root so that we can + # restore the value without having re-run + # _try_test_root. + self._initial_test_root = test_root + self._test_root = test_root + self._logger.info("Setting test_root to %s" % self._test_root) + return self._test_root + + self._logger.debug( + "_setup_test_root: " + "Attempt %d of %d failed to set test_root to %s" + % (attempt, max_attempts, test_root) + ) + + if attempt != max_attempts: + time.sleep(20) + + raise ADBError( + "Unable to set up test root using paths: [%s]" % ", ".join(paths) + ) + + def _try_test_root(self, test_root): + try: + if not self.is_dir(test_root): + self.mkdir(test_root, parents=True) + proof_dir = posixpath.join(test_root, "proof") + if self.is_dir(proof_dir): + self.rm(proof_dir, recursive=True) + self.mkdir(proof_dir) + self.rm(proof_dir, recursive=True) + except ADBError as e: + self._logger.warning("%s is not writable: %s" % (test_root, str(e))) + return False + + return True + + # Host Command methods + + def command(self, cmds, timeout=None): + """Executes an adb command on the host against the device. + + :param list cmds: The command and its arguments to be + executed. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. This timeout is per adb call. The + total time spent may exceed this value. If it is not + specified, the value set in the ADBDevice constructor is used. + :return: :class:`ADBProcess` + + command() provides a low level interface for executing + commands for a specific device on the host via adb. + + command() executes on the host in such a fashion that stdout + of the adb process are file handles on the host and + the exit code is available as the exit code of the adb + process. + + For executing shell commands on the device, use + ADBDevice.shell(). The caller provides a list containing + commands, as well as a timeout period in seconds. + + A subprocess is spawned to execute adb for the device with + stdout and stderr directed to a temporary file. If the process + takes longer than the specified timeout, the process is + terminated. + + It is the caller's responsibilty to clean up by closing + the stdout temporary file. + """ + + return ADBCommand.command( + self, cmds, device_serial=self._device_serial, timeout=timeout + ) + + def command_output(self, cmds, timeout=None): + """Executes an adb command on the host against the device returning + stdout. + + :param list cmds: The command and its arguments to be executed. + :param int timeout: The maximum time in seconds + for any spawned adb process to complete before throwing + an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :return: str - content of stdout. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + return ADBCommand.command_output( + self, cmds, device_serial=self._device_serial, timeout=timeout + ) + + # Networking methods + + def _validate_port(self, port, is_local=True): + """Validate a port forwarding specifier. Raises ValueError on failure. + + :param str port: The port specifier to validate + :param bool is_local: Flag indicating whether the port represents a local port. + """ + prefixes = ["tcp", "localabstract", "localreserved", "localfilesystem", "dev"] + + if not is_local: + prefixes += ["jdwp"] + + parts = port.split(":", 1) + if len(parts) != 2 or parts[0] not in prefixes: + raise ValueError("Invalid port specifier %s" % port) + + def _validate_direction(self, direction): + """Validate direction of the socket connection. Raises ValueError on failure. + + :param str direction: The socket direction specifier to validate + :raises: :exc:`ValueError` + """ + if direction not in [ + self.SOCKET_DIRECTION_FORWARD, + self.SOCKET_DIRECTION_REVERSE, + ]: + raise ValueError("Invalid direction specifier {}".format(direction)) + + def create_socket_connection( + self, direction, local, remote, allow_rebind=True, timeout=None + ): + """Sets up a socket connection in the specified direction. + + :param str direction: Direction of the socket connection + :param str local: Local port + :param str remote: Remote port + :param bool allow_rebind: Do not fail if port is already bound + :param int timeout: The maximum time in seconds + for any spawned adb process to complete before throwing + an ADBTimeoutError. If it is not specified, the value + set in the ADBDevice constructor is used. + :return: When forwarding from "tcp:0", an int containing the port number + of the local port assigned by adb, otherwise None. + :raises: :exc:`ValueError` + :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + # validate socket direction, and local and remote port formatting. + self._validate_direction(direction) + for port, is_local in [(local, True), (remote, False)]: + self._validate_port(port, is_local=is_local) + + cmd = [direction, local, remote] + + if not allow_rebind: + cmd.insert(1, "--no-rebind") + + # execute commands to establish socket connection. + cmd_output = self.command_output(cmd, timeout=timeout) + + # If we want to forward using local port "tcp:0", then we're letting + # adb assign the port for us, so we need to return that assignment. + if ( + direction == self.SOCKET_DIRECTION_FORWARD + and local == "tcp:0" + and cmd_output + ): + return int(cmd_output) + + return None + + def list_socket_connections(self, direction, timeout=None): + """Return a list of tuples specifying active socket connectionss. + + Return values are of the form (device, local, remote). + + :param str direction: 'forward' to list forward socket connections + 'reverse' to list reverse socket connections + :param int timeout: The maximum time in seconds + for any spawned adb process to complete before throwing + an ADBTimeoutError. If it is not specified, the value + set in the ADBDevice constructor is used. + :raises: :exc:`ValueError` + :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + self._validate_direction(direction) + + cmd = [direction, "--list"] + output = self.command_output(cmd, timeout=timeout) + return [tuple(line.split(" ")) for line in output.splitlines() if line.strip()] + + def remove_socket_connections(self, direction, local=None, timeout=None): + """Remove existing socket connections for a given direction. + + :param str direction: 'forward' to remove forward socket connection + 'reverse' to remove reverse socket connection + :param str local: local port specifier as for ADBDevice.forward. If local + is not specified removes all forwards. + :param int timeout: The maximum time in seconds + for any spawned adb process to complete before throwing + an ADBTimeoutError. If it is not specified, the value + set in the ADBDevice constructor is used. + :raises: :exc:`ValueError` + :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + self._validate_direction(direction) + + cmd = [direction] + + if local is None: + cmd.extend(["--remove-all"]) + else: + self._validate_port(local, is_local=True) + cmd.extend(["--remove", local]) + + self.command_output(cmd, timeout=timeout) + + # Legacy port forward methods + + def forward(self, local, remote, allow_rebind=True, timeout=None): + """Forward a local port to a specific port on the device. + + :return: When forwarding from "tcp:0", an int containing the port number + of the local port assigned by adb, otherwise None. + + See `ADBDevice.create_socket_connection`. + """ + return self.create_socket_connection( + self.SOCKET_DIRECTION_FORWARD, local, remote, allow_rebind, timeout + ) + + def list_forwards(self, timeout=None): + """Return a list of tuples specifying active forwards. + + See `ADBDevice.list_socket_connection`. + """ + return self.list_socket_connections(self.SOCKET_DIRECTION_FORWARD, timeout) + + def remove_forwards(self, local=None, timeout=None): + """Remove existing port forwards. + + See `ADBDevice.remove_socket_connection`. + """ + self.remove_socket_connections(self.SOCKET_DIRECTION_FORWARD, local, timeout) + + # Legacy port reverse methods + + def reverse(self, local, remote, allow_rebind=True, timeout=None): + """Sets up a reverse socket connection from device to host. + + See `ADBDevice.create_socket_connection`. + """ + self.create_socket_connection( + self.SOCKET_DIRECTION_REVERSE, local, remote, allow_rebind, timeout + ) + + def list_reverses(self, timeout=None): + """Returns a list of tuples showing active reverse socket connections. + + See `ADBDevice.list_socket_connection`. + """ + return self.list_socket_connections(self.SOCKET_DIRECTION_REVERSE, timeout) + + def remove_reverses(self, local=None, timeout=None): + """Remove existing reverse socket connections. + + See `ADBDevice.remove_socket_connection`. + """ + self.remove_socket_connections(self.SOCKET_DIRECTION_REVERSE, local, timeout) + + # Device Shell methods + + def shell( + self, + cmd, + env=None, + cwd=None, + timeout=None, + stdout_callback=None, + yield_stdout=None, + enable_run_as=False, + ): + """Executes a shell command on the device. + + :param str cmd: The command to be executed. + :param dict env: Contains the environment variables and + their values. + :param str cwd: The directory from which to execute. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. This timeout is per adb call. The + total time spent may exceed this value. If it is not + specified, the value set in the ADBDevice constructor is used. + :param function stdout_callback: Function called for each line of output. + :param bool yield_stdout: Flag used to make the returned process + iteratable. The return process can be used in a loop to get the output + and the loop would exit as the process ends. + :param bool enable_run_as: Flag used to temporarily enable use + of run-as to execute the command. + :return: :class:`ADBProcess` + + shell() provides a low level interface for executing commands + on the device via adb shell. + + shell() executes on the host in such as fashion that stdout + contains the stdout and stderr of the host abd process + combined with the stdout and stderr of the shell command + on the device. The exit code of shell() is the exit code of + the adb command if it was non-zero or the extracted exit code + from the output of the shell command executed on the + device. + + The caller provides a flag indicating if the command is to be + executed as root, a string for any requested working + directory, a hash defining the environment, a string + containing shell commands, as well as a timeout period in + seconds. + + The command line to be executed is created to set the current + directory, set the required environment variables, optionally + execute the command using su and to output the return code of + the command to stdout. The command list is created as a + command sequence separated by && which will terminate the + command sequence on the first command which returns a non-zero + exit code. + + A subprocess is spawned to execute adb shell for the device + with stdout and stderr directed to a temporary file. If the + process takes longer than the specified timeout, the process + is terminated. The return code is extracted from the stdout + and is then removed from the file. + + It is the caller's responsibilty to clean up by closing + the stdout temporary files. + + If the yield_stdout flag is set, then the returned ADBProcess + can be iterated over to get the output as it is produced by + adb command. The iterator ends when the process timed out or + if it exited. This flag is incompatible with stdout_callback. + + """ + + def _timed_read_line_handler(signum, frame): + raise IOError("ReadLineTimeout") + + def _timed_read_line(filehandle, timeout=None): + """ + Attempt to readline from filehandle. If readline does not return + within timeout seconds, raise IOError('ReadLineTimeout'). + On Windows, required signal facilities are usually not available; + as a result, the timeout is not respected and some reads may + block on Windows. + """ + if not hasattr(signal, "SIGALRM"): + return filehandle.readline() + if timeout is None: + timeout = 5 + line = "" + default_alarm_handler = signal.getsignal(signal.SIGALRM) + signal.signal(signal.SIGALRM, _timed_read_line_handler) + signal.alarm(int(timeout)) + try: + line = filehandle.readline() + finally: + signal.alarm(0) + signal.signal(signal.SIGALRM, default_alarm_handler) + return line + + first_word = cmd.split(" ")[0] + if first_word in self.BUILTINS: + # Do not attempt to use su or run-as with builtin commands + pass + elif self._have_root_shell: + pass + elif self._have_android_su: + cmd = "su 0 %s" % cmd + elif self._have_su: + cmd = "su -c %s" % ADBDevice._quote(cmd) + elif self._run_as_package and enable_run_as: + cmd = "run-as %s %s" % (self._run_as_package, cmd) + else: + pass + + # prepend cwd and env to command if necessary + if cwd: + cmd = "cd %s && %s" % (cwd, cmd) + if env: + envstr = "&& ".join(["export %s=%s" % (x[0], x[1]) for x in env.items()]) + cmd = envstr + "&& " + cmd + # Before Android 7, an exitcode 0 for the process on the host + # did not mean that the exitcode of the Android process was + # also 0. We therefore used the echo adb_returncode=$? hack to + # obtain it there. However Android 7 and later intermittently + # do not emit the adb_returncode in stdout using this hack. In + # Android 7 and later the exitcode of the host process does + # match the exitcode of the Android process and we can use it + # directly. + if ( + self._device_serial.startswith("emulator") + or not hasattr(self, "version") + or self.version < version_codes.N + ): + cmd += "; echo adb_returncode=$?" + + args = [self._adb_path] + if self._adb_host: + args.extend(["-H", self._adb_host]) + if self._adb_port: + args.extend(["-P", str(self._adb_port)]) + if self._device_serial: + args.extend(["-s", self._device_serial]) + args.extend(["wait-for-device", "shell", cmd]) + + if timeout is None: + timeout = self._timeout + + if yield_stdout: + # When using yield_stdout, rely on the timeout implemented in + # ADBProcess instead of relying on our own here. + assert not stdout_callback + return ADBProcess(args, use_stdout_pipe=yield_stdout, timeout=timeout) + else: + adb_process = ADBProcess(args) + + start_time = time.time() + exitcode = adb_process.proc.poll() + if not stdout_callback: + while ((time.time() - start_time) <= float(timeout)) and exitcode is None: + time.sleep(self._polling_interval) + exitcode = adb_process.proc.poll() + else: + stdout2 = io.open(adb_process.stdout_file.name, "rb") + partial = b"" + while ((time.time() - start_time) <= float(timeout)) and exitcode is None: + try: + line = _timed_read_line(stdout2) + if line and len(line) > 0: + if line.endswith(b"\n") or line.endswith(b"\r"): + line = partial + line + partial = b"" + line = line.rstrip() + if self._verbose: + self._logger.info(six.ensure_str(line)) + stdout_callback(line) + else: + # no more output available now, but more to come? + partial = partial + line + else: + # no new output, so sleep and poll + time.sleep(self._polling_interval) + except IOError: + pass + exitcode = adb_process.proc.poll() + if exitcode is None: + adb_process.proc.kill() + adb_process.timedout = True + adb_process.exitcode = adb_process.proc.poll() + elif exitcode == 0: + if ( + not self._device_serial.startswith("emulator") + and hasattr(self, "version") + and self.version >= version_codes.N + ): + adb_process.exitcode = 0 + else: + adb_process.exitcode = self._get_exitcode(adb_process.stdout_file) + else: + adb_process.exitcode = exitcode + + if stdout_callback: + line = stdout2.readline() + while line: + if line.endswith(b"\n") or line.endswith(b"\r"): + line = partial + line + partial = b"" + stdout_callback(line.rstrip()) + else: + # no more output available now, but more to come? + partial = partial + line + line = stdout2.readline() + if partial: + stdout_callback(partial) + stdout2.close() + + adb_process.stdout_file.seek(0, os.SEEK_SET) + + return adb_process + + def shell_bool(self, cmd, env=None, cwd=None, timeout=None, enable_run_as=False): + """Executes a shell command on the device returning True on success + and False on failure. + + :param str cmd: The command to be executed. + :param dict env: Contains the environment variables and + their values. + :param str cwd: The directory from which to execute. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :param bool enable_run_as: Flag used to temporarily enable use + of run-as to execute the command. + :return: bool + :raises: :exc:`ADBTimeoutError` + """ + adb_process = None + try: + adb_process = self.shell( + cmd, env=env, cwd=cwd, timeout=timeout, enable_run_as=enable_run_as + ) + if adb_process.timedout: + raise ADBTimeoutError("%s" % adb_process) + return adb_process.exitcode == 0 + finally: + if adb_process: + if self._verbose: + output = adb_process.stdout + self._logger.debug( + "shell_bool: %s, " + "timeout: %s, " + "timedout: %s, " + "exitcode: %s, " + "output: %s" + % ( + " ".join(adb_process.args), + timeout, + adb_process.timedout, + adb_process.exitcode, + output, + ) + ) + + adb_process.stdout_file.close() + + def shell_output(self, cmd, env=None, cwd=None, timeout=None, enable_run_as=False): + """Executes an adb shell on the device returning stdout. + + :param str cmd: The command to be executed. + :param dict env: Contains the environment variables and their values. + :param str cwd: The directory from which to execute. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. This timeout is per + adb call. The total time spent may exceed this + value. If it is not specified, the value set + in the ADBDevice constructor is used. + :param bool enable_run_as: Flag used to temporarily enable use + of run-as to execute the command. + :return: str - content of stdout. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + adb_process = None + try: + adb_process = self.shell( + cmd, env=env, cwd=cwd, timeout=timeout, enable_run_as=enable_run_as + ) + if adb_process.timedout: + raise ADBTimeoutError("%s" % adb_process) + if adb_process.exitcode: + raise ADBProcessError(adb_process) + output = adb_process.stdout + if self._verbose: + self._logger.debug( + "shell_output: %s, " + "timeout: %s, " + "timedout: %s, " + "exitcode: %s, " + "output: %s" + % ( + " ".join(adb_process.args), + timeout, + adb_process.timedout, + adb_process.exitcode, + output, + ) + ) + + return output + finally: + if adb_process and isinstance(adb_process.stdout_file, io.IOBase): + adb_process.stdout_file.close() + + # Informational methods + + def _get_logcat_buffer_args(self, buffers): + valid_buffers = set(["radio", "main", "events"]) + invalid_buffers = set(buffers).difference(valid_buffers) + if invalid_buffers: + raise ADBError( + "Invalid logcat buffers %s not in %s " + % (list(invalid_buffers), list(valid_buffers)) + ) + args = [] + for b in buffers: + args.extend(["-b", b]) + return args + + def clear_logcat(self, timeout=None, buffers=[]): + """Clears logcat via adb logcat -c. + + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. This timeout is per + adb call. The total time spent may exceed this + value. If it is not specified, the value set + in the ADBDevice constructor is used. + :param list buffers: Log buffers to clear. Valid buffers are + "radio", "events", and "main". Defaults to "main". + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + buffers = self._get_logcat_buffer_args(buffers) + cmds = ["logcat", "-c"] + buffers + try: + self.command_output(cmds, timeout=timeout) + self.shell_output("log logcat cleared", timeout=timeout) + except ADBTimeoutError: + raise + except ADBProcessError as e: + if "failed to clear" not in str(e): + raise + self._logger.warning( + "retryable logcat clear error?: {}. Retrying...".format(str(e)) + ) + try: + self.command_output(cmds, timeout=timeout) + self.shell_output("log logcat cleared", timeout=timeout) + except ADBProcessError as e2: + if "failed to clear" not in str(e): + raise + self._logger.warning( + "Ignoring failure to clear logcat: {}.".format(str(e2)) + ) + + def get_logcat( + self, + filter_specs=[ + "dalvikvm:I", + "ConnectivityService:S", + "WifiMonitor:S", + "WifiStateTracker:S", + "wpa_supplicant:S", + "NetworkStateTracker:S", + "EmulatedCamera_Camera:S", + "EmulatedCamera_Device:S", + "EmulatedCamera_FakeCamera:S", + "EmulatedCamera_FakeDevice:S", + "EmulatedCamera_CallbackNotifier:S", + "GnssLocationProvider:S", + "Hyphenator:S", + "BatteryStats:S", + ], + format="time", + filter_out_regexps=[], + timeout=None, + buffers=[], + ): + """Returns the contents of the logcat file as a list of strings. + + :param list filter_specs: Optional logcat messages to + be included. + :param str format: Optional logcat format. + :param list filter_out_regexps: Optional logcat messages to be + excluded. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :param list buffers: Log buffers to retrieve. Valid buffers are + "radio", "events", and "main". Defaults to "main". + :return: list of lines logcat output. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + buffers = self._get_logcat_buffer_args(buffers) + cmds = ["logcat", "-v", format, "-d"] + buffers + filter_specs + lines = self.command_output(cmds, timeout=timeout).splitlines() + + for regex in filter_out_regexps: + lines = [line for line in lines if not re.search(regex, line)] + + return lines + + def get_prop(self, prop, timeout=None): + """Gets value of a property from the device via adb shell getprop. + + :param str prop: The propery name. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :return: str value of property. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + output = self.shell_output("getprop %s" % prop, timeout=timeout) + return output + + def get_state(self, timeout=None): + """Returns the device's state via adb get-state. + + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before throwing + an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :return: str value of adb get-state. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + output = self.command_output(["get-state"], timeout=timeout).strip() + return output + + def get_ip_address(self, interfaces=None, timeout=None): + """Returns the device's ip address, or None if it doesn't have one + + :param list interfaces: Interfaces to allow, or None to allow any + non-loopback interface. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before throwing + an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :return: str ip address of the device or None if it could not + be found. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + if not self.is_rooted: + self._logger.warning("Device not rooted. Can not obtain ip address.") + return None + self._logger.debug("get_ip_address: interfaces: %s" % interfaces) + if not interfaces: + interfaces = ["wlan0", "eth0"] + wifi_interface = self.get_prop("wifi.interface", timeout=timeout) + self._logger.debug("get_ip_address: wifi_interface: %s" % wifi_interface) + if wifi_interface and wifi_interface not in interfaces: + interfaces = interfaces.append(wifi_interface) + + # ifconfig interface + # can return two different formats: + # eth0: ip 192.168.1.139 mask 255.255.255.0 flags [up broadcast running multicast] + # or + # wlan0 Link encap:Ethernet HWaddr 00:9A:CD:B8:39:65 + # inet addr:192.168.1.38 Bcast:192.168.1.255 Mask:255.255.255.0 + # inet6 addr: fe80::29a:cdff:feb8:3965/64 Scope: Link + # UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1 + # RX packets:180 errors:0 dropped:0 overruns:0 frame:0 + # TX packets:218 errors:0 dropped:0 overruns:0 carrier:0 + # collisions:0 txqueuelen:1000 + # RX bytes:84577 TX bytes:31202 + + re1_ip = re.compile(r"(\w+): ip ([0-9.]+) mask.*") + # re1_ip will match output of the first format + # with group 1 returning the interface and group 2 returing the ip address. + + # re2_interface will match the interface line in the second format + # while re2_ip will match the inet addr line of the second format. + re2_interface = re.compile(r"(\w+)\s+Link") + re2_ip = re.compile(r"\s+inet addr:([0-9.]+)") + + matched_interface = None + matched_ip = None + re_bad_addr = re.compile(r"127.0.0.1|0.0.0.0") + + self._logger.debug("get_ip_address: ifconfig") + for interface in interfaces: + try: + output = self.shell_output("ifconfig %s" % interface, timeout=timeout) + except ADBError as e: + self._logger.warning( + "get_ip_address ifconfig %s: %s" % (interface, str(e)) + ) + output = "" + + for line in output.splitlines(): + if not matched_interface: + match = re1_ip.match(line) + if match: + matched_interface, matched_ip = match.groups() + else: + match = re2_interface.match(line) + if match: + matched_interface = match.group(1) + else: + match = re2_ip.match(line) + if match: + matched_ip = match.group(1) + + if matched_ip: + if not re_bad_addr.match(matched_ip): + self._logger.debug( + "get_ip_address: found: %s %s" + % (matched_interface, matched_ip) + ) + return matched_ip + matched_interface = None + matched_ip = None + + self._logger.debug("get_ip_address: netcfg") + # Fall back on netcfg if ifconfig does not work. + # $ adb shell netcfg + # lo UP 127.0.0.1/8 0x00000049 00:00:00:00:00:00 + # dummy0 DOWN 0.0.0.0/0 0x00000082 8e:cd:67:48:b7:c2 + # rmnet0 DOWN 0.0.0.0/0 0x00000000 00:00:00:00:00:00 + # rmnet1 DOWN 0.0.0.0/0 0x00000000 00:00:00:00:00:00 + # rmnet2 DOWN 0.0.0.0/0 0x00000000 00:00:00:00:00:00 + # rmnet3 DOWN 0.0.0.0/0 0x00000000 00:00:00:00:00:00 + # rmnet4 DOWN 0.0.0.0/0 0x00000000 00:00:00:00:00:00 + # rmnet5 DOWN 0.0.0.0/0 0x00000000 00:00:00:00:00:00 + # rmnet6 DOWN 0.0.0.0/0 0x00000000 00:00:00:00:00:00 + # rmnet7 DOWN 0.0.0.0/0 0x00000000 00:00:00:00:00:00 + # sit0 DOWN 0.0.0.0/0 0x00000080 00:00:00:00:00:00 + # vip0 DOWN 0.0.0.0/0 0x00001012 00:01:00:00:00:01 + # wlan0 UP 192.168.1.157/24 0x00001043 38:aa:3c:1c:f6:94 + + re3_netcfg = re.compile( + r"(\w+)\s+UP\s+([1-9]\d{0,2}\.\d{1,3}\.\d{1,3}\.\d{1,3})" + ) + try: + output = self.shell_output("netcfg", timeout=timeout) + except ADBError as e: + self._logger.warning("get_ip_address netcfg: %s" % str(e)) + output = "" + for line in output.splitlines(): + match = re3_netcfg.search(line) + if match: + matched_interface, matched_ip = match.groups() + if matched_interface == "lo" or re_bad_addr.match(matched_ip): + matched_interface = None + matched_ip = None + elif matched_ip and matched_interface in interfaces: + self._logger.debug( + "get_ip_address: found: %s %s" % (matched_interface, matched_ip) + ) + return matched_ip + self._logger.debug("get_ip_address: not found") + return matched_ip + + # File management methods + + def remount(self, timeout=None): + """Remount /system/ in read/write mode + + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before throwing + an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + + rv = self.command_output(["remount"], timeout=timeout) + if "remount succeeded" not in rv: + raise ADBError("Unable to remount device") + + def batch_execute(self, commands, timeout=None, enable_run_as=False): + """Writes commands to a temporary file then executes on the device. + + :param list commands_list: List of commands to be run by the shell. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before throwing + an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :param bool enable_run_as: Flag used to temporarily enable use + of run-as to execute the command. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + try: + tmpf = tempfile.NamedTemporaryFile(mode="w", delete=False) + tmpf.write("\n".join(commands)) + tmpf.close() + script = "/sdcard/{}".format(os.path.basename(tmpf.name)) + self.push(tmpf.name, script) + self.shell_output( + "sh {}".format(script), enable_run_as=enable_run_as, timeout=timeout + ) + finally: + if tmpf: + os.unlink(tmpf.name) + if script: + self.rm(script, timeout=timeout) + + def chmod(self, path, recursive=False, mask="777", timeout=None): + """Recursively changes the permissions of a directory on the + device. + + :param str path: The directory name on the device. + :param bool recursive: Flag specifying if the command should be + executed recursively. + :param str mask: The octal permissions. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before throwing + an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + # Note that on some tests such as webappstartup, an error + # occurs during recursive calls to chmod where a "No such file + # or directory" error will occur for the + # /data/data/org.mozilla.fennec/files/mozilla/*.webapp0/lock + # which is a symbolic link to a socket: lock -> + # 127.0.0.1:+<port>. On Linux, chmod -R ignores symbolic + # links but it appear Android's version does not. We ignore + # this type of error, but pass on any other errors that are + # detected. + path = posixpath.normpath(path.strip()) + enable_run_as = self.enable_run_as_for_path(path) + self._logger.debug( + "chmod: path=%s, recursive=%s, mask=%s" % (path, recursive, mask) + ) + if self.is_path_internal_storage(path, timeout=timeout): + # External storage on Android is case-insensitive and permissionless + # therefore even with the proper privileges it is not possible + # to change modes. + self._logger.debug("Ignoring attempt to chmod external storage") + return + + # build up the command to be run based on capabilities. + command = ["chmod"] + + if recursive and self._chmod_R: + command.append("-R") + + command.append(mask) + + if recursive and not self._chmod_R: + paths = self.ls(path, recursive=True, timeout=timeout) + base = " ".join(command) + commands = [" ".join([base, entry]) for entry in paths] + self.batch_execute(commands, timeout=timeout, enable_run_as=enable_run_as) + else: + command.append(path) + try: + self.shell_output( + cmd=" ".join(command), timeout=timeout, enable_run_as=enable_run_as + ) + except ADBProcessError as e: + if "No such file or directory" not in str(e): + # It appears that chmod -R with symbolic links will exit with + # exit code 1 but the files apart from the symbolic links + # were transfered. + raise + + def chown(self, path, owner, group=None, recursive=False, timeout=None): + """Run the chown command on the provided path. + + :param str path: path name on the device. + :param str owner: new owner of the path. + :param str group: optional parameter specifying the new group the path + should belong to. + :param bool recursive: optional value specifying whether the command should + operate on files and directories recursively. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before throwing + an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + path = posixpath.normpath(path.strip()) + enable_run_as = self.enable_run_as_for_path(path) + if self.is_path_internal_storage(path, timeout=timeout): + self._logger.warning("Ignoring attempt to chown external storage") + return + + # build up the command to be run based on capabilities. + command = ["chown"] + + if recursive and self._chown_R: + command.append("-R") + + if group: + # officially supported notation is : but . has been checked with + # sdk 17 and it works. + command.append("{owner}.{group}".format(owner=owner, group=group)) + else: + command.append(owner) + + if recursive and not self._chown_R: + # recursive desired, but chown -R is not supported natively. + # like with chmod, get the list of subpaths, put them into a script + # then run it with adb with one call. + paths = self.ls(path, recursive=True, timeout=timeout) + base = " ".join(command) + commands = [" ".join([base, entry]) for entry in paths] + + self.batch_execute(commands, timeout=timeout, enable_run_as=enable_run_as) + else: + # recursive or not, and chown -R is supported natively. + # command can simply be run as provided by the user. + command.append(path) + self.shell_output( + cmd=" ".join(command), timeout=timeout, enable_run_as=enable_run_as + ) + + def _test_path(self, argument, path, timeout=None): + """Performs path and file type checking. + + :param str argument: Command line argument to the test command. + :param str path: The path or filename on the device. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :param bool root: Flag specifying if the command should be + executed as root. + :return: boolean - True if path or filename fulfills the + condition of the test. + :raises: :exc:`ADBTimeoutError` + """ + enable_run_as = self.enable_run_as_for_path(path) + if not enable_run_as and not self._device_serial.startswith("emulator"): + return self.shell_bool( + "test -{arg} {path}".format(arg=argument, path=path), + timeout=timeout, + enable_run_as=False, + ) + # Bug 1572563 - work around intermittent test path failures on emulators. + # The shell built-in test is not supported via run-as. + if argument == "f": + return self.exists(path, timeout=timeout) and not self.is_dir( + path, timeout=timeout + ) + if argument == "d": + return self.shell_bool( + "ls -a {}/".format(path), timeout=timeout, enable_run_as=enable_run_as + ) + if argument == "e": + return self.shell_bool( + "ls -a {}".format(path), timeout=timeout, enable_run_as=enable_run_as + ) + raise ADBError("_test_path: Unknown argument %s" % argument) + + def exists(self, path, timeout=None): + """Returns True if the path exists on the device. + + :param str path: The path name on the device. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :param bool root: Flag specifying if the command should be + executed as root. + :return: boolean - True if path exists. + :raises: :exc:`ADBTimeoutError` + """ + path = posixpath.normpath(path) + return self._test_path("e", path, timeout=timeout) + + def is_dir(self, path, timeout=None): + """Returns True if path is an existing directory on the device. + + :param str path: The directory on the device. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :return: boolean - True if path exists on the device and is a + directory. + :raises: :exc:`ADBTimeoutError` + """ + path = posixpath.normpath(path) + return self._test_path("d", path, timeout=timeout) + + def is_file(self, path, timeout=None): + """Returns True if path is an existing file on the device. + + :param str path: The file name on the device. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :return: boolean - True if path exists on the device and is a + file. + :raises: :exc:`ADBTimeoutError` + """ + path = posixpath.normpath(path) + return self._test_path("f", path, timeout=timeout) + + def list_files(self, path, timeout=None): + """Return a list of files/directories contained in a directory + on the device. + + :param str path: The directory name on the device. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :return: list of files/directories contained in the directory. + :raises: :exc:`ADBTimeoutError` + """ + path = posixpath.normpath(path.strip()) + enable_run_as = self.enable_run_as_for_path(path) + data = [] + if self.is_dir(path, timeout=timeout): + try: + data = self.shell_output( + "%s %s" % (self._ls, path), + timeout=timeout, + enable_run_as=enable_run_as, + ).splitlines() + self._logger.debug("list_files: data: %s" % data) + except ADBError: + self._logger.error( + "Ignoring exception in ADBDevice.list_files\n%s" + % traceback.format_exc() + ) + data[:] = [item for item in data if item] + self._logger.debug("list_files: %s" % data) + return data + + def ls(self, path, recursive=False, timeout=None): + """Return a list of matching files/directories on the device. + + The ls method emulates the behavior of the ls shell command. + It differs from the list_files method by supporting wild cards + and returning matches even if the path is not a directory and + by allowing a recursive listing. + + ls /sdcard always returns /sdcard and not the contents of the + sdcard path. The ls method makes the behavior consistent with + others paths by adjusting /sdcard to /sdcard/. Note this is + also the case of other sdcard related paths such as + /storage/emulated/legacy but no adjustment is made in those + cases. + + The ls method works around a Nexus 4 bug which prevents + recursive listing of directories on the sdcard unless the path + ends with "/*" by adjusting sdcard paths ending in "/" to end + with "/*". This adjustment is only made on official Nexus 4 + builds with property ro.product.model "Nexus 4". Note that + this will fail to return any "hidden" files or directories + which begin with ".". + + :param str path: The directory name on the device. + :param bool recursive: Flag specifying if a recursive listing + is to be returned. If recursive is False, the returned + matches will be relative to the path. If recursive is True, + the returned matches will be absolute paths. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :return: list of files/directories contained in the directory. + :raises: :exc:`ADBTimeoutError` + """ + path = posixpath.normpath(path.strip()) + enable_run_as = self.enable_run_as_for_path(path) + parent = "" + entries = {} + + if path == "/sdcard": + path += "/" + + # Android 2.3 and later all appear to support ls -R however + # Nexus 4 does not perform a recursive search on the sdcard + # unless the path is a directory with * wild card. + if not recursive: + recursive_flag = "" + else: + recursive_flag = "-R" + if path.startswith("/sdcard") and path.endswith("/"): + model = self.get_prop("ro.product.model", timeout=timeout) + if model == "Nexus 4": + path += "*" + lines = self.shell_output( + "%s %s %s" % (self._ls, recursive_flag, path), + timeout=timeout, + enable_run_as=enable_run_as, + ).splitlines() + for line in lines: + line = line.strip() + if not line: + parent = "" + continue + if line.endswith(":"): # This is a directory + parent = line.replace(":", "/") + entry = parent + # Remove earlier entry which is marked as a file. + if parent[:-1] in entries: + del entries[parent[:-1]] + elif parent: + entry = "%s%s" % (parent, line) + else: + entry = line + entries[entry] = 1 + entry_list = list(entries.keys()) + entry_list.sort() + return entry_list + + def mkdir(self, path, parents=False, timeout=None): + """Create a directory on the device. + + :param str path: The directory name on the device + to be created. + :param bool parents: Flag indicating if the parent directories are + also to be created. Think mkdir -p path. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + + def verify_mkdir(path): + # Verify that the directory was actually created. On some devices + # (x86_64 emulator, v 29.0.11) the directory is sometimes not + # immediately visible, so retries are allowed. + retry = 0 + while retry < 10: + if self.is_dir(path, timeout=timeout): + return True + time.sleep(1) + retry += 1 + return False + + self._sync(timeout=timeout) + + path = posixpath.normpath(path) + enable_run_as = self.enable_run_as_for_path(path) + if parents: + if self._mkdir_p is None or self._mkdir_p: + # Use shell_bool to catch the possible + # non-zero exitcode if -p is not supported. + if self.shell_bool( + "mkdir -p %s" % path, timeout=timeout, enable_run_as=enable_run_as + ) or verify_mkdir(path): + self.chmod(path, recursive=True, timeout=timeout) + self._mkdir_p = True + self._sync(timeout=timeout) + return + # mkdir -p is not supported. create the parent + # directories individually. + if not self.is_dir(posixpath.dirname(path)): + parts = path.split("/") + name = "/" + for part in parts[:-1]: + if part != "": + name = posixpath.join(name, part) + if not self.is_dir(name): + # Use shell_output to allow any non-zero + # exitcode to raise an ADBError. + self.shell_output( + "mkdir %s" % name, + timeout=timeout, + enable_run_as=enable_run_as, + ) + self.chmod(name, recursive=True, timeout=timeout) + self._sync(timeout=timeout) + + # If parents is True and the directory does exist, we don't + # need to do anything. Otherwise we call mkdir. If the + # directory already exists or if it is a file instead of a + # directory, mkdir will fail and we will raise an ADBError. + if not parents or not self.is_dir(path): + self.shell_output( + "mkdir %s" % path, timeout=timeout, enable_run_as=enable_run_as + ) + self._sync(timeout=timeout) + self.chmod(path, recursive=True, timeout=timeout) + if not verify_mkdir(path): + raise ADBError("mkdir %s Failed" % path) + + def push(self, local, remote, timeout=None): + """Pushes a file or directory to the device. + + :param str local: The name of the local file or + directory name. + :param str remote: The name of the remote file or + directory name. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + self._sync(timeout=timeout) + + # remove trailing / + local = os.path.normpath(local) + remote = posixpath.normpath(remote) + copy_required = False + sdcard_remote = None + if os.path.isfile(local) and self.is_dir(remote): + # force push to use the correct filename in the remote directory + remote = posixpath.join(remote, os.path.basename(local)) + elif os.path.isdir(local): + copy_required = True + temp_parent = tempfile.mkdtemp() + remote_name = os.path.basename(remote) + new_local = os.path.join(temp_parent, remote_name) + copytree(local, new_local) + local = new_local + # See do_sync_push in + # https://android.googlesource.com/platform/system/core/+/master/adb/file_sync_client.cpp + # Work around change in behavior in adb 1.0.36 where if + # the remote destination directory exists, adb push will + # copy the source directory *into* the destination + # directory otherwise it will copy the source directory + # *onto* the destination directory. + if self.is_dir(remote): + remote = "/".join(remote.rstrip("/").split("/")[:-1]) + try: + if not self._run_as_package: + self.command_output(["push", local, remote], timeout=timeout) + self.chmod(remote, recursive=True, timeout=timeout) + else: + # When using run-as to work around the lack of root on a + # device, we can not push directly to the app's + # internal storage since the shell user under which + # the push runs does not have permission to write to + # the app's directory. Instead, we use a two stage + # operation where we first push to a temporary + # intermediate location under /data/local/tmp which + # should be writable by the shell user, then using + # run-as, copy the data into the app's internal + # storage. + try: + with tempfile.NamedTemporaryFile(delete=True) as tmpf: + intermediate = posixpath.join( + "/data/local/tmp", os.path.basename(tmpf.name) + ) + self.command_output(["push", local, intermediate], timeout=timeout) + self.chmod(intermediate, recursive=True, timeout=timeout) + parent_dir = posixpath.dirname(remote) + if not self.is_dir(parent_dir, timeout=timeout): + self.mkdir(parent_dir, parents=True, timeout=timeout) + self.cp(intermediate, remote, recursive=True, timeout=timeout) + finally: + self.rm(intermediate, recursive=True, force=True, timeout=timeout) + except ADBProcessError as e: + if "remote secure_mkdirs failed" not in str(e): + raise + self._logger.warning( + "remote secure_mkdirs failed push('{}', '{}') {}".format( + local, remote, str(e) + ) + ) + # Work around change in Android where push creates + # directories which can not be written by "other" by first + # pushing the source to the sdcard which has no + # permissions issues, then moving it from the sdcard to + # the final destination. + self._logger.info("Falling back to using intermediate /sdcard in push.") + self.mkdir(posixpath.dirname(remote), parents=True, timeout=timeout) + with tempfile.NamedTemporaryFile(delete=True) as tmpf: + sdcard_remote = posixpath.join("/sdcard", os.path.basename(tmpf.name)) + self.command_output(["push", local, sdcard_remote], timeout=timeout) + self.cp(sdcard_remote, remote, recursive=True, timeout=timeout) + except BaseException: + raise + finally: + self._sync(timeout=timeout) + if copy_required: + shutil.rmtree(temp_parent) + if sdcard_remote: + self.rm(sdcard_remote, recursive=True, force=True, timeout=timeout) + + def pull(self, remote, local, timeout=None): + """Pulls a file or directory from the device. + + :param str remote: The path of the remote file or + directory. + :param str local: The path of the local file or + directory name. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + self._sync(timeout=timeout) + + # remove trailing / + local = os.path.normpath(local) + remote = posixpath.normpath(remote) + copy_required = False + original_local = local + if os.path.isdir(local) and self.is_dir(remote): + # See do_sync_pull in + # https://android.googlesource.com/platform/system/core/+/master/adb/file_sync_client.cpp + # Work around change in behavior in adb 1.0.36 where if + # the local destination directory exists, adb pull will + # copy the source directory *into* the destination + # directory otherwise it will copy the source directory + # *onto* the destination directory. + # + # If the destination directory does exist, pull to its + # parent directory. If the source and destination leaf + # directory names are different, pull the source directory + # into a temporary directory and then copy the temporary + # directory onto the destination. + local_name = os.path.basename(local) + remote_name = os.path.basename(remote) + if local_name != remote_name: + copy_required = True + temp_parent = tempfile.mkdtemp() + local = os.path.join(temp_parent, remote_name) + else: + local = "/".join(local.rstrip("/").split("/")[:-1]) + try: + if not self._run_as_package: + # We must first make the remote directory readable. + self.chmod(remote, recursive=True, timeout=timeout) + self.command_output(["pull", remote, local], timeout=timeout) + else: + # When using run-as to work around the lack of root on + # a device, we can not pull directly from the apps + # internal storage since the shell user under which + # the pull runs does not have permission to read from + # the app's directory. Instead, we use a two stage + # operation where we first use run-as to copy the data + # from the app's internal storage to a temporary + # intermediate location under /data/local/tmp which + # should be writable by the shell user, then using + # pull, to copy the data off of the device. + try: + with tempfile.NamedTemporaryFile(delete=True) as tmpf: + intermediate = posixpath.join( + "/data/local/tmp", os.path.basename(tmpf.name) + ) + # When using run-as <app>, we must first use the + # shell to create the intermediate and chmod it + # before the app will be able to access it. + if self.is_dir(remote, timeout=timeout): + self.mkdir( + posixpath.join(intermediate, remote_name), + parents=True, + timeout=timeout, + ) + else: + self.shell_output("echo > %s" % intermediate, timeout=timeout) + self.chmod(intermediate, timeout=timeout) + self.cp(remote, intermediate, recursive=True, timeout=timeout) + self.command_output(["pull", intermediate, local], timeout=timeout) + except ADBError as e: + self._logger.error("pull %s %s: %s" % (intermediate, local, str(e))) + finally: + self.rm(intermediate, recursive=True, force=True, timeout=timeout) + finally: + if copy_required: + copytree(local, original_local, dirs_exist_ok=True) + shutil.rmtree(temp_parent) + + def get_file(self, remote, offset=None, length=None, timeout=None): + """Pull file from device and return the file's content + + :param str remote: The path of the remote file. + :param offset: If specified, return only content beyond this offset. + :param length: If specified, limit content length accordingly. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + self._sync(timeout=timeout) + + with tempfile.NamedTemporaryFile() as tf: + self.pull(remote, tf.name, timeout=timeout) + with io.open(tf.name, mode="rb") as tf2: + # ADB pull does not support offset and length, but we can + # instead read only the requested portion of the local file + if offset is not None and length is not None: + tf2.seek(offset) + return tf2.read(length) + if offset is not None: + tf2.seek(offset) + return tf2.read() + return tf2.read() + + def rm(self, path, recursive=False, force=False, timeout=None): + """Delete files or directories on the device. + + :param str path: The path of the remote file or directory. + :param bool recursive: Flag specifying if the command is + to be applied recursively to the target. Default is False. + :param bool force: Flag which if True will not raise an + error when attempting to delete a non-existent file. Default + is False. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + path = posixpath.normpath(path) + enable_run_as = self.enable_run_as_for_path(path) + self._sync(timeout=timeout) + + cmd = "rm" + if recursive: + cmd += " -r" + try: + self.shell_output( + "%s %s" % (cmd, path), timeout=timeout, enable_run_as=enable_run_as + ) + self._sync(timeout=timeout) + if self.exists(path, timeout=timeout): + raise ADBError('rm("%s") failed to remove path.' % path) + except ADBError as e: + if not force and "No such file or directory" in str(e): + raise + if "Directory not empty" in str(e): + raise + if self._verbose and "No such file or directory" not in str(e): + self._logger.error( + "rm %s recursive=%s force=%s timeout=%s enable_run_as=%s: %s" + % (path, recursive, force, timeout, enable_run_as, str(e)) + ) + + def rmdir(self, path, timeout=None): + """Delete empty directory on the device. + + :param str path: The directory name on the device. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + path = posixpath.normpath(path) + enable_run_as = self.enable_run_as_for_path(path) + self.shell_output( + "rmdir %s" % path, timeout=timeout, enable_run_as=enable_run_as + ) + self._sync(timeout=timeout) + if self.is_dir(path, timeout=timeout): + raise ADBError('rmdir("%s") failed to remove directory.' % path) + + # Process management methods + + def get_process_list(self, timeout=None): + """Returns list of tuples (pid, name, user) for running + processes on device. + + :param int timeout: The maximum time + in seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, + the value set in the ADBDevice constructor is used. + :return: list of (pid, name, user) tuples for running processes + on the device. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + adb_process = None + max_attempts = 2 + try: + for attempt in range(1, max_attempts + 1): + adb_process = self.shell("ps", timeout=timeout) + if adb_process.timedout: + raise ADBTimeoutError("%s" % adb_process) + if adb_process.exitcode: + raise ADBProcessError(adb_process) + # first line is the headers + header = six.ensure_str(adb_process.stdout_file.readline()) + pid_i = -1 + user_i = -1 + els = header.split() + for i in range(len(els)): + item = els[i].lower() + if item == "user": + user_i = i + elif item == "pid": + pid_i = i + if user_i != -1 and pid_i != -1: + break + # if this isn't the final attempt, don't print this as an error + if attempt < max_attempts: + self._logger.info( + "get_process_list: attempt: %d %s" % (attempt, header) + ) + else: + raise ADBError( + "get_process_list: Unknown format: %s: %s" + % (header, adb_process) + ) + ret = [] + line = six.ensure_str(adb_process.stdout_file.readline()) + while line: + els = line.split() + try: + ret.append([int(els[pid_i]), els[-1], els[user_i]]) + except ValueError: + self._logger.error( + "get_process_list: %s %s\n%s" + % (header, line, traceback.format_exc()) + ) + raise ADBError( + "get_process_list: %s: %s: %s" % (header, line, adb_process) + ) + except IndexError: + self._logger.error( + "get_process_list: %s %s els %s pid_i %s user_i %s\n%s" + % (header, line, els, pid_i, user_i, traceback.format_exc()) + ) + raise ADBError( + "get_process_list: %s: %s els %s pid_i %s user_i %s: %s" + % (header, line, els, pid_i, user_i, adb_process) + ) + line = six.ensure_str(adb_process.stdout_file.readline()) + self._logger.debug("get_process_list: %s" % ret) + return ret + finally: + if adb_process and isinstance(adb_process.stdout_file, io.IOBase): + adb_process.stdout_file.close() + + def kill(self, pids, sig=None, attempts=3, wait=5, timeout=None): + """Kills processes on the device given a list of process ids. + + :param list pids: process ids to be killed. + :param int sig: signal to be sent to the process. + :param integer attempts: number of attempts to try to + kill the processes. + :param integer wait: number of seconds to wait after each attempt. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + pid_list = [str(pid) for pid in pids] + for attempt in range(attempts): + args = ["kill"] + if sig: + args.append("-%d" % sig) + args.extend(pid_list) + try: + self.shell_output(" ".join(args), timeout=timeout) + except ADBError as e: + if "No such process" not in str(e): + raise + pid_set = set(pid_list) + current_pid_set = set( + [str(proc[0]) for proc in self.get_process_list(timeout=timeout)] + ) + pid_list = list(pid_set.intersection(current_pid_set)) + if not pid_list: + break + self._logger.debug( + "Attempt %d of %d to kill processes %s failed" + % (attempt + 1, attempts, pid_list) + ) + time.sleep(wait) + + if pid_list: + raise ADBError("kill: processes %s not killed" % pid_list) + + def pkill(self, appname, sig=None, attempts=3, wait=5, timeout=None): + """Kills a processes on the device matching a name. + + :param str appname: The app name of the process to + be killed. Note that only the first 75 characters of the + process name are significant. + :param int sig: optional signal to be sent to the process. + :param integer attempts: number of attempts to try to + kill the processes. + :param integer wait: number of seconds to wait after each attempt. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :param bool root: Flag specifying if the command should + be executed as root. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + pids = self.pidof(appname, timeout=timeout) + + if not pids: + return + + try: + self.kill(pids, sig, attempts=attempts, wait=wait, timeout=timeout) + except ADBError as e: + if self.process_exist(appname, timeout=timeout): + raise e + + def process_exist(self, process_name, timeout=None): + """Returns True if process with name process_name is running on + device. + + :param str process_name: The name of the process + to check. Note that only the first 75 characters of the + process name are significant. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :return: boolean - True if process exists. + + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + if not isinstance(process_name, six.string_types): + raise ADBError("Process name %s is not a string" % process_name) + + # Filter out extra spaces. + parts = [x for x in process_name.split(" ") if x != ""] + process_name = " ".join(parts) + + # Filter out the quoted env string if it exists + # ex: '"name=value;name2=value2;etc=..." process args' -> 'process args' + parts = process_name.split('"') + if len(parts) > 2: + process_name = " ".join(parts[2:]).strip() + + pieces = process_name.split(" ") + parts = pieces[0].split("/") + app = parts[-1] + + if self.pidof(app, timeout=timeout): + return True + return False + + def cp(self, source, destination, recursive=False, timeout=None): + """Copies a file or directory on the device. + + :param source: string containing the path of the source file or + directory. + :param destination: string containing the path of the destination file + or directory. + :param recursive: optional boolean indicating if a recursive copy is to + be performed. Required if the source is a directory. Defaults to + False. Think cp -R source destination. + :param int timeout: optional integer specifying the maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + source = posixpath.normpath(source) + destination = posixpath.normpath(destination) + enable_run_as = self.enable_run_as_for_path( + source + ) or self.enable_run_as_for_path(destination) + if self._have_cp: + r = "-R" if recursive else "" + self.shell_output( + "cp %s %s %s" % (r, source, destination), + timeout=timeout, + enable_run_as=enable_run_as, + ) + self.chmod(destination, recursive=recursive, timeout=timeout) + self._sync(timeout=timeout) + return + + # Emulate cp behavior depending on if source and destination + # already exists and whether they are a directory or file. + if not self.exists(source, timeout=timeout): + raise ADBError("cp: can't stat '%s': No such file or directory" % source) + + if self.is_file(source, timeout=timeout): + if self.is_dir(destination, timeout=timeout): + # Copy the source file into the destination directory + destination = posixpath.join(destination, os.path.basename(source)) + self.shell_output("dd if=%s of=%s" % (source, destination), timeout=timeout) + self.chmod(destination, recursive=recursive, timeout=timeout) + self._sync(timeout=timeout) + return + + if self.is_file(destination, timeout=timeout): + raise ADBError("cp: %s: Not a directory" % destination) + + if not recursive: + raise ADBError("cp: omitting directory '%s'" % source) + + if self.is_dir(destination, timeout=timeout): + # Copy the source directory into the destination directory. + destination_dir = posixpath.join(destination, os.path.basename(source)) + else: + # Copy the contents of the source directory into the + # destination directory. + destination_dir = destination + + try: + # Do not create parent directories since cp does not. + self.mkdir(destination_dir, timeout=timeout) + except ADBError as e: + if "File exists" not in str(e): + raise + + for i in self.list_files(source, timeout=timeout): + self.cp( + posixpath.join(source, i), + posixpath.join(destination_dir, i), + recursive=recursive, + timeout=timeout, + ) + self.chmod(destination_dir, recursive=True, timeout=timeout) + + def mv(self, source, destination, timeout=None): + """Moves a file or directory on the device. + + :param source: string containing the path of the source file or + directory. + :param destination: string containing the path of the destination file + or directory. + :param int timeout: optional integer specifying the maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + source = posixpath.normpath(source) + destination = posixpath.normpath(destination) + enable_run_as = self.enable_run_as_for_path( + source + ) or self.enable_run_as_for_path(destination) + self.shell_output( + "mv %s %s" % (source, destination), + timeout=timeout, + enable_run_as=enable_run_as, + ) + + def reboot(self, timeout=None): + """Reboots the device. + + :param int timeout: optional integer specifying the maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADB constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + + reboot() reboots the device, issues an adb wait-for-device in order to + wait for the device to complete rebooting, then calls is_device_ready() + to determine if the device has completed booting. + + If the device supports running adbd as root, adbd will be + restarted running as root. Then, if the device supports + SELinux, setenforce Permissive will be called to change + SELinux to permissive. This must be done after adbd is + restarted in order for the SELinux Permissive setting to + persist. + + """ + self.command_output(["reboot"], timeout=timeout) + self._wait_for_boot_completed(timeout=timeout) + return self.is_device_ready(timeout=timeout) + + def get_sysinfo(self, timeout=None): + """ + Returns a detailed dictionary of information strings about the device. + + :param int timeout: optional integer specifying the maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADB constructor is used. + + :raises: :exc:`ADBTimeoutError` + """ + results = {"info": self.get_info(timeout=timeout)} + for service in ( + "meminfo", + "cpuinfo", + "dbinfo", + "procstats", + "usagestats", + "battery", + "batterystats", + "diskstats", + ): + results[service] = self.shell_output( + "dumpsys %s" % service, timeout=timeout + ) + return results + + def get_info(self, directive=None, timeout=None): + """ + Returns a dictionary of information strings about the device. + + :param directive: information you want to get. Options are: + - `battery` - battery charge as a percentage + - `disk` - total, free, available bytes on disk + - `id` - unique id of the device + - `os` - name of the os + - `process` - list of running processes (same as ps) + - `systime` - system time of the device + - `uptime` - uptime of the device + + If `directive` is `None`, will return all available information + :param int timeout: optional integer specifying the maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADB constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + directives = ["battery", "disk", "id", "os", "process", "systime", "uptime"] + + if directive in directives: + directives = [directive] + + info = {} + if "battery" in directives: + info["battery"] = self.get_battery_percentage(timeout=timeout) + if "disk" in directives: + info["disk"] = self.shell_output( + "df /data /system /sdcard", timeout=timeout + ).splitlines() + if "id" in directives: + info["id"] = self.command_output(["get-serialno"], timeout=timeout) + if "os" in directives: + info["os"] = self.get_prop("ro.build.display.id", timeout=timeout) + if "process" in directives: + ps = self.shell_output("ps", timeout=timeout) + info["process"] = ps.splitlines() + if "systime" in directives: + info["systime"] = self.shell_output("date", timeout=timeout) + if "uptime" in directives: + uptime = self.shell_output("uptime", timeout=timeout) + if uptime: + m = re.match(r"up time: ((\d+) days, )*(\d{2}):(\d{2}):(\d{2})", uptime) + if m: + uptime = "%d days %d hours %d minutes %d seconds" % tuple( + [int(g or 0) for g in m.groups()[1:]] + ) + info["uptime"] = uptime + return info + + # Properties to manage SELinux on the device: + # https://source.android.com/devices/tech/security/selinux/index.html + # setenforce [ Enforcing | Permissive | 1 | 0 ] + # getenforce returns either Enforcing or Permissive + + @property + def selinux(self): + """Returns True if SELinux is supported, False otherwise.""" + if self._selinux is None: + self._selinux = self.enforcing != "" + return self._selinux + + @property + def enforcing(self): + try: + enforce = self.shell_output("getenforce") + except ADBError as e: + enforce = "" + self._logger.warning("Unable to get SELinux enforcing due to %s." % e) + return enforce + + @enforcing.setter + def enforcing(self, value): + """Set SELinux mode. + :param str value: The new SELinux mode. Should be one of + Permissive, 0, Enforcing, 1 but it is not validated. + """ + try: + self.shell_output("setenforce %s" % value) + self._logger.info("Setting SELinux %s" % value) + except ADBError as e: + self._logger.warning("Unable to set SELinux Permissive due to %s." % e) + + # Informational methods + + def get_battery_percentage(self, timeout=None): + """Returns the battery charge as a percentage. + + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :return: battery charge as a percentage. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + level = None + scale = None + percentage = 0 + cmd = "dumpsys battery" + re_parameter = re.compile(r"\s+(\w+):\s+(\d+)") + lines = self.shell_output(cmd, timeout=timeout).splitlines() + for line in lines: + match = re_parameter.match(line) + if match: + parameter = match.group(1) + value = match.group(2) + if parameter == "level": + level = float(value) + elif parameter == "scale": + scale = float(value) + if parameter is not None and scale is not None: + # pylint --py3k W1619 + percentage = 100.0 * level / scale + break + return percentage + + def get_top_activity(self, timeout=None): + """Returns the name of the top activity (focused app) reported by dumpsys + + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADBDevice constructor is used. + :return: package name of top activity or None (cannot be determined) + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + if self.version < version_codes.Q: + return self._get_top_activity_P(timeout=timeout) + return self._get_top_activity_Q(timeout=timeout) + + def _get_top_activity_P(self, timeout=None): + """Returns the name of the top activity (focused app) reported by dumpsys + for Android 9 and earlier. + """ + package = None + data = None + cmd = "dumpsys window windows" + verbose = self._verbose + try: + self._verbose = False + data = self.shell_output(cmd, timeout=timeout) + except Exception as e: + # dumpsys intermittently fails on some platforms. + self._logger.info("_get_top_activity_P: Exception %s: %s" % (cmd, e)) + return package + finally: + self._verbose = verbose + m = re.search("mFocusedApp(.+)/", data) + if not m: + # alternative format seen on newer versions of Android + m = re.search("FocusedApplication(.+)/", data) + if m: + line = m.group(0) + # Extract package name: string of non-whitespace ending in forward slash + m = re.search(r"(\S+)/$", line) + if m: + package = m.group(1) + if self._verbose: + self._logger.debug("get_top_activity: %s" % str(package)) + return package + + def _get_top_activity_Q(self, timeout=None): + """Returns the name of the top activity (focused app) reported by dumpsys + for Android 10 and later. + """ + package = None + data = None + cmd = "dumpsys window" + verbose = self._verbose + try: + self._verbose = False + data = self.shell_output(cmd, timeout=timeout) + except Exception as e: + # dumpsys intermittently fails on some platforms (4.3 arm emulator) + self._logger.info("_get_top_activity_Q: Exception %s: %s" % (cmd, e)) + return package + finally: + self._verbose = verbose + m = re.search(r"mFocusedWindow=Window{\S+ \S+ (\S+)/\S+}", data) + if m: + package = m.group(1) + if self._verbose: + self._logger.debug("get_top_activity: %s" % str(package)) + return package + + # System control methods + + def is_device_ready(self, timeout=None): + """Checks if a device is ready for testing. + + This method uses the android only package manager to check for + readiness. + + :param int timeout: The maximum time + in seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADB constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + # command_output automatically inserts a 'wait-for-device' + # argument to adb. Issuing an empty command is the same as adb + # -s <device> wait-for-device. We don't send an explicit + # 'wait-for-device' since that would add duplicate + # 'wait-for-device' arguments which is an error in newer + # versions of adb. + self._wait_for_boot_completed(timeout=timeout) + pm_error_string = "Error: Could not access the Package Manager" + ready_path = os.path.join(self.test_root, "ready") + for attempt in range(self._device_ready_retry_attempts): + failure = "Unknown failure" + success = True + try: + state = self.get_state(timeout=timeout) + if state != "device": + failure = "Device state: %s" % state + success = False + else: + if self.enforcing != "Permissive": + self.enforcing = "Permissive" + if self.is_dir(ready_path, timeout=timeout): + self.rmdir(ready_path, timeout=timeout) + self.mkdir(ready_path, timeout=timeout) + self.rmdir(ready_path, timeout=timeout) + # Invoke the pm list packages command to see if it is up and + # running. + data = self.shell_output( + "pm list packages org.mozilla", timeout=timeout + ) + if pm_error_string in data: + failure = data + success = False + except ADBError as e: + success = False + failure = str(e) + + if not success: + self._logger.debug( + "Attempt %s of %s device not ready: %s" + % (attempt + 1, self._device_ready_retry_attempts, failure) + ) + time.sleep(self._device_ready_retry_wait) + + return success + + def power_on(self, timeout=None): + """Sets the device's power stayon value. + + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADB constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + try: + self.shell_output("svc power stayon true", timeout=timeout) + except ADBError as e: + # Executing this via adb shell errors, but not interactively. + # Any other exitcode is a real error. + if "exitcode: 137" not in str(e): + raise + self._logger.warning("Unable to set power stayon true: %s" % e) + + # Application management methods + + def add_change_device_settings(self, app_name, timeout=None): + """ + Allows the test to change Android device settings. + :param str: app_name: Name of application (e.g. `org.mozilla.fennec`) + """ + self.shell_output( + "appops set %s android:write_settings allow" % app_name, + timeout=timeout, + enable_run_as=False, + ) + + def add_mock_location(self, app_name, timeout=None): + """ + Allows the Android device to use mock locations. + :param str: app_name: Name of application (e.g. `org.mozilla.fennec`) + """ + self.shell_output( + "appops set %s android:mock_location allow" % app_name, + timeout=timeout, + enable_run_as=False, + ) + + def grant_runtime_permissions(self, app_name, timeout=None): + """ + Grant required runtime permissions to the specified app + (typically org.mozilla.fennec_$USER). + + :param str: app_name: Name of application (e.g. `org.mozilla.fennec`) + """ + if self.version >= version_codes.M: + permissions = [ + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.ACCESS_COARSE_LOCATION", + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.CAMERA", + "android.permission.RECORD_AUDIO", + ] + if self.version < version_codes.R: + # WRITE_EXTERNAL_STORAGE is no longer available + # in Android 11+ + permissions.append("android.permission.WRITE_EXTERNAL_STORAGE") + self._logger.info("Granting important runtime permissions to %s" % app_name) + for permission in permissions: + try: + self.shell_output( + "pm grant %s %s" % (app_name, permission), + timeout=timeout, + enable_run_as=False, + ) + except ADBError as e: + self._logger.warning( + "Unable to grant runtime permission %s to %s due to %s" + % (permission, app_name, e) + ) + + def install_app_bundle(self, bundletool, bundle_path, java_home=None, timeout=None): + """Installs an app bundle (AAB) on the device. + + :param str bundletool: Path to the bundletool jar + :param str bundle_path: The aab file name to be installed. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADB constructor is used. + :param str java_home: Path to the JDK location. Will default to + $JAVA_HOME when not specififed. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + device_serial = self._device_serial or os.environ.get("ANDROID_SERIAL") + java_home = java_home or os.environ.get("JAVA_HOME") + with tempfile.TemporaryDirectory() as temporaryDirectory: + # bundletool doesn't come with a debug-key so we need to provide + # one ourselves. + keystore_path = os.path.join(temporaryDirectory, "debug.keystore") + keytool_path = os.path.join(java_home, "bin", "keytool") + key_gen = [ + keytool_path, + "-genkey", + "-v", + "-keystore", + keystore_path, + "-alias", + "androiddebugkey", + "-storepass", + "android", + "-keypass", + "android", + "-keyalg", + "RSA", + "-validity", + "14000", + "-dname", + "cn=Unknown, ou=Unknown, o=Unknown, c=Unknown", + ] + self._logger.info("key_gen: %s" % key_gen) + try: + subprocess.check_call(key_gen, timeout=timeout) + except subprocess.TimeoutExpired: + raise ADBTimeoutError("ADBDevice: unable to generate key") + + apks_path = "{}/tmp.apks".format(temporaryDirectory) + java_path = os.path.join(java_home, "bin", "java") + build_apks = [ + java_path, + "-jar", + bundletool, + "build-apks", + "--bundle={}".format(bundle_path), + "--output={}".format(apks_path), + "--connected-device", + "--device-id={}".format(device_serial), + "--adb={}".format(self._adb_path), + "--ks={}".format(keystore_path), + "--ks-key-alias=androiddebugkey", + "--key-pass=pass:android", + "--ks-pass=pass:android", + ] + self._logger.info("build_apks: %s" % build_apks) + + try: + subprocess.check_call(build_apks, timeout=timeout) + except subprocess.TimeoutExpired: + raise ADBTimeoutError("ADBDevice: unable to generate apks") + install_apks = [ + java_path, + "-jar", + bundletool, + "install-apks", + "--apks={}".format(apks_path), + "--device-id={}".format(device_serial), + "--adb={}".format(self._adb_path), + ] + self._logger.info("install_apks: %s" % install_apks) + + try: + subprocess.check_call(install_apks, timeout=timeout) + except subprocess.TimeoutExpired: + raise ADBTimeoutError("ADBDevice: unable to install apks") + + def install_app(self, apk_path, replace=False, timeout=None): + """Installs an app on the device. + + :param str apk_path: The apk file name to be installed. + :param bool replace: If True, replace existing application. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADB constructor is used. + :return: string - name of installed package. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + dump_packages = "dumpsys package packages" + packages_before = set(self.shell_output(dump_packages).split("\n")) + cmd = ["install"] + if replace: + cmd.append("-r") + cmd.append(apk_path) + data = self.command_output(cmd, timeout=timeout) + if data.find("Success") == -1: + raise ADBError("install failed for %s. Got: %s" % (apk_path, data)) + packages_after = set(self.shell_output(dump_packages).split("\n")) + packages_diff = packages_after - packages_before + package_name = None + re_pkg = re.compile(r"\s+pkg=Package{[^ ]+ (.*)}") + for diff in packages_diff: + match = re_pkg.match(diff) + if match: + package_name = match.group(1) + break + return package_name + + def is_app_installed(self, app_name, timeout=None): + """Returns True if an app is installed on the device. + + :param str app_name: name of the app to be checked. + :param int timeout: maximum time in seconds for any spawned + adb process to complete before throwing an ADBTimeoutError. + This timeout is per adb call. If it is not specified, + the value set in the ADB constructor is used. + + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + pm_error_string = "Error: Could not access the Package Manager" + data = self.shell_output( + "pm list package %s" % app_name, timeout=timeout, enable_run_as=False + ) + if pm_error_string in data: + raise ADBError(pm_error_string) + output = [line for line in data.splitlines() if line.strip()] + return any(["package:{}".format(app_name) == out for out in output]) + + def launch_application( + self, + app_name, + activity_name, + intent, + url=None, + extras=None, + wait=True, + fail_if_running=True, + grant_runtime_permissions=True, + timeout=None, + is_service=False, + ): + """Launches an Android application + + :param str app_name: Name of application (e.g. `com.android.chrome`) + :param str activity_name: Name of activity to launch (e.g. `.Main`) + :param str intent: Intent to launch application with + :param str url: URL to open + :param dict extras: Extra arguments for application. + :param bool wait: If True, wait for application to start before + returning. + :param bool fail_if_running: Raise an exception if instance of + application is already running. + :param bool grant_runtime_permissions: Grant special runtime + permissions. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADB constructor is used. + :param bool is_service: Whether we want to launch a service or not. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + # If fail_if_running is True, we throw an exception here. Only one + # instance of an application can be running at once on Android, + # starting a new instance may not be what we want depending on what + # we want to do + if fail_if_running and self.process_exist(app_name, timeout=timeout): + raise ADBError( + "Only one instance of an application may be running " "at once" + ) + + if grant_runtime_permissions: + self.grant_runtime_permissions(app_name) + + acmd = ["am"] + ["startservice" if is_service else "start"] + if wait: + acmd.extend(["-W"]) + acmd.extend( + [ + "-n", + "%s/%s" % (app_name, activity_name), + ] + ) + if intent: + acmd.extend(["-a", intent]) + + # Note that isinstance(True, int) and isinstance(False, int) + # is True. This means we must test the type of the value + # against bool prior to testing it against int in order to + # prevent falsely identifying a bool value as an int. + if extras: + for key, val in extras.items(): + if isinstance(val, bool): + extra_type_param = "--ez" + elif isinstance(val, int): + extra_type_param = "--ei" + else: + extra_type_param = "--es" + acmd.extend([extra_type_param, str(key), str(val)]) + + if url: + acmd.extend(["-d", url]) + + cmd = self._escape_command_line(acmd) + self._logger.info("launch_application: %s" % cmd) + cmd_output = self.shell_output(cmd, timeout=timeout) + if "Error:" in cmd_output: + for line in cmd_output.split("\n"): + self._logger.info(line) + raise ADBError( + "launch_application %s/%s failed" % (app_name, activity_name) + ) + + def launch_fennec( + self, + app_name, + intent="android.intent.action.VIEW", + moz_env=None, + extra_args=None, + url=None, + wait=True, + fail_if_running=True, + timeout=None, + ): + """Convenience method to launch Fennec on Android with various + debugging arguments + + :param str app_name: Name of fennec application (e.g. + `org.mozilla.fennec`) + :param str intent: Intent to launch application. + :param str moz_env: Mozilla specific environment to pass into + application. + :param str extra_args: Extra arguments to be parsed by fennec. + :param str url: URL to open + :param bool wait: If True, wait for application to start before + returning. + :param bool fail_if_running: Raise an exception if instance of + application is already running. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADB constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + extras = {} + + if moz_env: + # moz_env is expected to be a dictionary of environment variables: + # Fennec itself will set them when launched + for env_count, (env_key, env_val) in enumerate(moz_env.items()): + extras["env" + str(env_count)] = env_key + "=" + env_val + + # Additional command line arguments that fennec will read and use (e.g. + # with a custom profile) + if extra_args: + extras["args"] = " ".join(extra_args) + + self.launch_application( + app_name, + "org.mozilla.gecko.BrowserApp", + intent, + url=url, + extras=extras, + wait=wait, + fail_if_running=fail_if_running, + timeout=timeout, + ) + + def launch_service( + self, + app_name, + activity_name=None, + intent="android.intent.action.MAIN", + moz_env=None, + extra_args=None, + url=None, + e10s=False, + wait=True, + grant_runtime_permissions=False, + out_file=None, + timeout=None, + ): + """Convenience method to launch a service on Android with various + debugging arguments; convenient for geckoview apps. + + :param str app_name: Name of application (e.g. + `org.mozilla.geckoview_example` or `org.mozilla.geckoview.test_runner`) + :param str activity_name: Activity name, like `GeckoViewActivity`, or + `TestRunnerActivity`. + :param str intent: Intent to launch application. + :param str moz_env: Mozilla specific environment to pass into + application. + :param str extra_args: Extra arguments to be parsed by the app. + :param str url: URL to open + :param bool e10s: If True, run in multiprocess mode. + :param bool wait: If True, wait for application to start before + returning. + :param bool grant_runtime_permissions: Grant special runtime + permissions. + :param str out_file: File where to redirect the output to + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADB constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + extras = {} + + if moz_env: + # moz_env is expected to be a dictionary of environment variables: + # geckoview_example itself will set them when launched + for env_count, (env_key, env_val) in enumerate(moz_env.items()): + extras["env" + str(env_count)] = env_key + "=" + env_val + + # Additional command line arguments that the app will read and use (e.g. + # with a custom profile) + if extra_args: + for arg_count, arg in enumerate(extra_args): + extras["arg" + str(arg_count)] = arg + + extras["use_multiprocess"] = e10s + extras["out_file"] = out_file + self.launch_application( + app_name, + "%s.%s" % (app_name, activity_name), + intent, + url=url, + extras=extras, + wait=wait, + grant_runtime_permissions=grant_runtime_permissions, + timeout=timeout, + is_service=True, + fail_if_running=False, + ) + + def launch_activity( + self, + app_name, + activity_name=None, + intent="android.intent.action.MAIN", + moz_env=None, + extra_args=None, + url=None, + e10s=False, + wait=True, + fail_if_running=True, + timeout=None, + ): + """Convenience method to launch an application on Android with various + debugging arguments; convenient for geckoview apps. + + :param str app_name: Name of application (e.g. + `org.mozilla.geckoview_example` or `org.mozilla.geckoview.test_runner`) + :param str activity_name: Activity name, like `GeckoViewActivity`, or + `TestRunnerActivity`. + :param str intent: Intent to launch application. + :param str moz_env: Mozilla specific environment to pass into + application. + :param str extra_args: Extra arguments to be parsed by the app. + :param str url: URL to open + :param bool e10s: If True, run in multiprocess mode. + :param bool wait: If True, wait for application to start before + returning. + :param bool fail_if_running: Raise an exception if instance of + application is already running. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADB constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + extras = {} + + if moz_env: + # moz_env is expected to be a dictionary of environment variables: + # geckoview_example itself will set them when launched + for env_count, (env_key, env_val) in enumerate(moz_env.items()): + extras["env" + str(env_count)] = env_key + "=" + env_val + + # Additional command line arguments that the app will read and use (e.g. + # with a custom profile) + if extra_args: + for arg_count, arg in enumerate(extra_args): + extras["arg" + str(arg_count)] = arg + + extras["use_multiprocess"] = e10s + self.launch_application( + app_name, + "%s.%s" % (app_name, activity_name), + intent, + url=url, + extras=extras, + wait=wait, + fail_if_running=fail_if_running, + timeout=timeout, + ) + + def stop_application(self, app_name, timeout=None): + """Stops the specified application + + For Android 3.0+, we use the "am force-stop" to do this, which + is reliable and does not require root. For earlier versions of + Android, we simply try to manually kill the processes started + by the app repeatedly until none is around any more. This is + less reliable and does require root. + + :param str app_name: Name of application (e.g. `com.android.chrome`) + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADB constructor is used. + :param bool root: Flag specifying if the command should be + executed as root. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + if self.version >= version_codes.HONEYCOMB: + self.shell_output("am force-stop %s" % app_name, timeout=timeout) + else: + num_tries = 0 + max_tries = 5 + while self.process_exist(app_name, timeout=timeout): + if num_tries > max_tries: + raise ADBError( + "Couldn't successfully kill %s after %s " + "tries" % (app_name, max_tries) + ) + self.pkill(app_name, timeout=timeout) + num_tries += 1 + + # sleep for a short duration to make sure there are no + # additional processes in the process of being launched + # (this is not 100% guaranteed to work since it is inherently + # racey, but it's the best we can do) + time.sleep(1) + + def uninstall_app(self, app_name, reboot=False, timeout=None): + """Uninstalls an app on the device. + + :param str app_name: The name of the app to be + uninstalled. + :param bool reboot: Flag indicating that the device should + be rebooted after the app is uninstalled. No reboot occurs + if the app is not installed. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADB constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + if self.is_app_installed(app_name, timeout=timeout): + data = self.command_output(["uninstall", app_name], timeout=timeout) + if data.find("Success") == -1: + self._logger.debug("uninstall_app failed: %s" % data) + raise ADBError("uninstall failed for %s. Got: %s" % (app_name, data)) + self.run_as_package = None + if reboot: + self.reboot(timeout=timeout) + + def update_app(self, apk_path, timeout=None): + """Updates an app on the device and reboots. + + :param str apk_path: The apk file name to be + updated. + :param int timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. + This timeout is per adb call. The total time spent + may exceed this value. If it is not specified, the value + set in the ADB constructor is used. + :raises: :exc:`ADBTimeoutError` + :exc:`ADBError` + """ + cmd = ["install", "-r"] + if self.version >= version_codes.M: + cmd.append("-g") + cmd.append(apk_path) + output = self.command_output(cmd, timeout=timeout) + self.reboot(timeout=timeout) + return output diff --git a/testing/mozbase/mozdevice/mozdevice/adb_android.py b/testing/mozbase/mozdevice/mozdevice/adb_android.py new file mode 100644 index 0000000000..135fda4195 --- /dev/null +++ b/testing/mozbase/mozdevice/mozdevice/adb_android.py @@ -0,0 +1,13 @@ +# 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 .adb import ADBDevice + + +class ADBAndroid(ADBDevice): + """ADBAndroid functionality is now provided by ADBDevice. New callers + should use ADBDevice. + """ + + pass diff --git a/testing/mozbase/mozdevice/mozdevice/remote_process_monitor.py b/testing/mozbase/mozdevice/mozdevice/remote_process_monitor.py new file mode 100644 index 0000000000..2934a9f3d1 --- /dev/null +++ b/testing/mozbase/mozdevice/mozdevice/remote_process_monitor.py @@ -0,0 +1,285 @@ +# 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/. + +import re +import time + +import six + +from .adb import ADBTimeoutError + + +class RemoteProcessMonitor: + """ + RemoteProcessMonitor provides a convenient way to run a remote process, + dump its log file, and wait for it to end. + """ + + def __init__( + self, + app_name, + device, + log, + message_logger, + remote_log_file, + remote_profile, + ): + self.app_name = app_name + self.device = device + self.log = log + self.remote_log_file = remote_log_file + self.remote_profile = remote_profile + self.counts = {} + self.counts["pass"] = 0 + self.counts["fail"] = 0 + self.counts["todo"] = 0 + self.last_test_seen = "RemoteProcessMonitor" + self.message_logger = message_logger + if self.device.is_file(self.remote_log_file): + self.device.rm(self.remote_log_file) + self.log.info("deleted remote log %s" % self.remote_log_file) + + def launch(self, app, debugger_info, test_url, extra_args, env, e10s): + """ + Start the remote activity. + """ + if self.app_name and self.device.process_exist(self.app_name): + self.log.info("%s is already running. Stopping..." % self.app_name) + self.device.stop_application(self.app_name) + args = [] + if debugger_info: + args.extend(debugger_info.args) + args.append(app) + args.extend(extra_args) + activity = "TestRunnerActivity" + self.device.launch_activity( + self.app_name, + activity_name=activity, + e10s=e10s, + moz_env=env, + extra_args=args, + url=test_url, + ) + return self.pid + + @property + def pid(self): + """ + Determine the pid of the remote process (or the first process with + the same name). + """ + procs = self.device.get_process_list() + # limit the comparison to the first 75 characters due to a + # limitation in processname length in android. + pids = [proc[0] for proc in procs if proc[1] == self.app_name[:75]] + if pids is None or len(pids) < 1: + return 0 + return pids[0] + + def read_stdout(self): + """ + Fetch the full remote log file, log any new content and return True if new + content is processed. + """ + try: + new_log_content = self.device.get_file( + self.remote_log_file, offset=self.stdout_len + ) + except ADBTimeoutError: + raise + except Exception as e: + self.log.error( + "%s | exception reading log: %s" % (self.last_test_seen, str(e)) + ) + return False + if not new_log_content: + return False + + self.stdout_len += len(new_log_content) + new_log_content = six.ensure_str(new_log_content, errors="replace") + + self.log_buffer += new_log_content + lines = self.log_buffer.split("\n") + lines = [l for l in lines if l] + + if lines: + if self.log_buffer.endswith("\n"): + # all lines are complete; no need to buffer + self.log_buffer = "" + else: + # keep the last (unfinished) line in the buffer + self.log_buffer = lines[-1] + del lines[-1] + if not lines: + return False + + for line in lines: + # This passes the line to the logger (to be logged or buffered) + if isinstance(line, six.text_type): + # if line is unicode - let's encode it to bytes + parsed_messages = self.message_logger.write( + line.encode("UTF-8", "replace") + ) + else: + # if line is bytes type, write it as it is + parsed_messages = self.message_logger.write(line) + + for message in parsed_messages: + if isinstance(message, dict): + if message.get("action") == "test_start": + self.last_test_seen = message["test"] + elif message.get("action") == "test_end": + self.last_test_seen = "{} (finished)".format(message["test"]) + elif message.get("action") == "suite_end": + self.last_test_seen = "Last test finished" + elif message.get("action") == "log": + line = message["message"].strip() + m = re.match(r".*:\s*(\d*)", line) + if m: + try: + val = int(m.group(1)) + if "Passed:" in line: + self.counts["pass"] += val + self.last_test_seen = "Last test finished" + elif "Failed:" in line: + self.counts["fail"] += val + elif "Todo:" in line: + self.counts["todo"] += val + except ADBTimeoutError: + raise + except Exception: + pass + + return True + + def wait(self, timeout=None): + """ + Wait for the remote process to end (or for its activity to go to background). + While waiting, periodically retrieve the process output and print it. + If the process is still running but no output is received in *timeout* + seconds, return False; else, once the process exits/goes to background, + return True. + """ + self.log_buffer = "" + self.stdout_len = 0 + + timer = 0 + output_timer = 0 + interval = 10 + status = True + top = self.app_name + + # wait for log creation on startup + retries = 0 + while retries < 20 and not self.device.is_file(self.remote_log_file): + retries += 1 + time.sleep(1) + if self.device.is_file(self.remote_log_file): + # We must change the remote log's permissions so that the shell can read it. + self.device.chmod(self.remote_log_file, mask="666") + else: + self.log.warning( + "Failed wait for remote log: %s missing?" % self.remote_log_file + ) + + while top == self.app_name: + has_output = self.read_stdout() + if has_output: + output_timer = 0 + if self.counts["pass"] > 0: + interval = 0.5 + time.sleep(interval) + timer += interval + output_timer += interval + if timeout and output_timer > timeout: + status = False + break + if not has_output: + top = self.device.get_top_activity(timeout=60) + if top is None: + self.log.info("Failed to get top activity, retrying, once...") + top = self.device.get_top_activity(timeout=60) + + # Flush anything added to stdout during the sleep + self.read_stdout() + self.log.info("wait for %s complete; top activity=%s" % (self.app_name, top)) + if top == self.app_name: + self.log.info("%s unexpectedly found running. Killing..." % self.app_name) + self.kill() + if not status: + self.log.error( + "TEST-UNEXPECTED-FAIL | %s | " + "application timed out after %d seconds with no output" + % (self.last_test_seen, int(timeout)) + ) + return status + + def kill(self): + """ + End a troublesome remote process: Trigger ANR and breakpad dumps, then + force the application to end. + """ + + # Trigger an ANR report with "kill -3" (SIGQUIT) + try: + self.device.pkill(self.app_name, sig=3, attempts=1) + except ADBTimeoutError: + raise + except: # NOQA: E722 + pass + time.sleep(3) + + # Trigger a breakpad dump with "kill -6" (SIGABRT) + try: + self.device.pkill(self.app_name, sig=6, attempts=1) + except ADBTimeoutError: + raise + except: # NOQA: E722 + pass + + # Wait for process to end + retries = 0 + while retries < 3: + if self.device.process_exist(self.app_name): + self.log.info( + "%s still alive after SIGABRT: waiting..." % self.app_name + ) + time.sleep(5) + else: + break + retries += 1 + if self.device.process_exist(self.app_name): + try: + self.device.pkill(self.app_name, sig=9, attempts=1) + except ADBTimeoutError: + raise + except: # NOQA: E722 + self.log.error("%s still alive after SIGKILL!" % self.app_name) + if self.device.process_exist(self.app_name): + self.device.stop_application(self.app_name) + + # Test harnesses use the MOZ_CRASHREPORTER environment variables to suppress + # the interactive crash reporter, but that may not always be effective; + # check for and cleanup errant crashreporters. + crashreporter = "%s.CrashReporter" % self.app_name + if self.device.process_exist(crashreporter): + self.log.warning( + "%s unexpectedly found running. Killing..." % crashreporter + ) + try: + self.device.pkill(crashreporter) + except ADBTimeoutError: + raise + except: # NOQA: E722 + pass + if self.device.process_exist(crashreporter): + self.log.error("%s still running!!" % crashreporter) + + @staticmethod + def elf_arm(filename): + """ + Determine if the specified file is an ARM binary. + """ + data = open(filename, "rb").read(20) + return data[:4] == "\x7fELF" and ord(data[18]) == 40 # EM_ARM diff --git a/testing/mozbase/mozdevice/mozdevice/version_codes.py b/testing/mozbase/mozdevice/mozdevice/version_codes.py new file mode 100644 index 0000000000..c1d56c7b84 --- /dev/null +++ b/testing/mozbase/mozdevice/mozdevice/version_codes.py @@ -0,0 +1,70 @@ +# 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/. + +""" +VERSION CODES of the android releases. + +See http://developer.android.com/reference/android/os/Build.VERSION_CODES.html. +""" +# Magic version number for a current development build, which has +# not yet turned into an official release. +CUR_DEVELOPMENT = 10000 + +# October 2008: The original, first, version of Android +BASE = 1 +# February 2009: First Android update, officially called 1.1 +BASE_1_1 = 2 +# May 2009: Android 1.5 +CUPCAKE = 3 +# September 2009: Android 1.6 +DONUT = 4 +# November 2009: Android 2.0 +ECLAIR = 5 +# December 2009: Android 2.0.1 +ECLAIR_0_1 = 6 +# January 2010: Android 2.1 +ECLAIR_MR1 = 7 +# June 2010: Android 2.2 +FROYO = 8 +# November 2010: Android 2.3 +GINGERBREAD = 9 +# February 2011: Android 2.3.3 +GINGERBREAD_MR1 = 10 +# February 2011: Android 3.0 +HONEYCOMB = 11 +# May 2011: Android 3.1 +HONEYCOMB_MR1 = 12 +# June 2011: Android 3.2 +HONEYCOMB_MR2 = 13 +# October 2011: Android 4.0 +ICE_CREAM_SANDWICH = 14 +# December 2011: Android 4.0.3 +ICE_CREAM_SANDWICH_MR1 = 15 +# June 2012: Android 4.1 +JELLY_BEAN = 16 +# November 2012: Android 4.2 +JELLY_BEAN_MR1 = 17 +# July 2013: Android 4.3 +JELLY_BEAN_MR2 = 18 +# October 2013: Android 4.4 +KITKAT = 19 +# Android 4.4W +KITKAT_WATCH = 20 +# Lollilop +LOLLIPOP = 21 +LOLLIPOP_MR1 = 22 +# Marshmallow +M = 23 +# Nougat +N = 24 +N_MR1 = 25 +# Oreo +O = 26 +O_MR1 = 27 +# Pie +P = 28 +# 10 +Q = 29 +# 11 +R = 30 diff --git a/testing/mozbase/mozdevice/setup.cfg b/testing/mozbase/mozdevice/setup.cfg new file mode 100644 index 0000000000..2a9acf13da --- /dev/null +++ b/testing/mozbase/mozdevice/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 diff --git a/testing/mozbase/mozdevice/setup.py b/testing/mozbase/mozdevice/setup.py new file mode 100644 index 0000000000..91ce63d9f6 --- /dev/null +++ b/testing/mozbase/mozdevice/setup.py @@ -0,0 +1,34 @@ +# 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 setuptools import setup + +PACKAGE_NAME = "mozdevice" +PACKAGE_VERSION = "4.1.1" + +deps = ["mozlog >= 6.0"] + +setup( + name=PACKAGE_NAME, + version=PACKAGE_VERSION, + description="Mozilla-authored device management", + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + classifiers=[ + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.5", + ], + # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + keywords="", + author="Mozilla Automation and Testing Team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + license="MPL", + packages=["mozdevice"], + include_package_data=True, + zip_safe=False, + install_requires=deps, + entry_points=""" + # -*- Entry points: -*- + """, +) diff --git a/testing/mozbase/mozdevice/tests/conftest.py b/testing/mozbase/mozdevice/tests/conftest.py new file mode 100644 index 0000000000..831090a428 --- /dev/null +++ b/testing/mozbase/mozdevice/tests/conftest.py @@ -0,0 +1,236 @@ +import sys +from random import randint, seed +from unittest.mock import patch + +import mozdevice +import pytest +from six import StringIO + +# set up required module-level variables/objects +seed(1488590) + + +def random_tcp_port(): + """Returns a pseudo-random integer generated from a seed. + + :returns: int: pseudo-randomly generated integer + """ + return randint(8000, 12000) + + +@pytest.fixture(autouse=True) +def mock_command_output(monkeypatch): + """Monkeypatches the ADBDevice.command_output() method call. + + Instead of calling the concrete method implemented in adb.py::ADBDevice, + this method simply returns a string representation of the command that was + received. + + As an exception, if the command begins with "forward tcp:0 ", this method + returns a mock port number. + + :param object monkeypatch: pytest provided fixture for mocking. + """ + + def command_output_wrapper(object, cmd, timeout): + """Actual monkeypatch implementation of the command_output method call. + + :param object object: placeholder object representing ADBDevice + :param str cmd: command to be executed + :param timeout: unused parameter to represent timeout threshold + :returns: string - string representation of command to be executed + int - mock port number (only used when cmd begins with "forward tcp:0 ") + """ + + if cmd[0] == "forward" and cmd[1] == "tcp:0": + return 7777 + + print(str(cmd)) + return str(cmd) + + monkeypatch.setattr(mozdevice.ADBDevice, "command_output", command_output_wrapper) + + +@pytest.fixture(autouse=True) +def mock_shell_output(monkeypatch): + """Monkeypatches the ADBDevice.shell_output() method call. + + Instead of returning the output of an adb call, this method will + return appropriate string output. Content of the string output is + in line with the calling method's expectations. + + :param object monkeypatch: pytest provided fixture for mocking. + """ + + def shell_output_wrapper( + object, cmd, env=None, cwd=None, timeout=None, enable_run_as=False + ): + """Actual monkeypatch implementation of the shell_output method call. + + :param object object: placeholder object representing ADBDevice + :param str cmd: command to be executed + :param env: contains the environment variable + :type env: dict or None + :param cwd: The directory from which to execute. + :type cwd: str or None + :param timeout: unused parameter tp represent timeout threshold + :param enable_run_as: bool determining if run_as <app> is to be used + :returns: string - string representation of a simulated call to adb + """ + if "pm list package error" in cmd: + return "Error: Could not access the Package Manager" + elif "pm list package none" in cmd: + return "" + elif "pm list package" in cmd: + apps = ["org.mozilla.fennec", "org.mozilla.geckoview_example"] + return ("package:{}\n" * len(apps)).format(*apps) + else: + print(str(cmd)) + return str(cmd) + + monkeypatch.setattr(mozdevice.ADBDevice, "shell_output", shell_output_wrapper) + + +@pytest.fixture(autouse=True) +def mock_is_path_internal_storage(monkeypatch): + """Monkeypatches the ADBDevice.is_path_internal_storage() method call. + + Instead of returning the outcome of whether the path provided is + internal storage or external, this will always return True. + + :param object monkeypatch: pytest provided fixture for mocking. + """ + + def is_path_internal_storage_wrapper(object, path, timeout=None): + """Actual monkeypatch implementation of the is_path_internal_storage() call. + + :param str path: The path to test. + :param timeout: The maximum time in + seconds for any spawned adb process to complete before + throwing an ADBTimeoutError. This timeout is per adb call. The + total time spent may exceed this value. If it is not + specified, the value set in the ADBDevice constructor is used. + :returns: boolean + + :raises: * ADBTimeoutError + * ADBError + """ + if "internal_storage" in path: + return True + return False + + monkeypatch.setattr( + mozdevice.ADBDevice, + "is_path_internal_storage", + is_path_internal_storage_wrapper, + ) + + +@pytest.fixture(autouse=True) +def mock_enable_run_as_for_path(monkeypatch): + """Monkeypatches the ADBDevice.enable_run_as_for_path(path) method. + + Always return True + + :param object monkeypatch: pytest provided fixture for mocking. + """ + + def enable_run_as_for_path_wrapper(object, path): + """Actual monkeypatch implementation of the enable_run_as_for_path() call. + + :param str path: The path to test. + :returns: boolean + """ + return True + + monkeypatch.setattr( + mozdevice.ADBDevice, "enable_run_as_for_path", enable_run_as_for_path_wrapper + ) + + +@pytest.fixture(autouse=True) +def mock_shell_bool(monkeypatch): + """Monkeypatches the ADBDevice.shell_bool() method call. + + Instead of returning the output of an adb call, this method will + return appropriate string output. Content of the string output is + in line with the calling method's expectations. + + :param object monkeypatch: pytest provided fixture for mocking. + """ + + def shell_bool_wrapper( + object, cmd, env=None, cwd=None, timeout=None, enable_run_as=False + ): + """Actual monkeypatch implementation of the shell_bool method call. + + :param object object: placeholder object representing ADBDevice + :param str cmd: command to be executed + :param env: contains the environment variable + :type env: dict or None + :param cwd: The directory from which to execute. + :type cwd: str or None + :param timeout: unused parameter tp represent timeout threshold + :param enable_run_as: bool determining if run_as <app> is to be used + :returns: string - string representation of a simulated call to adb + """ + print(cmd) + return str(cmd) + + monkeypatch.setattr(mozdevice.ADBDevice, "shell_bool", shell_bool_wrapper) + + +@pytest.fixture(autouse=True) +def mock_adb_object(): + """Patches the __init__ method call when instantiating ADBDevice. + + ADBDevice normally requires instantiated objects in order to execute + its commands. + + With a pytest-mock patch, we are able to mock the initialization of + the ADBDevice object. By yielding the instantiated mock object, + unit tests can be run that call methods that require an instantiated + object. + + :yields: ADBDevice - mock instance of ADBDevice object + """ + with patch.object(mozdevice.ADBDevice, "__init__", lambda self: None): + yield mozdevice.ADBDevice() + + +@pytest.fixture +def redirect_stdout_and_assert(): + """Redirects the stdout pipe temporarily to a StringIO stream. + + This is useful to assert on methods that do not return + a value, such as most ADBDevice methods. + + The original stdout pipe is preserved throughout the process. + + :returns: _wrapper method + """ + + def _wrapper(func, **kwargs): + """Implements the stdout sleight-of-hand. + + After preserving the original sys.stdout, it is switched + to use cStringIO.StringIO. + + Method with no return value is called, and the stdout + pipe is switched back to the original sys.stdout. + + The expected outcome is received as part of the kwargs. + This is asserted against a sanitized output from the method + under test. + + :param object func: method under test + :param dict kwargs: dictionary of function parameters + """ + original_stdout = sys.stdout + sys.stdout = testing_stdout = StringIO() + expected_text = kwargs.pop("text") + func(**kwargs) + sys.stdout = original_stdout + assert expected_text in testing_stdout.getvalue().rstrip() + + return _wrapper diff --git a/testing/mozbase/mozdevice/tests/manifest.toml b/testing/mozbase/mozdevice/tests/manifest.toml new file mode 100644 index 0000000000..22b338ca95 --- /dev/null +++ b/testing/mozbase/mozdevice/tests/manifest.toml @@ -0,0 +1,10 @@ +[DEFAULT] +subsuite = "mozbase" + +["test_chown.py"] + +["test_escape_command_line.py"] + +["test_is_app_installed.py"] + +["test_socket_connection.py"] diff --git a/testing/mozbase/mozdevice/tests/test_chown.py b/testing/mozbase/mozdevice/tests/test_chown.py new file mode 100644 index 0000000000..1bbfcc5d8e --- /dev/null +++ b/testing/mozbase/mozdevice/tests/test_chown.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python + +import logging +from unittest.mock import patch + +import mozunit +import pytest + + +@pytest.mark.parametrize("boolean_value", [True, False]) +def test_set_chown_r_attribute( + mock_adb_object, redirect_stdout_and_assert, boolean_value +): + mock_adb_object._chown_R = boolean_value + assert mock_adb_object._chown_R == boolean_value + + +def test_chown_path_internal(mock_adb_object, redirect_stdout_and_assert): + """Tests whether attempt to chown internal path is ignored""" + with patch.object(logging, "getLogger") as mock_log: + mock_adb_object._logger = mock_log + + testing_parameters = { + "owner": "someuser", + "path": "internal_storage", + } + expected = "Ignoring attempt to chown external storage" + mock_adb_object.chown(**testing_parameters) + assert "".join(mock_adb_object._logger.method_calls[0][1]) != "" + assert "".join(mock_adb_object._logger.method_calls[0][1]) == expected + + +def test_chown_one_path(mock_adb_object, redirect_stdout_and_assert): + """Tests the path where only one path is provided.""" + # set up mock logging and self._chown_R attribute. + with patch.object(logging, "getLogger") as mock_log: + mock_adb_object._logger = mock_log + mock_adb_object._chown_R = True + + testing_parameters = { + "owner": "someuser", + "path": "/system", + } + command = "chown {owner} {path}".format(**testing_parameters) + testing_parameters["text"] = command + redirect_stdout_and_assert(mock_adb_object.chown, **testing_parameters) + + +def test_chown_one_path_with_group(mock_adb_object, redirect_stdout_and_assert): + """Tests the path where group is provided.""" + # set up mock logging and self._chown_R attribute. + with patch.object(logging, "getLogger") as mock_log: + mock_adb_object._logger = mock_log + mock_adb_object._chown_R = True + + testing_parameters = { + "owner": "someuser", + "path": "/system", + "group": "group_2", + } + command = "chown {owner}.{group} {path}".format(**testing_parameters) + testing_parameters["text"] = command + redirect_stdout_and_assert(mock_adb_object.chown, **testing_parameters) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozdevice/tests/test_escape_command_line.py b/testing/mozbase/mozdevice/tests/test_escape_command_line.py new file mode 100644 index 0000000000..112dd936c5 --- /dev/null +++ b/testing/mozbase/mozdevice/tests/test_escape_command_line.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python + +import mozunit + + +def test_escape_command_line(mock_adb_object, redirect_stdout_and_assert): + """Test _escape_command_line.""" + cases = { + # expected output : test input + "adb shell ls -l": ["adb", "shell", "ls", "-l"], + "adb shell 'ls -l'": ["adb", "shell", "ls -l"], + "-e 'if (true)'": ["-e", "if (true)"], + "-e 'if (x === \"hello\")'": ["-e", 'if (x === "hello")'], + "-e 'if (x === '\"'\"'hello'\"'\"')'": ["-e", "if (x === 'hello')"], + } + for expected, input in cases.items(): + assert mock_adb_object._escape_command_line(input) == expected + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozdevice/tests/test_is_app_installed.py b/testing/mozbase/mozdevice/tests/test_is_app_installed.py new file mode 100644 index 0000000000..a51836bc02 --- /dev/null +++ b/testing/mozbase/mozdevice/tests/test_is_app_installed.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python + +import mozunit +import pytest +from mozdevice import ADBError + + +def test_is_app_installed(mock_adb_object): + """Tests that is_app_installed returns True if app is installed.""" + assert mock_adb_object.is_app_installed("org.mozilla.geckoview_example") + + +def test_is_app_installed_not_installed(mock_adb_object): + """Tests that is_app_installed returns False if provided app_name + does not resolve.""" + assert not mock_adb_object.is_app_installed("some_random_name") + + +def test_is_app_installed_partial_name(mock_adb_object): + """Tests that is_app_installed returns False if provided app_name + is only a partial match.""" + assert not mock_adb_object.is_app_installed("fennec") + + +def test_is_app_installed_package_manager_error(mock_adb_object): + """Tests that is_app_installed is able to raise an exception.""" + with pytest.raises(ADBError): + mock_adb_object.is_app_installed("error") + + +def test_is_app_installed_no_installed_package_found(mock_adb_object): + """Tests that is_app_installed is able to handle scenario + where no installed packages are found.""" + assert not mock_adb_object.is_app_installed("none") + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozdevice/tests/test_socket_connection.py b/testing/mozbase/mozdevice/tests/test_socket_connection.py new file mode 100644 index 0000000000..1182737546 --- /dev/null +++ b/testing/mozbase/mozdevice/tests/test_socket_connection.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python + +import mozunit +import pytest +from conftest import random_tcp_port + + +@pytest.fixture(params=["tcp:{}".format(random_tcp_port()) for _ in range(5)]) +def select_test_port(request): + """Generate a list of ports to be used for testing.""" + yield request.param + + +def test_list_socket_connections_reverse(mock_adb_object): + assert [("['reverse',", "'--list']")] == mock_adb_object.list_socket_connections( + "reverse" + ) + + +def test_list_socket_connections_forward(mock_adb_object): + assert [("['forward',", "'--list']")] == mock_adb_object.list_socket_connections( + "forward" + ) + + +def test_create_socket_connection_reverse( + mock_adb_object, select_test_port, redirect_stdout_and_assert +): + _expected = "['reverse', '{0}', '{0}']".format(select_test_port) + redirect_stdout_and_assert( + mock_adb_object.create_socket_connection, + direction="reverse", + local=select_test_port, + remote=select_test_port, + text=_expected, + ) + + +def test_create_socket_connection_forward( + mock_adb_object, select_test_port, redirect_stdout_and_assert +): + _expected = "['forward', '{0}', '{0}']".format(select_test_port) + redirect_stdout_and_assert( + mock_adb_object.create_socket_connection, + direction="forward", + local=select_test_port, + remote=select_test_port, + text=_expected, + ) + + +def test_create_socket_connection_forward_adb_assigned_port( + mock_adb_object, select_test_port +): + result = mock_adb_object.create_socket_connection( + direction="forward", local="tcp:0", remote=select_test_port + ) + assert isinstance(result, int) and result == 7777 + + +def test_remove_socket_connections_reverse(mock_adb_object, redirect_stdout_and_assert): + _expected = "['reverse', '--remove-all']" + redirect_stdout_and_assert( + mock_adb_object.remove_socket_connections, direction="reverse", text=_expected + ) + + +def test_remove_socket_connections_forward(mock_adb_object, redirect_stdout_and_assert): + _expected = "['forward', '--remove-all']" + redirect_stdout_and_assert( + mock_adb_object.remove_socket_connections, direction="forward", text=_expected + ) + + +def test_legacy_forward(mock_adb_object, select_test_port, redirect_stdout_and_assert): + _expected = "['forward', '{0}', '{0}']".format(select_test_port) + redirect_stdout_and_assert( + mock_adb_object.forward, + local=select_test_port, + remote=select_test_port, + text=_expected, + ) + + +def test_legacy_forward_adb_assigned_port(mock_adb_object, select_test_port): + result = mock_adb_object.forward(local="tcp:0", remote=select_test_port) + assert isinstance(result, int) and result == 7777 + + +def test_legacy_reverse(mock_adb_object, select_test_port, redirect_stdout_and_assert): + _expected = "['reverse', '{0}', '{0}']".format(select_test_port) + redirect_stdout_and_assert( + mock_adb_object.reverse, + local=select_test_port, + remote=select_test_port, + text=_expected, + ) + + +def test_validate_port_invalid_prefix(mock_adb_object): + with pytest.raises(ValueError): + mock_adb_object._validate_port("{}".format("invalid"), is_local=True) + + +@pytest.mark.xfail +def test_validate_port_non_numerical_port_identifier(mock_adb_object): + with pytest.raises(AttributeError): + mock_adb_object._validate_port( + "{}".format("tcp:this:is:not:a:number"), is_local=True + ) + + +def test_validate_port_identifier_length_short(mock_adb_object): + with pytest.raises(ValueError): + mock_adb_object._validate_port("{}".format("tcp"), is_local=True) + + +def test_validate_direction(mock_adb_object): + with pytest.raises(ValueError): + mock_adb_object._validate_direction("{}".format("bad direction")) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozfile/mozfile/__init__.py b/testing/mozbase/mozfile/mozfile/__init__.py new file mode 100644 index 0000000000..5d45755ac7 --- /dev/null +++ b/testing/mozbase/mozfile/mozfile/__init__.py @@ -0,0 +1,6 @@ +# flake8: noqa +# 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 .mozfile import * diff --git a/testing/mozbase/mozfile/mozfile/mozfile.py b/testing/mozbase/mozfile/mozfile/mozfile.py new file mode 100644 index 0000000000..892f8ee20f --- /dev/null +++ b/testing/mozbase/mozfile/mozfile/mozfile.py @@ -0,0 +1,691 @@ +# 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/. + +# We don't import all modules at the top for performance reasons. See Bug 1008943 + +import errno +import os +import re +import stat +import sys +import time +import warnings +from contextlib import contextmanager +from textwrap import dedent + +from six.moves import urllib + +__all__ = [ + "extract_tarball", + "extract_zip", + "extract", + "is_url", + "load", + "load_source", + "copy_contents", + "match", + "move", + "remove", + "rmtree", + "tree", + "which", + "NamedTemporaryFile", + "TemporaryDirectory", +] + +# utilities for extracting archives + + +def extract_tarball(src, dest, ignore=None): + """extract a .tar file""" + + import tarfile + + def _is_within_directory(directory, target): + real_directory = os.path.realpath(directory) + real_target = os.path.realpath(target) + prefix = os.path.commonprefix([real_directory, real_target]) + return prefix == real_directory + + with tarfile.open(src) as bundle: + namelist = [] + + for m in bundle: + # Mitigation for CVE-2007-4559, Python's tarfile library will allow + # writing files outside of the intended destination. + member_path = os.path.join(dest, m.name) + if not _is_within_directory(dest, member_path): + raise RuntimeError( + dedent( + f""" + Tar bundle '{src}' may be maliciously crafted to escape the destination! + The following path was detected: + + {m.name} + """ + ) + ) + if m.issym(): + link_path = os.path.join(os.path.dirname(member_path), m.linkname) + if not _is_within_directory(dest, link_path): + raise RuntimeError( + dedent( + f""" + Tar bundle '{src}' may be maliciously crafted to escape the destination! + The following path was detected: + + {m.name} + """ + ) + ) + + if m.mode & (stat.S_ISUID | stat.S_ISGID): + raise RuntimeError( + dedent( + f""" + Tar bundle '{src}' may be maliciously crafted to setuid/setgid! + The following path was detected: + + {m.name} + """ + ) + ) + + if ignore and any(match(m.name, i) for i in ignore): + continue + bundle.extract(m, path=dest) + namelist.append(m.name) + + return namelist + + +def extract_zip(src, dest, ignore=None): + """extract a zip file""" + + import zipfile + + if isinstance(src, zipfile.ZipFile): + bundle = src + else: + try: + bundle = zipfile.ZipFile(src) + except Exception: + print("src: %s" % src) + raise + + namelist = bundle.namelist() + + for name in namelist: + if ignore and any(match(name, i) for i in ignore): + continue + + bundle.extract(name, dest) + filename = os.path.realpath(os.path.join(dest, name)) + mode = bundle.getinfo(name).external_attr >> 16 & 0x1FF + # Only update permissions if attributes are set. Otherwise fallback to the defaults. + if mode: + os.chmod(filename, mode) + bundle.close() + return namelist + + +def extract(src, dest=None, ignore=None): + """ + Takes in a tar or zip file and extracts it to dest + + If dest is not specified, extracts to os.path.dirname(src) + + Returns the list of top level files that were extracted + """ + + import tarfile + import zipfile + + assert os.path.exists(src), "'%s' does not exist" % src + + if dest is None: + dest = os.path.dirname(src) + elif not os.path.isdir(dest): + os.makedirs(dest) + assert not os.path.isfile(dest), "dest cannot be a file" + + if tarfile.is_tarfile(src): + namelist = extract_tarball(src, dest, ignore=ignore) + elif zipfile.is_zipfile(src): + namelist = extract_zip(src, dest, ignore=ignore) + else: + raise Exception("mozfile.extract: no archive format found for '%s'" % src) + + # namelist returns paths with forward slashes even in windows + top_level_files = [ + os.path.join(dest, name.rstrip("/")) + for name in namelist + if len(name.rstrip("/").split("/")) == 1 + ] + + # namelist doesn't include folders, append these to the list + for name in namelist: + index = name.find("/") + if index != -1: + root = os.path.join(dest, name[:index]) + if root not in top_level_files: + top_level_files.append(root) + + return top_level_files + + +# utilities for removal of files and directories + + +def rmtree(dir): + """Deprecated wrapper method to remove a directory tree. + + Ensure to update your code to use mozfile.remove() directly + + :param dir: directory to be removed + """ + + warnings.warn( + "mozfile.rmtree() is deprecated in favor of mozfile.remove()", + PendingDeprecationWarning, + stacklevel=2, + ) + return remove(dir) + + +def _call_windows_retry(func, args=(), retry_max=5, retry_delay=0.5): + """ + It's possible to see spurious errors on Windows due to various things + keeping a handle to the directory open (explorer, virus scanners, etc) + So we try a few times if it fails with a known error. + retry_delay is multiplied by the number of failed attempts to increase + the likelihood of success in subsequent attempts. + """ + retry_count = 0 + while True: + try: + func(*args) + except OSError as e: + # Error codes are defined in: + # http://docs.python.org/2/library/errno.html#module-errno + if e.errno not in (errno.EACCES, errno.ENOTEMPTY): + raise + + if retry_count == retry_max: + raise + + retry_count += 1 + + print( + '%s() failed for "%s". Reason: %s (%s). Retrying...' + % (func.__name__, args, e.strerror, e.errno) + ) + time.sleep(retry_count * retry_delay) + else: + # If no exception has been thrown it should be done + break + + +def remove(path): + """Removes the specified file, link, or directory tree. + + This is a replacement for shutil.rmtree that works better under + windows. It does the following things: + + - check path access for the current user before trying to remove + - retry operations on some known errors due to various things keeping + a handle on file paths - like explorer, virus scanners, etc. The + known errors are errno.EACCES and errno.ENOTEMPTY, and it will + retry up to 5 five times with a delay of (failed_attempts * 0.5) seconds + between each attempt. + + Note that no error will be raised if the given path does not exists. + + :param path: path to be removed + """ + + import shutil + + def _call_with_windows_retry(*args, **kwargs): + try: + _call_windows_retry(*args, **kwargs) + except OSError as e: + # The file or directory to be removed doesn't exist anymore + if e.errno != errno.ENOENT: + raise + + def _update_permissions(path): + """Sets specified pemissions depending on filetype""" + if os.path.islink(path): + # Path is a symlink which we don't have to modify + # because it should already have all the needed permissions + return + + stats = os.stat(path) + + if os.path.isfile(path): + mode = stats.st_mode | stat.S_IWUSR + elif os.path.isdir(path): + mode = stats.st_mode | stat.S_IWUSR | stat.S_IXUSR + else: + # Not supported type + return + + _call_with_windows_retry(os.chmod, (path, mode)) + + if not os.path.lexists(path): + return + + """ + On Windows, adds '\\\\?\\' to paths which match ^[A-Za-z]:\\.* to access + files or directories that exceed MAX_PATH(260) limitation or that ends + with a period. + """ + if ( + sys.platform in ("win32", "cygwin") + and len(path) >= 3 + and path[1] == ":" + and path[2] == "\\" + ): + path = "\\\\?\\%s" % path + + if os.path.isfile(path) or os.path.islink(path): + # Verify the file or link is read/write for the current user + _update_permissions(path) + _call_with_windows_retry(os.remove, (path,)) + + elif os.path.isdir(path): + # Verify the directory is read/write/execute for the current user + _update_permissions(path) + + # We're ensuring that every nested item has writable permission. + for root, dirs, files in os.walk(path): + for entry in dirs + files: + _update_permissions(os.path.join(root, entry)) + _call_with_windows_retry(shutil.rmtree, (path,)) + + +def copy_contents(srcdir, dstdir, ignore_dangling_symlinks=False): + """ + Copy the contents of the srcdir into the dstdir, preserving + subdirectories. + + If an existing file of the same name exists in dstdir, it will be overwritten. + """ + import shutil + + # dirs_exist_ok was introduced in Python 3.8 + # On earlier versions, or Windows, use the verbose mechanism. + # We use it on Windows because _call_with_windows_retry doesn't allow + # named arguments to be passed. + if (sys.version_info.major < 3 or sys.version_info.minor < 8) or (os.name == "nt"): + names = os.listdir(srcdir) + if not os.path.isdir(dstdir): + os.makedirs(dstdir) + errors = [] + for name in names: + srcname = os.path.join(srcdir, name) + dstname = os.path.join(dstdir, name) + try: + if os.path.islink(srcname): + linkto = os.readlink(srcname) + os.symlink(linkto, dstname) + elif os.path.isdir(srcname): + copy_contents(srcname, dstname) + else: + _call_windows_retry(shutil.copy2, (srcname, dstname)) + except OSError as why: + errors.append((srcname, dstname, str(why))) + except Exception as err: + errors.extend(err) + try: + _call_windows_retry(shutil.copystat, (srcdir, dstdir)) + except OSError as why: + if why.winerror is None: + errors.extend((srcdir, dstdir, str(why))) + if errors: + raise Exception(errors) + else: + shutil.copytree( + srcdir, + dstdir, + dirs_exist_ok=True, + ignore_dangling_symlinks=ignore_dangling_symlinks, + ) + + +def move(src, dst): + """ + Move a file or directory path. + + This is a replacement for shutil.move that works better under windows, + retrying operations on some known errors due to various things keeping + a handle on file paths. + """ + import shutil + + _call_windows_retry(shutil.move, (src, dst)) + + +def depth(directory): + """returns the integer depth of a directory or path relative to '/'""" + + directory = os.path.abspath(directory) + level = 0 + while True: + directory, remainder = os.path.split(directory) + level += 1 + if not remainder: + break + return level + + +def tree(directory, sort_key=lambda x: x.lower()): + """Display tree directory structure for `directory`.""" + vertical_line = "│" + item_marker = "├" + last_child = "└" + + retval = [] + indent = [] + last = {} + top = depth(directory) + + for dirpath, dirnames, filenames in os.walk(directory, topdown=True): + abspath = os.path.abspath(dirpath) + basename = os.path.basename(abspath) + parent = os.path.dirname(abspath) + level = depth(abspath) - top + + # sort articles of interest + for resource in (dirnames, filenames): + resource[:] = sorted(resource, key=sort_key) + + if level > len(indent): + indent.append(vertical_line) + indent = indent[:level] + + if dirnames: + files_end = item_marker + last[abspath] = dirnames[-1] + else: + files_end = last_child + + if last.get(parent) == os.path.basename(abspath): + # last directory of parent + dirpath_mark = last_child + indent[-1] = " " + elif not indent: + dirpath_mark = "" + else: + dirpath_mark = item_marker + + # append the directory and piece of tree structure + # if the top-level entry directory, print as passed + retval.append( + "%s%s%s" + % ("".join(indent[:-1]), dirpath_mark, basename if retval else directory) + ) + # add the files + if filenames: + last_file = filenames[-1] + retval.extend( + [ + ( + "%s%s%s" + % ( + "".join(indent), + files_end if filename == last_file else item_marker, + filename, + ) + ) + for index, filename in enumerate(filenames) + ] + ) + + return "\n".join(retval) + + +def which(cmd, mode=os.F_OK | os.X_OK, path=None, exts=None, extra_search_dirs=()): + """A wrapper around `shutil.which` to make the behavior on Windows + consistent with other platforms. + + On non-Windows platforms, this is a direct call to `shutil.which`. On + Windows, this: + + * Ensures that `cmd` without an extension will be found. Previously it was + only found if it had an extension in `PATHEXT`. + * Ensures the absolute path to the binary is returned. Previously if the + binary was found in `cwd`, a relative path was returned. + * Checks the Windows registry if shutil.which doesn't come up with anything. + + The arguments are the same as the ones in `shutil.which`. In addition there + is an `exts` argument that only has an effect on Windows. This is used to + set a custom value for PATHEXT and is formatted as a list of file + extensions. + + extra_search_dirs is a convenience argument. If provided, the strings in + the sequence will be appended to the END of the given `path`. + """ + from shutil import which as shutil_which + + if isinstance(path, (list, tuple)): + path = os.pathsep.join(path) + + if not path: + path = os.environ.get("PATH", os.defpath) + + if extra_search_dirs: + path = os.pathsep.join([path] + list(extra_search_dirs)) + + if sys.platform != "win32": + return shutil_which(cmd, mode=mode, path=path) + + oldexts = os.environ.get("PATHEXT", "") + if not exts: + exts = oldexts.split(os.pathsep) + + # This ensures that `cmd` without any extensions will be found. + # See: https://bugs.python.org/issue31405 + if "." not in exts: + exts.append(".") + + os.environ["PATHEXT"] = os.pathsep.join(exts) + try: + path = shutil_which(cmd, mode=mode, path=path) + if path: + return os.path.abspath(path.rstrip(".")) + finally: + if oldexts: + os.environ["PATHEXT"] = oldexts + else: + del os.environ["PATHEXT"] + + # If we've gotten this far, we need to check for registered executables + # before giving up. + try: + import winreg + except ImportError: + import _winreg as winreg + if not cmd.lower().endswith(".exe"): + cmd += ".exe" + try: + ret = winreg.QueryValue( + winreg.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\%s" % cmd, + ) + return os.path.abspath(ret) if ret else None + except winreg.error: + return None + + +# utilities for temporary resources + + +class NamedTemporaryFile(object): + """ + Like tempfile.NamedTemporaryFile except it works on Windows + in the case where you open the created file a second time. + + This behaves very similarly to tempfile.NamedTemporaryFile but may + not behave exactly the same. For example, this function does not + prevent fd inheritance by children. + + Example usage: + + with NamedTemporaryFile() as fh: + fh.write(b'foobar') + + print('Filename: %s' % fh.name) + + see https://bugzilla.mozilla.org/show_bug.cgi?id=821362 + """ + + def __init__( + self, mode="w+b", bufsize=-1, suffix="", prefix="tmp", dir=None, delete=True + ): + import tempfile + + fd, path = tempfile.mkstemp(suffix, prefix, dir, "t" in mode) + os.close(fd) + + self.file = open(path, mode) + self._path = path + self._delete = delete + self._unlinked = False + + def __getattr__(self, k): + return getattr(self.__dict__["file"], k) + + def __iter__(self): + return self.__dict__["file"] + + def __enter__(self): + self.file.__enter__() + return self + + def __exit__(self, exc, value, tb): + self.file.__exit__(exc, value, tb) + if self.__dict__["_delete"]: + os.unlink(self.__dict__["_path"]) + self._unlinked = True + + def __del__(self): + if self.__dict__["_unlinked"]: + return + self.file.__exit__(None, None, None) + if self.__dict__["_delete"]: + os.unlink(self.__dict__["_path"]) + + +@contextmanager +def TemporaryDirectory(): + """ + create a temporary directory using tempfile.mkdtemp, and then clean it up. + + Example usage: + with TemporaryDirectory() as tmp: + open(os.path.join(tmp, "a_temp_file"), "w").write("data") + + """ + + import shutil + import tempfile + + tempdir = tempfile.mkdtemp() + try: + yield tempdir + finally: + shutil.rmtree(tempdir) + + +# utilities dealing with URLs + + +def is_url(thing): + """ + Return True if thing looks like a URL. + """ + + parsed = urllib.parse.urlparse(thing) + if "scheme" in parsed: + return len(parsed.scheme) >= 2 + else: + return len(parsed[0]) >= 2 + + +def load(resource): + """ + open a file or URL for reading. If the passed resource string is not a URL, + or begins with 'file://', return a ``file``. Otherwise, return the + result of urllib.urlopen() + """ + + # handle file URLs separately due to python stdlib limitations + if resource.startswith("file://"): + resource = resource[len("file://") :] + + if not is_url(resource): + # if no scheme is given, it is a file path + return open(resource) + + return urllib.request.urlopen(resource) + + +# see https://docs.python.org/3/whatsnew/3.12.html#imp +def load_source(modname, filename): + import importlib.machinery + import importlib.util + + loader = importlib.machinery.SourceFileLoader(modname, filename) + spec = importlib.util.spec_from_file_location(modname, filename, loader=loader) + module = importlib.util.module_from_spec(spec) + sys.modules[module.__name__] = module + loader.exec_module(module) + return module + + +# We can't depend on mozpack.path here, so copy the 'match' function over. + +re_cache = {} +# Python versions < 3.7 return r'\/' for re.escape('/'). +if re.escape("/") == "/": + MATCH_STAR_STAR_RE = re.compile(r"(^|/)\\\*\\\*/") + MATCH_STAR_STAR_END_RE = re.compile(r"(^|/)\\\*\\\*$") +else: + MATCH_STAR_STAR_RE = re.compile(r"(^|\\\/)\\\*\\\*\\\/") + MATCH_STAR_STAR_END_RE = re.compile(r"(^|\\\/)\\\*\\\*$") + + +def match(path, pattern): + """ + Return whether the given path matches the given pattern. + An asterisk can be used to match any string, including the null string, in + one part of the path: + + ``foo`` matches ``*``, ``f*`` or ``fo*o`` + + However, an asterisk matching a subdirectory may not match the null string: + + ``foo/bar`` does *not* match ``foo/*/bar`` + + If the pattern matches one of the ancestor directories of the path, the + patch is considered matching: + + ``foo/bar`` matches ``foo`` + + Two adjacent asterisks can be used to match files and zero or more + directories and subdirectories. + + ``foo/bar`` matches ``foo/**/bar``, or ``**/bar`` + """ + if not pattern: + return True + if pattern not in re_cache: + p = re.escape(pattern) + p = MATCH_STAR_STAR_RE.sub(r"\1(?:.+/)?", p) + p = MATCH_STAR_STAR_END_RE.sub(r"(?:\1.+)?", p) + p = p.replace(r"\*", "[^/]*") + "(?:/.*)?$" + re_cache[pattern] = re.compile(p) + return re_cache[pattern].match(path) is not None diff --git a/testing/mozbase/mozfile/setup.cfg b/testing/mozbase/mozfile/setup.cfg new file mode 100644 index 0000000000..2a9acf13da --- /dev/null +++ b/testing/mozbase/mozfile/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 diff --git a/testing/mozbase/mozfile/setup.py b/testing/mozbase/mozfile/setup.py new file mode 100644 index 0000000000..172df3e68e --- /dev/null +++ b/testing/mozbase/mozfile/setup.py @@ -0,0 +1,34 @@ +# 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 setuptools import setup + +PACKAGE_NAME = "mozfile" +PACKAGE_VERSION = "3.0.0" + +setup( + name=PACKAGE_NAME, + version=PACKAGE_VERSION, + description="Library of file utilities for use in Mozilla testing", + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + classifiers=[ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", + ], + keywords="mozilla", + author="Mozilla Automation and Tools team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + license="MPL", + packages=["mozfile"], + include_package_data=True, + zip_safe=False, + install_requires=["six >= 1.13.0"], + tests_require=["wptserve"], +) diff --git a/testing/mozbase/mozfile/tests/files/missing_file_attributes.zip b/testing/mozbase/mozfile/tests/files/missing_file_attributes.zip Binary files differnew file mode 100644 index 0000000000..2b5409e89c --- /dev/null +++ b/testing/mozbase/mozfile/tests/files/missing_file_attributes.zip diff --git a/testing/mozbase/mozfile/tests/files/which/baz b/testing/mozbase/mozfile/tests/files/which/baz new file mode 100755 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/mozbase/mozfile/tests/files/which/baz diff --git a/testing/mozbase/mozfile/tests/files/which/baz.exe b/testing/mozbase/mozfile/tests/files/which/baz.exe new file mode 100755 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/mozbase/mozfile/tests/files/which/baz.exe diff --git a/testing/mozbase/mozfile/tests/files/which/registered/quux.exe b/testing/mozbase/mozfile/tests/files/which/registered/quux.exe new file mode 100755 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/mozbase/mozfile/tests/files/which/registered/quux.exe diff --git a/testing/mozbase/mozfile/tests/files/which/unix/baz.exe b/testing/mozbase/mozfile/tests/files/which/unix/baz.exe new file mode 100755 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/mozbase/mozfile/tests/files/which/unix/baz.exe diff --git a/testing/mozbase/mozfile/tests/files/which/unix/file b/testing/mozbase/mozfile/tests/files/which/unix/file new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/mozbase/mozfile/tests/files/which/unix/file diff --git a/testing/mozbase/mozfile/tests/files/which/unix/foo b/testing/mozbase/mozfile/tests/files/which/unix/foo new file mode 100755 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/mozbase/mozfile/tests/files/which/unix/foo diff --git a/testing/mozbase/mozfile/tests/files/which/win/bar b/testing/mozbase/mozfile/tests/files/which/win/bar new file mode 100755 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/mozbase/mozfile/tests/files/which/win/bar diff --git a/testing/mozbase/mozfile/tests/files/which/win/baz.exe b/testing/mozbase/mozfile/tests/files/which/win/baz.exe new file mode 100755 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/mozbase/mozfile/tests/files/which/win/baz.exe diff --git a/testing/mozbase/mozfile/tests/files/which/win/foo b/testing/mozbase/mozfile/tests/files/which/win/foo new file mode 100755 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/mozbase/mozfile/tests/files/which/win/foo diff --git a/testing/mozbase/mozfile/tests/files/which/win/foo.exe b/testing/mozbase/mozfile/tests/files/which/win/foo.exe new file mode 100755 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/mozbase/mozfile/tests/files/which/win/foo.exe diff --git a/testing/mozbase/mozfile/tests/manifest.toml b/testing/mozbase/mozfile/tests/manifest.toml new file mode 100644 index 0000000000..643b9c4c6e --- /dev/null +++ b/testing/mozbase/mozfile/tests/manifest.toml @@ -0,0 +1,18 @@ +[DEFAULT] +subsuite = "mozbase" + +["test_extract.py"] + +["test_load.py"] + +["test_move_remove.py"] + +["test_tempdir.py"] + +["test_tempfile.py"] + +["test_tree.py"] + +["test_url.py"] + +["test_which.py"] diff --git a/testing/mozbase/mozfile/tests/stubs.py b/testing/mozbase/mozfile/tests/stubs.py new file mode 100644 index 0000000000..3c1bd47207 --- /dev/null +++ b/testing/mozbase/mozfile/tests/stubs.py @@ -0,0 +1,56 @@ +import os +import shutil +import tempfile + +# stub file paths +files = [ + ("foo.txt",), + ( + "foo", + "bar.txt", + ), + ( + "foo", + "bar", + "fleem.txt", + ), + ( + "foobar", + "fleem.txt", + ), + ("bar.txt",), + ( + "nested_tree", + "bar", + "fleem.txt", + ), + ("readonly.txt",), +] + + +def create_empty_stub(): + tempdir = tempfile.mkdtemp() + return tempdir + + +def create_stub(tempdir=None): + """create a stub directory""" + + tempdir = tempdir or tempfile.mkdtemp() + try: + for path in files: + fullpath = os.path.join(tempdir, *path) + dirname = os.path.dirname(fullpath) + if not os.path.exists(dirname): + os.makedirs(dirname) + contents = path[-1] + f = open(fullpath, "w") + f.write(contents) + f.close() + return tempdir + except Exception: + try: + shutil.rmtree(tempdir) + except Exception: + pass + raise diff --git a/testing/mozbase/mozfile/tests/test_copycontents.py b/testing/mozbase/mozfile/tests/test_copycontents.py new file mode 100644 index 0000000000..b829d7b3a4 --- /dev/null +++ b/testing/mozbase/mozfile/tests/test_copycontents.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python + +import os +import shutil +import unittest + +import mozfile +import mozunit +import stubs + + +class MozfileCopyContentsTestCase(unittest.TestCase): + """Test our ability to copy the contents of directories""" + + def _directory_is_subset(self, set_, subset_): + """ + Confirm that all the contents of 'subset_' are contained in 'set_' + """ + names = os.listdir(subset_) + for name in names: + full_set_path = os.path.join(set_, name) + full_subset_path = os.path.join(subset_, name) + if os.path.isdir(full_subset_path): + self.assertTrue(os.path.isdir(full_set_path)) + self._directory_is_subset(full_set_path, full_subset_path) + elif os.path.islink(full_subset_path): + self.assertTrue(os.path.islink(full_set_path)) + else: + self.assertTrue(os.stat(full_set_path)) + + def _directories_are_equal(self, dir1, dir2): + """ + Confirm that the contents of 'dir1' are the same as 'dir2' + """ + names1 = os.listdir(dir1) + names2 = os.listdir(dir2) + self.assertTrue(len(names1) == len(names2)) + for name in names1: + self.assertTrue(name in names2) + dir1_path = os.path.join(dir1, name) + dir2_path = os.path.join(dir2, name) + if os.path.isdir(dir1_path): + self.assertTrue(os.path.isdir(dir2_path)) + self._directories_are_equal(dir1_path, dir2_path) + elif os.path.islink(dir1_path): + self.assertTrue(os.path.islink(dir2_path)) + else: + self.assertTrue(os.stat(dir2_path)) + + def test_copy_empty_directory(self): + tempdir = stubs.create_empty_stub() + dstdir = stubs.create_empty_stub() + self.assertTrue(os.path.isdir(tempdir)) + + mozfile.copy_contents(tempdir, dstdir) + self._directories_are_equal(dstdir, tempdir) + + if os.path.isdir(tempdir): + shutil.rmtree(tempdir) + if os.path.isdir(dstdir): + shutil.rmtree(dstdir) + + def test_copy_full_directory(self): + tempdir = stubs.create_stub() + dstdir = stubs.create_empty_stub() + self.assertTrue(os.path.isdir(tempdir)) + + mozfile.copy_contents(tempdir, dstdir) + self._directories_are_equal(dstdir, tempdir) + + if os.path.isdir(tempdir): + shutil.rmtree(tempdir) + if os.path.isdir(dstdir): + shutil.rmtree(dstdir) + + def test_copy_full_directory_with_existing_file(self): + tempdir = stubs.create_stub() + dstdir = stubs.create_empty_stub() + + filename = "i_dont_exist_in_tempdir" + f = open(os.path.join(dstdir, filename), "w") + f.write("Hello World") + f.close() + + self.assertTrue(os.path.isdir(tempdir)) + + mozfile.copy_contents(tempdir, dstdir) + self._directory_is_subset(dstdir, tempdir) + self.assertTrue(os.path.exists(os.path.join(dstdir, filename))) + + if os.path.isdir(tempdir): + shutil.rmtree(tempdir) + if os.path.isdir(dstdir): + shutil.rmtree(dstdir) + + def test_copy_full_directory_with_overlapping_file(self): + tempdir = stubs.create_stub() + dstdir = stubs.create_empty_stub() + + filename = "i_do_exist_in_tempdir" + for d in [tempdir, dstdir]: + f = open(os.path.join(d, filename), "w") + f.write("Hello " + d) + f.close() + + self.assertTrue(os.path.isdir(tempdir)) + self.assertTrue(os.path.exists(os.path.join(tempdir, filename))) + self.assertTrue(os.path.exists(os.path.join(dstdir, filename))) + + line = open(os.path.join(dstdir, filename), "r").readlines()[0] + self.assertTrue(line == "Hello " + dstdir) + + mozfile.copy_contents(tempdir, dstdir) + + line = open(os.path.join(dstdir, filename), "r").readlines()[0] + self.assertTrue(line == "Hello " + tempdir) + self._directories_are_equal(tempdir, dstdir) + + if os.path.isdir(tempdir): + shutil.rmtree(tempdir) + if os.path.isdir(dstdir): + shutil.rmtree(dstdir) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozfile/tests/test_extract.py b/testing/mozbase/mozfile/tests/test_extract.py new file mode 100644 index 0000000000..c2675d77f7 --- /dev/null +++ b/testing/mozbase/mozfile/tests/test_extract.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python + +import os +import tarfile +import tempfile +import zipfile + +import mozfile +import mozunit +import pytest +import stubs + + +@pytest.fixture +def ensure_directory_contents(): + """ensure the directory contents match""" + + def inner(directory): + for f in stubs.files: + path = os.path.join(directory, *f) + exists = os.path.exists(path) + if not exists: + print("%s does not exist" % (os.path.join(f))) + assert exists + if exists: + contents = open(path).read().strip() + assert contents == f[-1] + + return inner + + +@pytest.fixture(scope="module") +def tarpath(tmpdir_factory): + """create a stub tarball for testing""" + tmpdir = tmpdir_factory.mktemp("test_extract") + + tempdir = tmpdir.join("stubs").strpath + stubs.create_stub(tempdir) + filename = tmpdir.join("bundle.tar").strpath + archive = tarfile.TarFile(filename, mode="w") + for path in stubs.files: + archive.add(os.path.join(tempdir, *path), arcname=os.path.join(*path)) + archive.close() + + assert os.path.exists(filename) + return filename + + +@pytest.fixture(scope="module") +def zippath(tmpdir_factory): + """create a stub zipfile for testing""" + tmpdir = tmpdir_factory.mktemp("test_extract") + + tempdir = tmpdir.join("stubs").strpath + stubs.create_stub(tempdir) + filename = tmpdir.join("bundle.zip").strpath + archive = zipfile.ZipFile(filename, mode="w") + for path in stubs.files: + archive.write(os.path.join(tempdir, *path), arcname=os.path.join(*path)) + archive.close() + + assert os.path.exists(filename) + return filename + + +@pytest.fixture(scope="module", params=["tar", "zip"]) +def bundlepath(request, tarpath, zippath): + if request.param == "tar": + return tarpath + else: + return zippath + + +def test_extract(tmpdir, bundlepath, ensure_directory_contents): + """test extracting a zipfile""" + dest = tmpdir.mkdir("dest").strpath + mozfile.extract(bundlepath, dest) + ensure_directory_contents(dest) + + +def test_extract_zipfile_missing_file_attributes(tmpdir): + """if files do not have attributes set the default permissions have to be inherited.""" + _zipfile = os.path.join( + os.path.dirname(__file__), "files", "missing_file_attributes.zip" + ) + assert os.path.exists(_zipfile) + dest = tmpdir.mkdir("dest").strpath + + # Get the default file permissions for the user + fname = os.path.join(dest, "foo") + with open(fname, "w"): + pass + default_stmode = os.stat(fname).st_mode + + files = mozfile.extract_zip(_zipfile, dest) + for filename in files: + assert os.stat(os.path.join(dest, filename)).st_mode == default_stmode + + +def test_extract_non_archive(tarpath, zippath): + """test the generalized extract function""" + # test extracting some non-archive; this should fail + fd, filename = tempfile.mkstemp() + os.write(fd, b"This is not a zipfile or tarball") + os.close(fd) + exception = None + + try: + dest = tempfile.mkdtemp() + mozfile.extract(filename, dest) + except Exception as exc: + exception = exc + finally: + os.remove(filename) + os.rmdir(dest) + + assert isinstance(exception, Exception) + + +def test_extract_ignore(tmpdir, bundlepath): + dest = tmpdir.mkdir("dest").strpath + ignore = ("foo", "**/fleem.txt", "read*.txt") + mozfile.extract(bundlepath, dest, ignore=ignore) + + assert sorted(os.listdir(dest)) == ["bar.txt", "foo.txt"] + + +def test_tarball_escape(tmpdir): + """Ensures that extracting a tarball can't write outside of the intended + destination directory. + """ + workdir = tmpdir.mkdir("workdir") + os.chdir(workdir) + + # Generate a "malicious" bundle. + with open("bad.txt", "w") as fh: + fh.write("pwned!") + + def change_name(tarinfo): + tarinfo.name = "../" + tarinfo.name + return tarinfo + + with tarfile.open("evil.tar", "w:xz") as tar: + tar.add("bad.txt", filter=change_name) + + with pytest.raises(RuntimeError): + mozfile.extract_tarball("evil.tar", workdir) + assert not os.path.exists(tmpdir.join("bad.txt")) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozfile/tests/test_load.py b/testing/mozbase/mozfile/tests/test_load.py new file mode 100755 index 0000000000..7a3896e33b --- /dev/null +++ b/testing/mozbase/mozfile/tests/test_load.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python + +""" +tests for mozfile.load +""" + +import mozunit +import pytest +from mozfile import load +from wptserve.handlers import handler +from wptserve.server import WebTestHttpd + + +@pytest.fixture(name="httpd_url") +def fixture_httpd_url(): + """Yield a started WebTestHttpd server.""" + + @handler + def example(request, response): + """Example request handler.""" + body = b"example" + return ( + 200, + [("Content-type", "text/plain"), ("Content-length", len(body))], + body, + ) + + httpd = WebTestHttpd(host="127.0.0.1", routes=[("GET", "*", example)]) + + httpd.start() + yield httpd.get_url() + httpd.stop() + + +def test_http(httpd_url): + """Test with WebTestHttpd and a http:// URL.""" + content = load(httpd_url).read() + assert content == b"example" + + +@pytest.fixture(name="temporary_file") +def fixture_temporary_file(tmpdir): + """Yield a path to a temporary file.""" + foobar = tmpdir.join("foobar.txt") + foobar.write("hello world") + + yield str(foobar) + + foobar.remove() + + +def test_file_path(temporary_file): + """Test loading from a file path.""" + assert load(temporary_file).read() == "hello world" + + +def test_file_url(temporary_file): + """Test loading from a file URL.""" + assert load("file://%s" % temporary_file).read() == "hello world" + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozfile/tests/test_move_remove.py b/testing/mozbase/mozfile/tests/test_move_remove.py new file mode 100644 index 0000000000..0679c6c3fa --- /dev/null +++ b/testing/mozbase/mozfile/tests/test_move_remove.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python + +import errno +import os +import shutil +import stat +import threading +import time +import unittest +from contextlib import contextmanager + +import mozfile +import mozinfo +import mozunit +import stubs + + +def mark_readonly(path): + """Removes all write permissions from given file/directory. + + :param path: path of directory/file of which modes must be changed + """ + mode = os.stat(path)[stat.ST_MODE] + os.chmod(path, mode & ~stat.S_IWUSR & ~stat.S_IWGRP & ~stat.S_IWOTH) + + +class FileOpenCloseThread(threading.Thread): + """Helper thread for asynchronous file handling""" + + def __init__(self, path, delay, delete=False): + threading.Thread.__init__(self) + self.file_opened = threading.Event() + self.delay = delay + self.path = path + self.delete = delete + + def run(self): + with open(self.path): + self.file_opened.set() + time.sleep(self.delay) + if self.delete: + try: + os.remove(self.path) + except Exception: + pass + + +@contextmanager +def wait_file_opened_in_thread(*args, **kwargs): + thread = FileOpenCloseThread(*args, **kwargs) + thread.start() + thread.file_opened.wait() + try: + yield thread + finally: + thread.join() + + +class MozfileRemoveTestCase(unittest.TestCase): + """Test our ability to remove directories and files""" + + def setUp(self): + # Generate a stub + self.tempdir = stubs.create_stub() + + def tearDown(self): + if os.path.isdir(self.tempdir): + shutil.rmtree(self.tempdir) + + def test_remove_directory(self): + """Test the removal of a directory""" + self.assertTrue(os.path.isdir(self.tempdir)) + mozfile.remove(self.tempdir) + self.assertFalse(os.path.exists(self.tempdir)) + + def test_remove_directory_with_open_file(self): + """Test removing a directory with an open file""" + # Open a file in the generated stub + filepath = os.path.join(self.tempdir, *stubs.files[1]) + f = open(filepath, "w") + f.write("foo-bar") + + # keep file open and then try removing the dir-tree + if mozinfo.isWin: + # On the Windows family WindowsError should be raised. + self.assertRaises(OSError, mozfile.remove, self.tempdir) + self.assertTrue(os.path.exists(self.tempdir)) + else: + # Folder should be deleted on all other platforms + mozfile.remove(self.tempdir) + self.assertFalse(os.path.exists(self.tempdir)) + + def test_remove_closed_file(self): + """Test removing a closed file""" + # Open a file in the generated stub + filepath = os.path.join(self.tempdir, *stubs.files[1]) + with open(filepath, "w") as f: + f.write("foo-bar") + + # Folder should be deleted on all platforms + mozfile.remove(self.tempdir) + self.assertFalse(os.path.exists(self.tempdir)) + + def test_removing_open_file_with_retry(self): + """Test removing a file in use with retry""" + filepath = os.path.join(self.tempdir, *stubs.files[1]) + + with wait_file_opened_in_thread(filepath, 0.2): + # on windows first attempt will fail, + # and it will be retried until the thread leave the handle + mozfile.remove(filepath) + + # Check deletion was successful + self.assertFalse(os.path.exists(filepath)) + + def test_removing_already_deleted_file_with_retry(self): + """Test removing a meanwhile removed file with retry""" + filepath = os.path.join(self.tempdir, *stubs.files[1]) + + with wait_file_opened_in_thread(filepath, 0.2, True): + # on windows first attempt will fail, and before + # the retry the opened file will be deleted in the thread + mozfile.remove(filepath) + + # Check deletion was successful + self.assertFalse(os.path.exists(filepath)) + + def test_remove_readonly_tree(self): + """Test removing a read-only directory""" + + dirpath = os.path.join(self.tempdir, "nested_tree") + mark_readonly(dirpath) + + # However, mozfile should change write permissions and remove dir. + mozfile.remove(dirpath) + + self.assertFalse(os.path.exists(dirpath)) + + def test_remove_readonly_file(self): + """Test removing read-only files""" + filepath = os.path.join(self.tempdir, *stubs.files[1]) + mark_readonly(filepath) + + # However, mozfile should change write permission and then remove file. + mozfile.remove(filepath) + + self.assertFalse(os.path.exists(filepath)) + + @unittest.skipIf(mozinfo.isWin, "Symlinks are not supported on Windows") + def test_remove_symlink(self): + """Test removing a symlink""" + file_path = os.path.join(self.tempdir, *stubs.files[1]) + symlink_path = os.path.join(self.tempdir, "symlink") + + os.symlink(file_path, symlink_path) + self.assertTrue(os.path.islink(symlink_path)) + + # The linked folder and files should not be deleted + mozfile.remove(symlink_path) + self.assertFalse(os.path.exists(symlink_path)) + self.assertTrue(os.path.exists(file_path)) + + @unittest.skipIf(mozinfo.isWin, "Symlinks are not supported on Windows") + def test_remove_symlink_in_subfolder(self): + """Test removing a folder with an contained symlink""" + file_path = os.path.join(self.tempdir, *stubs.files[0]) + dir_path = os.path.dirname(os.path.join(self.tempdir, *stubs.files[1])) + symlink_path = os.path.join(dir_path, "symlink") + + os.symlink(file_path, symlink_path) + self.assertTrue(os.path.islink(symlink_path)) + + # The folder with the contained symlink will be deleted but not the + # original linked file + mozfile.remove(dir_path) + self.assertFalse(os.path.exists(dir_path)) + self.assertFalse(os.path.exists(symlink_path)) + self.assertTrue(os.path.exists(file_path)) + + @unittest.skipIf(mozinfo.isWin, "Symlinks are not supported on Windows") + def test_remove_broken_symlink(self): + """Test removing a folder with an contained symlink""" + file_path = os.path.join(self.tempdir, "readonly.txt") + working_link = os.path.join(self.tempdir, "link_to_readonly.txt") + broken_link = os.path.join(self.tempdir, "broken_link") + os.symlink(file_path, working_link) + os.symlink(os.path.join(self.tempdir, "broken.txt"), broken_link) + + self.assertTrue(os.path.exists(file_path)) + self.assertTrue(os.path.islink(working_link)) + self.assertTrue(os.path.islink(broken_link)) + + mozfile.remove(working_link) + self.assertFalse(os.path.lexists(working_link)) + self.assertTrue(os.path.exists(file_path)) + + mozfile.remove(broken_link) + self.assertFalse(os.path.lexists(broken_link)) + + @unittest.skipIf( + mozinfo.isWin or not os.geteuid(), + "Symlinks are not supported on Windows and cannot run test as root", + ) + def test_remove_symlink_for_system_path(self): + """Test removing a symlink which points to a system folder""" + symlink_path = os.path.join(self.tempdir, "symlink") + + os.symlink(os.path.dirname(self.tempdir), symlink_path) + self.assertTrue(os.path.islink(symlink_path)) + + # The folder with the contained symlink will be deleted but not the + # original linked file + mozfile.remove(symlink_path) + self.assertFalse(os.path.exists(symlink_path)) + + def test_remove_path_that_does_not_exists(self): + not_existing_path = os.path.join(self.tempdir, "I_do_not_not_exists") + try: + mozfile.remove(not_existing_path) + except OSError as exc: + if exc.errno == errno.ENOENT: + self.fail("removing non existing path must not raise error") + raise + + +class MozFileMoveTestCase(unittest.TestCase): + def setUp(self): + # Generate a stub + self.tempdir = stubs.create_stub() + self.addCleanup(mozfile.rmtree, self.tempdir) + + def test_move_file(self): + file_path = os.path.join(self.tempdir, *stubs.files[1]) + moved_path = file_path + ".moved" + self.assertTrue(os.path.isfile(file_path)) + self.assertFalse(os.path.exists(moved_path)) + mozfile.move(file_path, moved_path) + self.assertFalse(os.path.exists(file_path)) + self.assertTrue(os.path.isfile(moved_path)) + + def test_move_file_with_retry(self): + file_path = os.path.join(self.tempdir, *stubs.files[1]) + moved_path = file_path + ".moved" + + with wait_file_opened_in_thread(file_path, 0.2): + # first move attempt should fail on windows and be retried + mozfile.move(file_path, moved_path) + self.assertFalse(os.path.exists(file_path)) + self.assertTrue(os.path.isfile(moved_path)) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozfile/tests/test_tempdir.py b/testing/mozbase/mozfile/tests/test_tempdir.py new file mode 100644 index 0000000000..ba16b478b6 --- /dev/null +++ b/testing/mozbase/mozfile/tests/test_tempdir.py @@ -0,0 +1,44 @@ +#!/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/. + +""" +tests for mozfile.TemporaryDirectory +""" + +import os +import unittest + +import mozunit +from mozfile import TemporaryDirectory + + +class TestTemporaryDirectory(unittest.TestCase): + def test_removed(self): + """ensure that a TemporaryDirectory gets removed""" + path = None + with TemporaryDirectory() as tmp: + path = tmp + self.assertTrue(os.path.isdir(tmp)) + tmpfile = os.path.join(tmp, "a_temp_file") + open(tmpfile, "w").write("data") + self.assertTrue(os.path.isfile(tmpfile)) + self.assertFalse(os.path.isdir(path)) + self.assertFalse(os.path.exists(path)) + + def test_exception(self): + """ensure that TemporaryDirectory handles exceptions""" + path = None + with self.assertRaises(Exception): + with TemporaryDirectory() as tmp: + path = tmp + self.assertTrue(os.path.isdir(tmp)) + raise Exception("oops") + self.assertFalse(os.path.isdir(path)) + self.assertFalse(os.path.exists(path)) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozfile/tests/test_tempfile.py b/testing/mozbase/mozfile/tests/test_tempfile.py new file mode 100644 index 0000000000..3e250d6a76 --- /dev/null +++ b/testing/mozbase/mozfile/tests/test_tempfile.py @@ -0,0 +1,105 @@ +#!/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/. + +""" +tests for mozfile.NamedTemporaryFile +""" +import os +import unittest + +import mozfile +import mozunit +import six + + +class TestNamedTemporaryFile(unittest.TestCase): + """test our fix for NamedTemporaryFile""" + + def test_named_temporary_file(self): + """Ensure the fix for re-opening a NamedTemporaryFile works + + Refer to https://bugzilla.mozilla.org/show_bug.cgi?id=818777 + and https://bugzilla.mozilla.org/show_bug.cgi?id=821362 + """ + + test_string = b"A simple test" + with mozfile.NamedTemporaryFile() as temp: + # Test we can write to file + temp.write(test_string) + # Forced flush, so that we can read later + temp.flush() + + # Test we can open the file again on all platforms + self.assertEqual(open(temp.name, "rb").read(), test_string) + + def test_iteration(self): + """ensure the line iterator works""" + + # make a file and write to it + tf = mozfile.NamedTemporaryFile() + notes = [b"doe", b"rae", b"mi"] + for note in notes: + tf.write(b"%s\n" % note) + tf.flush() + + # now read from it + tf.seek(0) + lines = [line.rstrip(b"\n") for line in tf.readlines()] + self.assertEqual(lines, notes) + + # now read from it iteratively + lines = [] + for line in tf: + lines.append(line.strip()) + self.assertEqual(lines, []) # because we did not seek(0) + tf.seek(0) + lines = [] + for line in tf: + lines.append(line.strip()) + self.assertEqual(lines, notes) + + def test_delete(self): + """ensure ``delete=True/False`` works as expected""" + + # make a deleteable file; ensure it gets cleaned up + path = None + with mozfile.NamedTemporaryFile(delete=True) as tf: + path = tf.name + self.assertTrue(isinstance(path, six.string_types)) + self.assertFalse(os.path.exists(path)) + + # it is also deleted when __del__ is called + # here we will do so explicitly + tf = mozfile.NamedTemporaryFile(delete=True) + path = tf.name + self.assertTrue(os.path.exists(path)) + del tf + self.assertFalse(os.path.exists(path)) + + # Now the same thing but we won't delete the file + path = None + try: + with mozfile.NamedTemporaryFile(delete=False) as tf: + path = tf.name + self.assertTrue(os.path.exists(path)) + finally: + if path and os.path.exists(path): + os.remove(path) + + path = None + try: + tf = mozfile.NamedTemporaryFile(delete=False) + path = tf.name + self.assertTrue(os.path.exists(path)) + del tf + self.assertTrue(os.path.exists(path)) + finally: + if path and os.path.exists(path): + os.remove(path) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozfile/tests/test_tree.py b/testing/mozbase/mozfile/tests/test_tree.py new file mode 100644 index 0000000000..556c1b9139 --- /dev/null +++ b/testing/mozbase/mozfile/tests/test_tree.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python +# coding=UTF-8 + +import os +import shutil +import tempfile +import unittest + +import mozunit +from mozfile import tree + + +class TestTree(unittest.TestCase): + """Test the tree function.""" + + def test_unicode_paths(self): + """Test creating tree structure from a Unicode path.""" + try: + tmpdir = tempfile.mkdtemp(suffix="tmp🍪") + os.mkdir(os.path.join(tmpdir, "dir🍪")) + with open(os.path.join(tmpdir, "file🍪"), "w") as f: + f.write("foo") + + self.assertEqual("{}\n├file🍪\n└dir🍪".format(tmpdir), tree(tmpdir)) + finally: + shutil.rmtree(tmpdir) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozfile/tests/test_url.py b/testing/mozbase/mozfile/tests/test_url.py new file mode 100755 index 0000000000..a19f5f16a8 --- /dev/null +++ b/testing/mozbase/mozfile/tests/test_url.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python + +""" +tests for is_url +""" +import unittest + +import mozunit +from mozfile import is_url + + +class TestIsUrl(unittest.TestCase): + """test the is_url function""" + + def test_is_url(self): + self.assertTrue(is_url("http://mozilla.org")) + self.assertFalse(is_url("/usr/bin/mozilla.org")) + self.assertTrue(is_url("file:///usr/bin/mozilla.org")) + self.assertFalse(is_url("c:\foo\bar")) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozfile/tests/test_which.py b/testing/mozbase/mozfile/tests/test_which.py new file mode 100644 index 0000000000..b02f13ccdf --- /dev/null +++ b/testing/mozbase/mozfile/tests/test_which.py @@ -0,0 +1,63 @@ +# Any copyright is dedicated to the Public Domain. +# https://creativecommons.org/publicdomain/zero/1.0/ + +import os +import sys + +import mozunit +import six +from mozfile import which + +here = os.path.abspath(os.path.dirname(__file__)) + + +def test_which(monkeypatch): + cwd = os.path.join(here, "files", "which") + monkeypatch.chdir(cwd) + + if sys.platform == "win32": + if six.PY3: + import winreg + else: + import _winreg as winreg + bindir = os.path.join(cwd, "win") + monkeypatch.setenv("PATH", bindir) + monkeypatch.setattr(winreg, "QueryValue", (lambda k, sk: None)) + + assert which("foo.exe").lower() == os.path.join(bindir, "foo.exe").lower() + assert which("foo").lower() == os.path.join(bindir, "foo.exe").lower() + assert ( + which("foo", exts=[".FOO", ".BAR"]).lower() + == os.path.join(bindir, "foo").lower() + ) + assert os.environ.get("PATHEXT") != [".FOO", ".BAR"] + assert which("foo.txt") is None + + assert which("bar").lower() == os.path.join(bindir, "bar").lower() + assert which("baz").lower() == os.path.join(cwd, "baz.exe").lower() + + registered_dir = os.path.join(cwd, "registered") + quux = os.path.join(registered_dir, "quux.exe").lower() + + def mock_registry(key, subkey): + assert subkey == ( + r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\quux.exe" + ) + return quux + + monkeypatch.setattr(winreg, "QueryValue", mock_registry) + assert which("quux").lower() == quux + assert which("quux.exe").lower() == quux + + else: + bindir = os.path.join(cwd, "unix") + monkeypatch.setenv("PATH", bindir) + assert which("foo") == os.path.join(bindir, "foo") + assert which("baz") is None + assert which("baz", exts=[".EXE"]) is None + assert "PATHEXT" not in os.environ + assert which("file") is None + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozgeckoprofiler/mozgeckoprofiler/__init__.py b/testing/mozbase/mozgeckoprofiler/mozgeckoprofiler/__init__.py new file mode 100644 index 0000000000..ce0337db09 --- /dev/null +++ b/testing/mozbase/mozgeckoprofiler/mozgeckoprofiler/__init__.py @@ -0,0 +1,17 @@ +# 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/. + +""" +mozgeckoprofiler has utilities to symbolicate and load gecko profiles. +""" +from .profiling import save_gecko_profile, symbolicate_profile_json +from .symbolication import ProfileSymbolicator +from .viewgeckoprofile import view_gecko_profile + +__all__ = [ + "save_gecko_profile", + "symbolicate_profile_json", + "ProfileSymbolicator", + "view_gecko_profile", +] diff --git a/testing/mozbase/mozgeckoprofiler/mozgeckoprofiler/dump_syms_mac b/testing/mozbase/mozgeckoprofiler/mozgeckoprofiler/dump_syms_mac Binary files differnew file mode 100755 index 0000000000..e9b8edf879 --- /dev/null +++ b/testing/mozbase/mozgeckoprofiler/mozgeckoprofiler/dump_syms_mac diff --git a/testing/mozbase/mozgeckoprofiler/mozgeckoprofiler/profiling.py b/testing/mozbase/mozgeckoprofiler/mozgeckoprofiler/profiling.py new file mode 100644 index 0000000000..5a8d9b0269 --- /dev/null +++ b/testing/mozbase/mozgeckoprofiler/mozgeckoprofiler/profiling.py @@ -0,0 +1,85 @@ +# 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/. +import json +import os +import shutil +import tempfile + +from mozlog import get_proxy_logger + +from .symbolication import ProfileSymbolicator + +LOG = get_proxy_logger("profiler") + + +def save_gecko_profile(profile, filename): + with open(filename, "w") as f: + json.dump(profile, f) + + +def symbolicate_profile_json(profile_path, objdir_path): + """ + Symbolicate a single JSON profile. + """ + temp_dir = tempfile.mkdtemp() + missing_symbols_zip = os.path.join(temp_dir, "missingsymbols.zip") + + firefox_symbol_path = os.path.join(objdir_path, "dist", "crashreporter-symbols") + if not os.path.isdir(firefox_symbol_path): + os.mkdir(firefox_symbol_path) + + windows_symbol_path = os.path.join(temp_dir, "windows") + os.mkdir(windows_symbol_path) + + symbol_paths = {"FIREFOX": firefox_symbol_path, "WINDOWS": windows_symbol_path} + + symbolicator = ProfileSymbolicator( + { + # Trace-level logging (verbose) + "enableTracing": 0, + # Fallback server if symbol is not found locally + "remoteSymbolServer": "https://symbolication.services.mozilla.com/symbolicate/v4", + # Maximum number of symbol files to keep in memory + "maxCacheEntries": 2000000, + # Frequency of checking for recent symbols to + # cache (in hours) + "prefetchInterval": 12, + # Oldest file age to prefetch (in hours) + "prefetchThreshold": 48, + # Maximum number of library versions to pre-fetch + # per library + "prefetchMaxSymbolsPerLib": 3, + # Default symbol lookup directories + "defaultApp": "FIREFOX", + "defaultOs": "WINDOWS", + # Paths to .SYM files, expressed internally as a + # mapping of app or platform names to directories + # Note: App & OS names from requests are converted + # to all-uppercase internally + "symbolPaths": symbol_paths, + } + ) + + LOG.info( + "Symbolicating the performance profile... This could take a couple " + "of minutes." + ) + + try: + with open(profile_path, "r", encoding="utf-8") as profile_file: + profile = json.load(profile_file) + symbolicator.dump_and_integrate_missing_symbols(profile, missing_symbols_zip) + symbolicator.symbolicate_profile(profile) + # Overwrite the profile in place. + save_gecko_profile(profile, profile_path) + except MemoryError: + LOG.error( + "Ran out of memory while trying" + " to symbolicate profile {0}".format(profile_path) + ) + except Exception as e: + LOG.error("Encountered an exception during profile symbolication") + LOG.error(e) + + shutil.rmtree(temp_dir) diff --git a/testing/mozbase/mozgeckoprofiler/mozgeckoprofiler/symFileManager.py b/testing/mozbase/mozgeckoprofiler/mozgeckoprofiler/symFileManager.py new file mode 100644 index 0000000000..92cfcf3230 --- /dev/null +++ b/testing/mozbase/mozgeckoprofiler/mozgeckoprofiler/symFileManager.py @@ -0,0 +1,353 @@ +# 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/. +import itertools +import os +import re +import threading +import time +from bisect import bisect + +from mozlog import get_proxy_logger + +LOG = get_proxy_logger("profiler") + +# Libraries to keep prefetched +PREFETCHED_LIBS = ["xul.pdb", "firefox.pdb"] + + +class SymbolInfo: + def __init__(self, addressMap): + self.sortedAddresses = sorted(addressMap.keys()) + self.sortedSymbols = [addressMap[address] for address in self.sortedAddresses] + self.entryCount = len(self.sortedAddresses) + + # TODO: Add checks for address < funcEnd ? + def Lookup(self, address): + nearest = bisect(self.sortedAddresses, address) - 1 + if nearest < 0: + return None + return self.sortedSymbols[nearest] + + def GetEntryCount(self): + return self.entryCount + + +# Singleton for .sym / .nmsym file cache management + + +class SymFileManager: + """This class fetches symbols from files and caches the results. + + options (obj) + symbolPaths : dictionary + Paths to .SYM files, expressed internally as a mapping of app or platform + names to directories. App & OS names from requests are converted to + all-uppercase internally + e.g. { "FIREFOX": "/tmp/path" } + maxCacheEntries : number + Maximum number of symbol files to keep in memory + prefetchInterval : number + Frequency of checking for recent symbols to cache (in hours) + prefetchThreshold : number + Oldest file age to prefetch (in hours) + prefetchMaxSymbolsPerLib : (number) + Maximum number of library versions to pre-fetch per library + """ + + sCache = {} + sCacheCount = 0 + sCacheLock = threading.Lock() + sMruSymbols = [] + + sOptions = {} + sCallbackTimer = None + + def __init__(self, options): + self.sOptions = options + + def GetLibSymbolMap(self, libName, breakpadId, symbolSources): + # Empty lib name means client couldn't associate frame with any lib + if libName == "": + return None + + # Check cache first + libSymbolMap = None + self.sCacheLock.acquire() + try: + if libName in self.sCache and breakpadId in self.sCache[libName]: + libSymbolMap = self.sCache[libName][breakpadId] + self.UpdateMruList(libName, breakpadId) + finally: + self.sCacheLock.release() + + if libSymbolMap is None: + LOG.debug("Need to fetch PDB file for " + libName + " " + breakpadId) + + # Guess the name of the .sym or .nmsym file on disk + if libName[-4:] == ".pdb": + symFileNameWithoutExtension = re.sub(r"\.[^\.]+$", "", libName) + else: + symFileNameWithoutExtension = libName + + # Look in the symbol dirs for this .sym or .nmsym file + for extension, source in itertools.product( + [".sym", ".nmsym"], symbolSources + ): + symFileName = symFileNameWithoutExtension + extension + pathSuffix = ( + os.sep + libName + os.sep + breakpadId + os.sep + symFileName + ) + path = self.sOptions["symbolPaths"][source] + pathSuffix + libSymbolMap = self.FetchSymbolsFromFile(path) + if libSymbolMap: + break + + if not libSymbolMap: + LOG.debug("No matching sym files, tried " + str(symbolSources)) + return None + + LOG.debug( + "Storing libSymbolMap under [" + libName + "][" + breakpadId + "]" + ) + self.sCacheLock.acquire() + try: + self.MaybeEvict(libSymbolMap.GetEntryCount()) + if libName not in self.sCache: + self.sCache[libName] = {} + self.sCache[libName][breakpadId] = libSymbolMap + self.sCacheCount += libSymbolMap.GetEntryCount() + self.UpdateMruList(libName, breakpadId) + LOG.debug( + str(self.sCacheCount) + + " symbols in cache after fetching symbol file" + ) + finally: + self.sCacheLock.release() + + return libSymbolMap + + def FetchSymbolsFromFile(self, path): + try: + symFile = open(path, "r") + except Exception as e: + LOG.debug("Error opening file " + path + ": " + str(e)) + return None + + LOG.debug("Parsing SYM file at " + path) + + try: + symbolMap = {} + lineNum = 0 + publicCount = 0 + funcCount = 0 + if path.endswith(".sym"): + for line in symFile: + lineNum += 1 + if line[0:7] == "PUBLIC ": + line = line.rstrip() + fields = line.split(" ") + if len(fields) < 4: + LOG.debug("Line " + str(lineNum) + " is messed") + continue + if fields[1] == "m": + address = int(fields[2], 16) + symbolMap[address] = " ".join(fields[4:]) + else: + address = int(fields[1], 16) + symbolMap[address] = " ".join(fields[3:]) + publicCount += 1 + elif line[0:5] == "FUNC ": + line = line.rstrip() + fields = line.split(" ") + if len(fields) < 5: + LOG.debug("Line " + str(lineNum) + " is messed") + continue + if fields[1] == "m": + address = int(fields[2], 16) + symbolMap[address] = " ".join(fields[5:]) + else: + address = int(fields[1], 16) + symbolMap[address] = " ".join(fields[4:]) + funcCount += 1 + elif path.endswith(".nmsym"): + addressLength = 0 + for line in symFile: + lineNum += 1 + if line.startswith(" "): + continue + if addressLength == 0: + addressLength = line.find(" ") + address = int(line[0:addressLength], 16) + # Some lines have the form + # "address space letter space symbol", + # some have the form "address space symbol". + # The letter has a meaning, but we ignore it. + if line[addressLength + 2] == " ": + symbol = line[addressLength + 3 :].rstrip() + else: + symbol = line[addressLength + 1 :].rstrip() + symbolMap[address] = symbol + publicCount += 1 + except Exception: + LOG.error("Error parsing SYM file " + path) + return None + + logString = "Found " + str(len(symbolMap)) + " unique entries from " + logString += ( + str(publicCount) + " PUBLIC lines, " + str(funcCount) + " FUNC lines" + ) + LOG.debug(logString) + + return SymbolInfo(symbolMap) + + def PrefetchRecentSymbolFiles(self): + """This method runs in a loop. Use the options "prefetchThreshold" to adjust""" + global PREFETCHED_LIBS + + LOG.info("Prefetching recent symbol files") + # Schedule next timer callback + interval = self.sOptions["prefetchInterval"] * 60 * 60 + self.sCallbackTimer = threading.Timer(interval, self.PrefetchRecentSymbolFiles) + self.sCallbackTimer.start() + + thresholdTime = time.time() - self.sOptions["prefetchThreshold"] * 60 * 60 + symDirsToInspect = {} + for pdbName in PREFETCHED_LIBS: + symDirsToInspect[pdbName] = [] + topLibPath = self.sOptions["symbolPaths"]["FIREFOX"] + os.sep + pdbName + + try: + symbolDirs = os.listdir(topLibPath) + for symbolDir in symbolDirs: + candidatePath = topLibPath + os.sep + symbolDir + mtime = os.path.getmtime(candidatePath) + if mtime > thresholdTime: + symDirsToInspect[pdbName].append((mtime, candidatePath)) + except Exception as e: + LOG.error("Error while pre-fetching: " + str(e)) + + LOG.info( + "Found " + + str(len(symDirsToInspect[pdbName])) + + " new " + + pdbName + + " recent dirs" + ) + + # Only prefetch the most recent N entries + symDirsToInspect[pdbName].sort(reverse=True) + symDirsToInspect[pdbName] = symDirsToInspect[pdbName][ + : self.sOptions["prefetchMaxSymbolsPerLib"] + ] + + # Don't fetch symbols already in cache. + # Ideally, mutex would be held from check to insert in self.sCache, + # but we don't want to hold the lock during I/O. This won't cause + # inconsistencies. + self.sCacheLock.acquire() + try: + for pdbName in symDirsToInspect: + for mtime, symbolDirPath in symDirsToInspect[pdbName]: + pdbId = os.path.basename(symbolDirPath) + if pdbName in self.sCache and pdbId in self.sCache[pdbName]: + symDirsToInspect[pdbName].remove((mtime, symbolDirPath)) + finally: + self.sCacheLock.release() + + # Read all new symbol files in at once + fetchedSymbols = {} + fetchedCount = 0 + for pdbName in symDirsToInspect: + # The corresponding symbol file name ends with .sym + symFileName = re.sub(r"\.[^\.]+$", ".sym", pdbName) + + for mtime, symbolDirPath in symDirsToInspect[pdbName]: + pdbId = os.path.basename(symbolDirPath) + symbolFilePath = symbolDirPath + os.sep + symFileName + symbolInfo = self.FetchSymbolsFromFile(symbolFilePath) + if symbolInfo: + # Stop if the prefetched items are bigger than the cache + if ( + fetchedCount + symbolInfo.GetEntryCount() + > self.sOptions["maxCacheEntries"] + ): + break + fetchedSymbols[(pdbName, pdbId)] = symbolInfo + fetchedCount += symbolInfo.GetEntryCount() + else: + LOG.error("Couldn't fetch .sym file symbols for " + symbolFilePath) + continue + + # Insert new symbols into global symbol cache + self.sCacheLock.acquire() + try: + # Make room for the new symbols + self.MaybeEvict(fetchedCount) + + for pdbName, pdbId in fetchedSymbols: + if pdbName not in self.sCache: + self.sCache[pdbName] = {} + + if pdbId in self.sCache[pdbName]: + continue + + newSymbolFile = fetchedSymbols[(pdbName, pdbId)] + self.sCache[pdbName][pdbId] = newSymbolFile + self.sCacheCount += newSymbolFile.GetEntryCount() + + # Move new symbols to front of MRU list to give them a chance + self.UpdateMruList(pdbName, pdbId) + + finally: + self.sCacheLock.release() + + LOG.info("Finished prefetching recent symbol files") + + def UpdateMruList(self, pdbName, pdbId): + libId = (pdbName, pdbId) + if libId in self.sMruSymbols: + self.sMruSymbols.remove(libId) + self.sMruSymbols.insert(0, libId) + + def MaybeEvict(self, freeEntriesNeeded): + maxCacheSize = self.sOptions["maxCacheEntries"] + LOG.debug( + "Cache occupancy before MaybeEvict: " + + str(self.sCacheCount) + + "/" + + str(maxCacheSize) + ) + + if ( + self.sCacheCount == 0 + or self.sCacheCount + freeEntriesNeeded <= maxCacheSize + ): + # No need to lock mutex here, this doesn't need to be 100% + return + + # If adding the new entries would exceed the max cache size, + # evict so that cache is at 70% capacity after new entries added + numOldEntriesAfterEvict = max(0, (0.70 * maxCacheSize) - freeEntriesNeeded) + numToEvict = self.sCacheCount - numOldEntriesAfterEvict + + # Evict symbols until evict quota is met, starting with least recently + # used + for pdbName, pdbId in reversed(self.sMruSymbols): + if numToEvict <= 0: + break + + evicteeCount = self.sCache[pdbName][pdbId].GetEntryCount() + + del self.sCache[pdbName][pdbId] + self.sCacheCount -= evicteeCount + self.sMruSymbols.pop() + + numToEvict -= evicteeCount + + LOG.debug( + "Cache occupancy after MaybeEvict: " + + str(self.sCacheCount) + + "/" + + str(maxCacheSize) + ) diff --git a/testing/mozbase/mozgeckoprofiler/mozgeckoprofiler/symbolication.py b/testing/mozbase/mozgeckoprofiler/mozgeckoprofiler/symbolication.py new file mode 100644 index 0000000000..ecec5c1d9d --- /dev/null +++ b/testing/mozbase/mozgeckoprofiler/mozgeckoprofiler/symbolication.py @@ -0,0 +1,360 @@ +# 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/. +import hashlib +import http.client +import os +import platform +import subprocess +import zipfile +from distutils import spawn + +import six +from mozlog import get_proxy_logger + +from .symbolicationRequest import SymbolicationRequest +from .symFileManager import SymFileManager + +LOG = get_proxy_logger("profiler") + +if six.PY2: + # Import for Python 2 + from cStringIO import StringIO as sio + from urllib2 import urlopen +else: + # Import for Python 3 + from io import BytesIO as sio + from urllib.request import urlopen + + # Symbolication is broken when using type 'str' in python 2.7, so we use 'basestring'. + # But for python 3.0 compatibility, 'basestring' isn't defined, but the 'str' type works. + # So we force 'basestring' to 'str'. + basestring = str + + +class SymbolError(Exception): + pass + + +class OSXSymbolDumper: + def __init__(self): + self.dump_syms_bin = os.path.join(os.path.dirname(__file__), "dump_syms_mac") + if not os.path.exists(self.dump_syms_bin): + raise SymbolError("No dump_syms_mac binary in this directory") + + def store_symbols( + self, lib_path, expected_breakpad_id, output_filename_without_extension + ): + """ + Returns the filename at which the .sym file was created, or None if no + symbols were dumped. + """ + output_filename = output_filename_without_extension + ".sym" + + def get_archs(filename): + """ + Find the list of architectures present in a Mach-O file. + """ + return ( + subprocess.Popen(["lipo", "-info", filename], stdout=subprocess.PIPE) + .communicate()[0] + .split(b":")[2] + .strip() + .split() + ) + + def process_file(arch): + proc = subprocess.Popen( + [self.dump_syms_bin, "-a", arch, lib_path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + stdout, stderr = proc.communicate() + if proc.returncode != 0: + return None + + module = stdout.splitlines()[0] + bits = module.split(b" ", 4) + if len(bits) != 5: + return None + _, platform, cpu_arch, actual_breakpad_id, debug_file = bits + + if str(actual_breakpad_id, "utf-8") != expected_breakpad_id: + return None + + with open(output_filename, "wb") as f: + f.write(stdout) + return output_filename + + for arch in get_archs(lib_path): + result = process_file(arch) + if result is not None: + return result + return None + + +class LinuxSymbolDumper: + def __init__(self): + self.nm = spawn.find_executable("nm") + if not self.nm: + raise SymbolError("Could not find nm, necessary for symbol dumping") + + def store_symbols(self, lib_path, breakpad_id, output_filename_without_extension): + """ + Returns the filename at which the .sym file was created, or None if no + symbols were dumped. + """ + output_filename = output_filename_without_extension + ".nmsym" + + proc = subprocess.Popen( + [self.nm, "--demangle", lib_path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + stdout, stderr = proc.communicate() + + if proc.returncode != 0: + return + + with open(output_filename, "wb") as f: + f.write(stdout) + + # Append nm -D output to the file. On Linux, most system libraries + # have no "normal" symbols, but they have "dynamic" symbols, which + # nm -D shows. + proc = subprocess.Popen( + [self.nm, "--demangle", "-D", lib_path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + stdout, stderr = proc.communicate() + if proc.returncode == 0: + f.write(stdout) + return output_filename + + +class ProfileSymbolicator: + """This class orchestrates symbolication for a Gecko profile. + + It can be used by multiple pieces of testing infrastructure that generate Gecko + performance profiles. + + Args: + options (obj): See SymFileManager for details on these options. + """ + + def __init__(self, options): + self.options = options + self.sym_file_manager = SymFileManager(self.options) + self.symbol_dumper = self.get_symbol_dumper() + + def get_symbol_dumper(self): + try: + if platform.system() == "Darwin": + return OSXSymbolDumper() + elif platform.system() == "Linux": + return LinuxSymbolDumper() + except SymbolError: + return None + + def integrate_symbol_zip_from_url(self, symbol_zip_url): + if self.have_integrated(symbol_zip_url): + return + LOG.info( + "Retrieving symbol zip from {symbol_zip_url}...".format( + symbol_zip_url=symbol_zip_url + ) + ) + try: + io = urlopen(symbol_zip_url, None, 30) + with zipfile.ZipFile(sio(io.read())) as zf: + self.integrate_symbol_zip(zf) + self._create_file_if_not_exists(self._marker_file(symbol_zip_url)) + except (IOError, http.client.IncompleteRead): + LOG.info("Symbol zip request failed.") + + def integrate_symbol_zip_from_file(self, filename): + if self.have_integrated(filename): + return + with open(filename, "rb") as f: + with zipfile.ZipFile(f) as zf: + self.integrate_symbol_zip(zf) + self._create_file_if_not_exists(self._marker_file(filename)) + + def _create_file_if_not_exists(self, filename): + try: + os.makedirs(os.path.dirname(filename)) + except OSError: + pass + try: + open(filename, "a").close() + except IOError: + pass + + def integrate_symbol_zip(self, symbol_zip_file): + symbol_zip_file.extractall(self.options["symbolPaths"]["FIREFOX"]) + + def _marker_file(self, symbol_zip_url): + marker_dir = os.path.join(self.options["symbolPaths"]["FIREFOX"], ".markers") + return os.path.join( + marker_dir, hashlib.sha1(symbol_zip_url.encode("utf-8")).hexdigest() + ) + + def have_integrated(self, symbol_zip_url): + return os.path.isfile(self._marker_file(symbol_zip_url)) + + def get_unknown_modules_in_profile(self, profile_json): + if "libs" not in profile_json: + return [] + shared_libraries = profile_json["libs"] + memoryMap = [] + for lib in shared_libraries: + memoryMap.append([lib["debugName"], lib["breakpadId"]]) + + rawRequest = { + "stacks": [[]], + "memoryMap": memoryMap, + "version": 4, + "symbolSources": ["FIREFOX", "WINDOWS"], + } + request = SymbolicationRequest(self.sym_file_manager, rawRequest) + if not request.isValidRequest: + return [] + request.Symbolicate(0) # This sets request.knownModules + + unknown_modules = [] + for i, lib in enumerate(shared_libraries): + if not request.knownModules[i]: + unknown_modules.append(lib) + return unknown_modules + + def dump_and_integrate_missing_symbols(self, profile_json, symbol_zip_path): + if not self.symbol_dumper: + return + + unknown_modules = self.get_unknown_modules_in_profile(profile_json) + if not unknown_modules: + return + + # We integrate the dumped symbols by dumping them directly into our + # symbol directory. + output_dir = self.options["symbolPaths"]["FIREFOX"] + + # Additionally, we add all dumped symbol files to the missingsymbols + # zip file. + with zipfile.ZipFile(symbol_zip_path, "a", zipfile.ZIP_DEFLATED) as zf: + for lib in unknown_modules: + self.dump_and_integrate_symbols_for_lib(lib, output_dir, zf) + + def dump_and_integrate_symbols_for_lib(self, lib, output_dir, zip): + name = lib["debugName"] + expected_name_without_extension = os.path.join(name, lib["breakpadId"], name) + for extension in [".sym", ".nmsym"]: + expected_name = expected_name_without_extension + extension + if expected_name in zip.namelist(): + # No need to dump the symbols again if we already have it in + # the missingsymbols zip file from a previous run. + zip.extract(expected_name, output_dir) + return + + lib_path = lib["path"] + if not os.path.exists(lib_path): + return + + output_filename_without_extension = os.path.join( + output_dir, expected_name_without_extension + ) + store_path = os.path.dirname(output_filename_without_extension) + if not os.path.exists(store_path): + os.makedirs(store_path) + + # Dump the symbols. + sym_file = self.symbol_dumper.store_symbols( + lib_path, lib["breakpadId"], output_filename_without_extension + ) + if sym_file: + rootlen = len(os.path.join(output_dir, "_")) - 1 + output_filename = sym_file[rootlen:] + if output_filename not in zip.namelist(): + zip.write(sym_file, output_filename) + + def symbolicate_profile(self, profile_json): + if "libs" not in profile_json: + return + + shared_libraries = profile_json["libs"] + addresses = self._find_addresses(profile_json) + symbols_to_resolve = self._assign_symbols_to_libraries( + addresses, shared_libraries + ) + symbolication_table = self._resolve_symbols(symbols_to_resolve) + self._substitute_symbols(profile_json, symbolication_table) + + for process in profile_json["processes"]: + self.symbolicate_profile(process) + + def _find_addresses(self, profile_json): + addresses = set() + for thread in profile_json["threads"]: + if isinstance(thread, basestring): + continue + for s in thread["stringTable"]: + if s[0:2] == "0x": + addresses.add(s) + return addresses + + def _substitute_symbols(self, profile_json, symbolication_table): + for thread in profile_json["threads"]: + if isinstance(thread, basestring): + continue + for i, s in enumerate(thread["stringTable"]): + thread["stringTable"][i] = symbolication_table.get(s, s) + + def _get_containing_library(self, address, libs): + left = 0 + right = len(libs) - 1 + while left <= right: + mid = (left + right) // 2 + if address >= libs[mid]["end"]: + left = mid + 1 + elif address < libs[mid]["start"]: + right = mid - 1 + else: + return libs[mid] + return None + + def _assign_symbols_to_libraries(self, addresses, shared_libraries): + libs_with_symbols = {} + for address in addresses: + lib = self._get_containing_library(int(address, 0), shared_libraries) + if not lib: + continue + if lib["start"] not in libs_with_symbols: + libs_with_symbols[lib["start"]] = {"library": lib, "symbols": set()} + libs_with_symbols[lib["start"]]["symbols"].add(address) + # pylint: disable=W1656 + return libs_with_symbols.values() + + def _resolve_symbols(self, symbols_to_resolve): + memoryMap = [] + processedStack = [] + all_symbols = [] + for moduleIndex, library_with_symbols in enumerate(symbols_to_resolve): + lib = library_with_symbols["library"] + symbols = library_with_symbols["symbols"] + memoryMap.append([lib["debugName"], lib["breakpadId"]]) + all_symbols += symbols + for symbol in symbols: + processedStack.append([moduleIndex, int(symbol, 0) - lib["start"]]) + + rawRequest = { + "stacks": [processedStack], + "memoryMap": memoryMap, + "version": 4, + "symbolSources": ["FIREFOX", "WINDOWS"], + } + request = SymbolicationRequest(self.sym_file_manager, rawRequest) + if not request.isValidRequest: + return {} + symbolicated_stack = request.Symbolicate(0) + return dict(zip(all_symbols, symbolicated_stack)) diff --git a/testing/mozbase/mozgeckoprofiler/mozgeckoprofiler/symbolicationRequest.py b/testing/mozbase/mozgeckoprofiler/mozgeckoprofiler/symbolicationRequest.py new file mode 100644 index 0000000000..1b277abbde --- /dev/null +++ b/testing/mozbase/mozgeckoprofiler/mozgeckoprofiler/symbolicationRequest.py @@ -0,0 +1,331 @@ +# 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/. +import json +import re + +import six +from mozlog import get_proxy_logger + +LOG = get_proxy_logger("profiler") + +# Precompiled regex for validating lib names +# Empty lib name means client couldn't associate frame with any lib +gLibNameRE = re.compile("[0-9a-zA-Z_+\-\.]*$") + +# Maximum number of times a request can be forwarded to a different server +# for symbolication. Also prevents loops. +MAX_FORWARDED_REQUESTS = 3 + +if six.PY2: + # Import for Python 2 + from urllib2 import Request, urlopen +else: + # Import for Python 3 + from urllib.request import Request, urlopen + + # Symbolication is broken when using type 'str' in python 2.7, so we use 'basestring'. + # But for python 3.0 compatibility, 'basestring' isn't defined, but the 'str' type works. + # So we force 'basestring' to 'str'. + basestring = str + + +class ModuleV3: + def __init__(self, libName, breakpadId): + self.libName = libName + self.breakpadId = breakpadId + + +def getModuleV3(libName, breakpadId): + if not isinstance(libName, basestring) or not gLibNameRE.match(libName): + LOG.debug("Bad library name: " + str(libName)) + return None + + if not isinstance(breakpadId, basestring): + LOG.debug("Bad breakpad id: " + str(breakpadId)) + return None + + return ModuleV3(libName, breakpadId) + + +class SymbolicationRequest: + def __init__(self, symFileManager, rawRequests): + self.Reset() + self.symFileManager = symFileManager + self.stacks = [] + self.combinedMemoryMap = [] + self.knownModules = [] + self.symbolSources = [] + self.ParseRequests(rawRequests) + + def Reset(self): + self.symFileManager = None + self.isValidRequest = False + self.combinedMemoryMap = [] + self.knownModules = [] + self.stacks = [] + self.forwardCount = 0 + + def ParseRequests(self, rawRequests): + self.isValidRequest = False + + try: + if not isinstance(rawRequests, dict): + LOG.debug("Request is not a dictionary") + return + + if "version" not in rawRequests: + LOG.debug("Request is missing 'version' field") + return + version = rawRequests["version"] + if version != 4: + LOG.debug("Invalid version: %s" % version) + return + + if "forwarded" in rawRequests: + if not isinstance(rawRequests["forwarded"], (int, int)): + LOG.debug("Invalid 'forwards' field: %s" % rawRequests["forwarded"]) + return + self.forwardCount = rawRequests["forwarded"] + + # Client specifies which sets of symbols should be used + if "symbolSources" in rawRequests: + try: + sourceList = [x.upper() for x in rawRequests["symbolSources"]] + for source in sourceList: + if source in self.symFileManager.sOptions["symbolPaths"]: + self.symbolSources.append(source) + else: + LOG.debug("Unrecognized symbol source: " + source) + continue + except Exception: + self.symbolSources = [] + pass + + if not self.symbolSources: + self.symbolSources.append(self.symFileManager.sOptions["defaultApp"]) + self.symbolSources.append(self.symFileManager.sOptions["defaultOs"]) + + if "memoryMap" not in rawRequests: + LOG.debug("Request is missing 'memoryMap' field") + return + memoryMap = rawRequests["memoryMap"] + if not isinstance(memoryMap, list): + LOG.debug("'memoryMap' field in request is not a list") + + if "stacks" not in rawRequests: + LOG.debug("Request is missing 'stacks' field") + return + stacks = rawRequests["stacks"] + if not isinstance(stacks, list): + LOG.debug("'stacks' field in request is not a list") + return + + # Check memory map is well-formatted + # We try to be more permissive here with the modules. If a module is not + # well-formatted, we ignore that one by adding a None to the clean memory map. We have + # to add a None instead of simply omitting that module because the indexes of the + # modules in the memory map has to match the indexes of the shared libraries in the + # profile data. + cleanMemoryMap = [] + for module in memoryMap: + if not isinstance(module, list): + LOG.debug("Entry in memory map is not a list: " + str(module)) + cleanMemoryMap.append(None) + continue + + if len(module) != 2: + LOG.debug( + "Entry in memory map is not a 2 item list: " + str(module) + ) + cleanMemoryMap.append(None) + continue + moduleV3 = getModuleV3(*module) + + if moduleV3 is None: + LOG.debug("Failed to get Module V3.") + + cleanMemoryMap.append(moduleV3) + + self.combinedMemoryMap = cleanMemoryMap + self.knownModules = [False] * len(self.combinedMemoryMap) + + # Check stack is well-formatted + for stack in stacks: + if not isinstance(stack, list): + LOG.debug("stack is not a list") + return + for entry in stack: + if not isinstance(entry, list): + LOG.debug("stack entry is not a list") + return + if len(entry) != 2: + LOG.debug("stack entry doesn't have exactly 2 elements") + return + + self.stacks.append(stack) + + except Exception as e: + LOG.debug("Exception while parsing request: " + str(e)) + return + + self.isValidRequest = True + + def ForwardRequest(self, indexes, stack, modules, symbolicatedStack): + LOG.debug("Forwarding " + str(len(stack)) + " PCs for symbolication") + + try: + url = self.symFileManager.sOptions["remoteSymbolServer"] + rawModules = [] + moduleToIndex = {} + newIndexToOldIndex = {} + for moduleIndex, m in modules: + l = [m.libName, m.breakpadId] + newModuleIndex = len(rawModules) + rawModules.append(l) + moduleToIndex[m] = newModuleIndex + newIndexToOldIndex[newModuleIndex] = moduleIndex + + rawStack = [] + for entry in stack: + moduleIndex = entry[0] + offset = entry[1] + module = self.combinedMemoryMap[moduleIndex] + if module is None: + continue + newIndex = moduleToIndex[module] + rawStack.append([newIndex, offset]) + + requestVersion = 4 + while True: + requestObj = { + "symbolSources": self.symbolSources, + "stacks": [rawStack], + "memoryMap": rawModules, + "forwarded": self.forwardCount + 1, + "version": requestVersion, + } + requestJson = json.dumps(requestObj).encode() + headers = {"Content-Type": "application/json"} + requestHandle = Request(url, requestJson, headers) + try: + response = urlopen(requestHandle) + except Exception as e: + if requestVersion == 4: + # Try again with version 3 + requestVersion = 3 + continue + raise e + succeededVersion = requestVersion + break + + except Exception as e: + LOG.error("Exception while forwarding request: " + str(e)) + return + + try: + responseJson = json.loads(response.read()) + except Exception as e: + LOG.error( + "Exception while reading server response to forwarded" + " request: " + str(e) + ) + return + + try: + if succeededVersion == 4: + responseKnownModules = responseJson["knownModules"] + for newIndex, known in enumerate(responseKnownModules): + if known and newIndex in newIndexToOldIndex: + self.knownModules[newIndexToOldIndex[newIndex]] = True + + responseSymbols = responseJson["symbolicatedStacks"][0] + else: + responseSymbols = responseJson[0] + if len(responseSymbols) != len(stack): + LOG.error( + str(len(responseSymbols)) + + " symbols in response, " + + str(len(stack)) + + " PCs in request!" + ) + return + + for index in range(0, len(stack)): + symbol = responseSymbols[index] + originalIndex = indexes[index] + symbolicatedStack[originalIndex] = symbol + except Exception as e: + LOG.error( + "Exception while parsing server response to forwarded" + " request: " + str(e) + ) + return + + def Symbolicate(self, stackNum): + # Check if we should forward requests when required sym files don't + # exist + shouldForwardRequests = False + if ( + self.symFileManager.sOptions["remoteSymbolServer"] + and self.forwardCount < MAX_FORWARDED_REQUESTS + ): + shouldForwardRequests = True + + # Symbolicate each PC + pcIndex = -1 + symbolicatedStack = [] + missingSymFiles = [] + unresolvedIndexes = [] + unresolvedStack = [] + unresolvedModules = [] + stack = self.stacks[stackNum] + + for moduleIndex, module in enumerate(self.combinedMemoryMap): + if module is None: + continue + + if not self.symFileManager.GetLibSymbolMap( + module.libName, module.breakpadId, self.symbolSources + ): + missingSymFiles.append((module.libName, module.breakpadId)) + if shouldForwardRequests: + unresolvedModules.append((moduleIndex, module)) + else: + self.knownModules[moduleIndex] = True + + for entry in stack: + pcIndex += 1 + moduleIndex = entry[0] + offset = entry[1] + if moduleIndex == -1: + symbolicatedStack.append(hex(offset)) + continue + module = self.combinedMemoryMap[moduleIndex] + if module is None: + continue + + if (module.libName, module.breakpadId) in missingSymFiles: + if shouldForwardRequests: + unresolvedIndexes.append(pcIndex) + unresolvedStack.append(entry) + symbolicatedStack.append(hex(offset) + " (in " + module.libName + ")") + continue + + functionName = None + libSymbolMap = self.symFileManager.GetLibSymbolMap( + module.libName, module.breakpadId, self.symbolSources + ) + functionName = libSymbolMap.Lookup(offset) + + if functionName is None: + functionName = hex(offset) + symbolicatedStack.append(functionName + " (in " + module.libName + ")") + + # Ask another server for help symbolicating unresolved addresses + if len(unresolvedStack) > 0 or len(unresolvedModules) > 0: + self.ForwardRequest( + unresolvedIndexes, unresolvedStack, unresolvedModules, symbolicatedStack + ) + + return symbolicatedStack diff --git a/testing/mozbase/mozgeckoprofiler/mozgeckoprofiler/viewgeckoprofile.py b/testing/mozbase/mozgeckoprofiler/mozgeckoprofiler/viewgeckoprofile.py new file mode 100644 index 0000000000..95c73cf503 --- /dev/null +++ b/testing/mozbase/mozgeckoprofiler/mozgeckoprofiler/viewgeckoprofile.py @@ -0,0 +1,136 @@ +#!/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/. + +import argparse +import os +import socket +import sys +import webbrowser + +import six +from mozlog import commandline, get_proxy_logger +from mozlog.commandline import add_logging_group + +here = os.path.abspath(os.path.dirname(__file__)) +LOG = get_proxy_logger("profiler") + +if six.PY2: + # Import for Python 2 + from urllib import quote + + from SimpleHTTPServer import SimpleHTTPRequestHandler + from SocketServer import TCPServer +else: + # Import for Python 3 + from http.server import SimpleHTTPRequestHandler + from socketserver import TCPServer + from urllib.parse import quote + + +class ProfileServingHTTPRequestHandler(SimpleHTTPRequestHandler): + """Extends the basic SimpleHTTPRequestHandler (which serves a directory + of files) to include request headers required by profiler.firefox.com""" + + def end_headers(self): + self.send_header("Access-Control-Allow-Origin", "https://profiler.firefox.com") + SimpleHTTPRequestHandler.end_headers(self) + + +class ViewGeckoProfile(object): + """Container class for ViewGeckoProfile""" + + def __init__(self, gecko_profile_data_path): + self.gecko_profile_data_path = gecko_profile_data_path + self.gecko_profile_dir = os.path.dirname(gecko_profile_data_path) + self.profiler_url = "https://profiler.firefox.com/from-url/" + self.httpd = None + self.host = "127.0.0.1" + self.port = None + self.oldcwd = os.getcwd() + + def setup_http_server(self): + # pick a free port + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("", 0)) + self.port = sock.getsockname()[1] + sock.close() + + # Temporarily change the directory to the profile directory. + os.chdir(self.gecko_profile_dir) + self.httpd = TCPServer((self.host, self.port), ProfileServingHTTPRequestHandler) + + def handle_single_request(self): + self.httpd.handle_request() + # Go back to the old cwd, which some infrastructure may be relying on. + os.chdir(self.oldcwd) + + def encode_url(self): + # Encode url i.e.: https://profiler.firefox.com/from-url/http... + file_url = "http://{}:{}/{}".format( + self.host, self.port, os.path.basename(self.gecko_profile_data_path) + ) + + self.profiler_url = self.profiler_url + quote(file_url, safe="") + LOG.info("Temporarily serving the profile from: %s" % file_url) + + def open_profile_in_browser(self): + # Open the file in the user's preferred browser. + LOG.info("Opening the profile: %s" % self.profiler_url) + webbrowser.open_new_tab(self.profiler_url) + + +def create_parser(mach_interface=False): + parser = argparse.ArgumentParser() + add_arg = parser.add_argument + + add_arg( + "-p", + "--profile-zip", + required=True, + dest="profile_zip", + help="path to the gecko profiles zip file to open in profiler.firefox.com", + ) + + add_logging_group(parser) + return parser + + +def verify_options(parser, args): + ctx = vars(args) + + if not os.path.isfile(args.profile_zip): + parser.error("{profile_zip} does not exist!".format(**ctx)) + + +def parse_args(argv=None): + parser = create_parser() + args = parser.parse_args(argv) + verify_options(parser, args) + return args + + +def view_gecko_profile(profile_path): + """ + Open a gecko profile in the user's default browser. This function opens + up a special URL to profiler.firefox.com and serves up the local profile. + """ + view_gecko_profile = ViewGeckoProfile(profile_path) + + view_gecko_profile.setup_http_server() + view_gecko_profile.encode_url() + view_gecko_profile.open_profile_in_browser() + view_gecko_profile.handle_single_request() + + +def start_from_command_line(): + args = parse_args(sys.argv[1:]) + commandline.setup_logging("view-gecko-profile", args, {"tbpl": sys.stdout}) + + view_gecko_profile(args.profile_zip) + + +if __name__ == "__main__": + start_from_command_line() diff --git a/testing/mozbase/mozgeckoprofiler/setup.py b/testing/mozbase/mozgeckoprofiler/setup.py new file mode 100644 index 0000000000..0c7949cae9 --- /dev/null +++ b/testing/mozbase/mozgeckoprofiler/setup.py @@ -0,0 +1,32 @@ +# 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 setuptools import setup + +PACKAGE_NAME = "mozgeckoprofiler" +PACKAGE_VERSION = "1.0.0" + +setup( + name=PACKAGE_NAME, + version=PACKAGE_VERSION, + description="Library to generate and view performance data in the Firefox Profiler", + long_description="see https://firefox-source-docs.mozilla.org/mozgeckoprofiler/index.html", + classifiers=[ + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", + ], + keywords="mozilla", + author="Mozilla Automation and Tools team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + license="MPL", + packages=["mozgeckoprofiler"], + include_package_data=True, + zip_safe=False, + install_requires=[], + tests_require=[], +) diff --git a/testing/mozbase/mozgeckoprofiler/tests/manifest.toml b/testing/mozbase/mozgeckoprofiler/tests/manifest.toml new file mode 100644 index 0000000000..95bae86eab --- /dev/null +++ b/testing/mozbase/mozgeckoprofiler/tests/manifest.toml @@ -0,0 +1,4 @@ +[DEFAULT] +subsuite = "mozbase" + +["test_view_gecko_profiler.py"] diff --git a/testing/mozbase/mozgeckoprofiler/tests/test_view_gecko_profiler.py b/testing/mozbase/mozgeckoprofiler/tests/test_view_gecko_profiler.py new file mode 100644 index 0000000000..76dd0f4594 --- /dev/null +++ b/testing/mozbase/mozgeckoprofiler/tests/test_view_gecko_profiler.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python + +import io +import os +import re +import shutil +import tempfile +import threading +import time +import unittest +from unittest import mock + +import mozunit +import requests +import six +from mozgeckoprofiler import view_gecko_profile + +if six.PY2: + # Import for Python 2 + from urllib import unquote +else: + # Import for Python 3 + from urllib.parse import unquote + + +def access_profiler_link(file_url, response): + """Attempts to access the profile in a loop for 5 seconds. + + This is run from a separate thread. + """ + timeout = 5 # seconds + start = time.time() + + while time.time() - start < timeout: + # Poll the server to try and get a response. + result = requests.get(url=file_url) + if result.ok: + # Return the text back in a list. + response[0] = result.text + return + time.sleep(0.1) + + response[0] = "Accessing the profiler link timed out after %s seconds" % timeout + + +class TestViewGeckoProfile(unittest.TestCase): + """Tests the opening local profiles in the Firefox Profiler.""" + + def setUp(self): + self.firefox_profiler_url = None + self.thread = None + self.response = [None] + + def test_view_gecko_profile(self): + # Create a temporary fake performance profile. + temp_dir = tempfile.mkdtemp() + profile_path = os.path.join(temp_dir, "fakeprofile.json") + with io.open(profile_path, "w") as f: + f.write("FAKE_PROFILE") + + # Mock the open_new_tab function so that we know when the view_gecko_profile + # function has done all of its work, and we can assert ressult of the + # user behavior. + def mocked_open_new_tab(firefox_profiler_url): + self.firefox_profiler_url = firefox_profiler_url + encoded_file_url = firefox_profiler_url.split("/")[-1] + decoded_file_url = unquote(encoded_file_url) + # Extract the actual file from the path. + self.thread = threading.Thread( + target=access_profiler_link, args=(decoded_file_url, self.response) + ) + print("firefox_profiler_url %s" % firefox_profiler_url) + print("encoded_file_url %s" % encoded_file_url) + print("decoded_file_url %s" % decoded_file_url) + self.thread.start() + + with mock.patch("webbrowser.open_new_tab", new=mocked_open_new_tab): + # Run the test + view_gecko_profile(profile_path) + + self.thread.join() + + # Compare the URLs, but replace the PORT value supplied, as that is dynamic. + expected_url = ( + "https://profiler.firefox.com/from-url/" + "http%3A%2F%2F127.0.0.1%3A{PORT}%2Ffakeprofile.json" + ) + actual_url = re.sub("%3A\d+%2F", "%3A{PORT}%2F", self.firefox_profiler_url) + + self.assertEqual( + actual_url, + expected_url, + "The URL generated was correct for the Firefox Profiler.", + ) + self.assertEqual( + self.response[0], + "FAKE_PROFILE", + "The response from the serve provided the profile contents.", + ) + + shutil.rmtree(temp_dir) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozhttpd/mozhttpd/__init__.py b/testing/mozbase/mozhttpd/mozhttpd/__init__.py new file mode 100644 index 0000000000..65c860f9c5 --- /dev/null +++ b/testing/mozbase/mozhttpd/mozhttpd/__init__.py @@ -0,0 +1,47 @@ +# 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/. + +""" +Mozhttpd is a simple http webserver written in python, designed expressly +for use in automated testing scenarios. It is designed to both serve static +content and provide simple web services. + +The server is based on python standard library modules such as +SimpleHttpServer, urlparse, etc. The ThreadingMixIn is used to +serve each request on a discrete thread. + +Some existing uses of mozhttpd include Peptest_, Eideticker_, and Talos_. + +.. _Peptest: https://github.com/mozilla/peptest/ + +.. _Eideticker: https://github.com/mozilla/eideticker/ + +.. _Talos: http://hg.mozilla.org/build/ + +The following simple example creates a basic HTTP server which serves +content from the current directory, defines a single API endpoint +`/api/resource/<resourceid>` and then serves requests indefinitely: + +:: + + import mozhttpd + + @mozhttpd.handlers.json_response + def resource_get(request, objid): + return (200, { 'id': objid, + 'query': request.query }) + + + httpd = mozhttpd.MozHttpd(port=8080, docroot='.', + urlhandlers = [ { 'method': 'GET', + 'path': '/api/resources/([^/]+)/?', + 'function': resource_get } ]) + print "Serving '%s' at %s:%s" % (httpd.docroot, httpd.host, httpd.port) + httpd.start(block=True) + +""" +from .handlers import json_response +from .mozhttpd import MozHttpd, Request, RequestHandler, main + +__all__ = ["MozHttpd", "Request", "RequestHandler", "main", "json_response"] diff --git a/testing/mozbase/mozhttpd/mozhttpd/handlers.py b/testing/mozbase/mozhttpd/mozhttpd/handlers.py new file mode 100644 index 0000000000..44f657031a --- /dev/null +++ b/testing/mozbase/mozhttpd/mozhttpd/handlers.py @@ -0,0 +1,20 @@ +# 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/. + +import json + + +def json_response(func): + """Translates results of 'func' into a JSON response.""" + + def wrap(*a, **kw): + (code, data) = func(*a, **kw) + json_data = json.dumps(data) + return ( + code, + {"Content-type": "application/json", "Content-Length": len(json_data)}, + json_data, + ) + + return wrap diff --git a/testing/mozbase/mozhttpd/mozhttpd/mozhttpd.py b/testing/mozbase/mozhttpd/mozhttpd/mozhttpd.py new file mode 100755 index 0000000000..dd4e606a55 --- /dev/null +++ b/testing/mozbase/mozhttpd/mozhttpd/mozhttpd.py @@ -0,0 +1,350 @@ +#!/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/. + +import errno +import logging +import os +import posixpath +import re +import socket +import sys +import threading +import time +import traceback +from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser + +import moznetwork +from six import ensure_binary, iteritems +from six.moves.BaseHTTPServer import HTTPServer +from six.moves.SimpleHTTPServer import SimpleHTTPRequestHandler +from six.moves.socketserver import ThreadingMixIn +from six.moves.urllib.parse import unquote, urlsplit + + +class EasyServer(ThreadingMixIn, HTTPServer): + allow_reuse_address = True + acceptable_errors = (errno.EPIPE, errno.ECONNABORTED) + + def handle_error(self, request, client_address): + error = sys.exc_info()[1] + + if ( + isinstance(error, socket.error) + and isinstance(error.args, tuple) + and error.args[0] in self.acceptable_errors + ) or (isinstance(error, IOError) and error.errno in self.acceptable_errors): + pass # remote hang up before the result is sent + else: + logging.error(error) + # The error can be ambiguous just the short description is logged, so we + # dump a stack trace to discover its origin. + traceback.print_exc() + + +class Request(object): + """Details of a request.""" + + # attributes from urlsplit that this class also sets + uri_attrs = ("scheme", "netloc", "path", "query", "fragment") + + def __init__(self, uri, headers, rfile=None): + self.uri = uri + self.headers = headers + parsed = urlsplit(uri) + for i, attr in enumerate(self.uri_attrs): + setattr(self, attr, parsed[i]) + try: + body_len = int(self.headers.get("Content-length", 0)) + except ValueError: + body_len = 0 + if body_len and rfile: + self.body = rfile.read(body_len) + else: + self.body = None + + +class RequestHandler(SimpleHTTPRequestHandler): + docroot = os.getcwd() # current working directory at time of import + proxy_host_dirs = False + request_log = [] + log_requests = False + request = None + + def __init__(self, *args, **kwargs): + SimpleHTTPRequestHandler.__init__(self, *args, **kwargs) + self.extensions_map[".svg"] = "image/svg+xml" + + def _try_handler(self, method): + if self.log_requests: + self.request_log.append( + {"method": method, "path": self.request.path, "time": time.time()} + ) + + handlers = [ + handler for handler in self.urlhandlers if handler["method"] == method + ] + for handler in handlers: + m = re.match(handler["path"], self.request.path) + if m: + (response_code, headerdict, data) = handler["function"]( + self.request, *m.groups() + ) + self.send_response(response_code) + for keyword, value in iteritems(headerdict): + self.send_header(keyword, value) + self.end_headers() + self.wfile.write(ensure_binary(data)) + + return True + + return False + + def _find_path(self): + """Find the on-disk path to serve this request from, + using self.path_mappings and self.docroot. + Return (url_path, disk_path).""" + path_components = list(filter(None, self.request.path.split("/"))) + for prefix, disk_path in iteritems(self.path_mappings): + prefix_components = list(filter(None, prefix.split("/"))) + if len(path_components) < len(prefix_components): + continue + if path_components[: len(prefix_components)] == prefix_components: + return ("/".join(path_components[len(prefix_components) :]), disk_path) + if self.docroot: + return self.request.path, self.docroot + return None + + def parse_request(self): + retval = SimpleHTTPRequestHandler.parse_request(self) + self.request = Request(self.path, self.headers, self.rfile) + return retval + + def do_GET(self): + if not self._try_handler("GET"): + res = self._find_path() + if res: + self.path, self.disk_root = res + # don't include query string and fragment, and prepend + # host directory if required. + if self.request.netloc and self.proxy_host_dirs: + self.path = "/" + self.request.netloc + self.path + SimpleHTTPRequestHandler.do_GET(self) + else: + self.send_response(404) + self.end_headers() + self.wfile.write(b"") + + def do_POST(self): + # if we don't have a match, we always fall through to 404 (this may + # not be "technically" correct if we have a local file at the same + # path as the resource but... meh) + if not self._try_handler("POST"): + self.send_response(404) + self.end_headers() + self.wfile.write(b"") + + def do_DEL(self): + # if we don't have a match, we always fall through to 404 (this may + # not be "technically" correct if we have a local file at the same + # path as the resource but... meh) + if not self._try_handler("DEL"): + self.send_response(404) + self.end_headers() + self.wfile.write(b"") + + def translate_path(self, path): + # this is taken from SimpleHTTPRequestHandler.translate_path(), + # except we serve from self.docroot instead of os.getcwd(), and + # parse_request()/do_GET() have already stripped the query string and + # fragment and mangled the path for proxying, if required. + path = posixpath.normpath(unquote(self.path)) + words = path.split("/") + words = list(filter(None, words)) + path = self.disk_root + for word in words: + drive, word = os.path.splitdrive(word) + head, word = os.path.split(word) + if word in (os.curdir, os.pardir): + continue + path = os.path.join(path, word) + return path + + # I found on my local network that calls to this were timing out + # I believe all of these calls are from log_message + def address_string(self): + return "a.b.c.d" + + # This produces a LOT of noise + def log_message(self, format, *args): + pass + + +class MozHttpd(object): + """ + :param host: Host from which to serve (default 127.0.0.1) + :param port: Port from which to serve (default 8888) + :param docroot: Server root (default os.getcwd()) + :param urlhandlers: Handlers to specify behavior against method and path match (default None) + :param path_mappings: A dict mapping URL prefixes to additional on-disk paths. + :param proxy_host_dirs: Toggle proxy behavior (default False) + :param log_requests: Toggle logging behavior (default False) + + Very basic HTTP server class. Takes a docroot (path on the filesystem) + and a set of urlhandler dictionaries of the form: + + :: + + { + 'method': HTTP method (string): GET, POST, or DEL, + 'path': PATH_INFO (regular expression string), + 'function': function of form fn(arg1, arg2, arg3, ..., request) + } + + and serves HTTP. For each request, MozHttpd will either return a file + off the docroot, or dispatch to a handler function (if both path and + method match). + + Note that one of docroot or urlhandlers may be None (in which case no + local files or handlers, respectively, will be used). If both docroot or + urlhandlers are None then MozHttpd will default to serving just the local + directory. + + MozHttpd also handles proxy requests (i.e. with a full URI on the request + line). By default files are served from docroot according to the request + URI's path component, but if proxy_host_dirs is True, files are served + from <self.docroot>/<host>/. + + For example, the request "GET http://foo.bar/dir/file.html" would + (assuming no handlers match) serve <docroot>/dir/file.html if + proxy_host_dirs is False, or <docroot>/foo.bar/dir/file.html if it is + True. + """ + + def __init__( + self, + host="127.0.0.1", + port=0, + docroot=None, + urlhandlers=None, + path_mappings=None, + proxy_host_dirs=False, + log_requests=False, + ): + self.host = host + self.port = int(port) + self.docroot = docroot + if not (urlhandlers or docroot or path_mappings): + self.docroot = os.getcwd() + self.proxy_host_dirs = proxy_host_dirs + self.httpd = None + self.urlhandlers = urlhandlers or [] + self.path_mappings = path_mappings or {} + self.log_requests = log_requests + self.request_log = [] + + class RequestHandlerInstance(RequestHandler): + docroot = self.docroot + urlhandlers = self.urlhandlers + path_mappings = self.path_mappings + proxy_host_dirs = self.proxy_host_dirs + request_log = self.request_log + log_requests = self.log_requests + + self.handler_class = RequestHandlerInstance + + def start(self, block=False): + """ + Starts the server. + + If `block` is True, the call will not return. If `block` is False, the + server will be started on a separate thread that can be terminated by + a call to stop(). + """ + self.httpd = EasyServer((self.host, self.port), self.handler_class) + if block: + self.httpd.serve_forever() + else: + self.server = threading.Thread(target=self.httpd.serve_forever) + self.server.setDaemon(True) # don't hang on exit + self.server.start() + + def stop(self): + """ + Stops the server. + + If the server is not running, this method has no effect. + """ + if self.httpd: + # FIXME: There is no shutdown() method in Python 2.4... + try: + self.httpd.shutdown() + except AttributeError: + pass + self.httpd = None + + def get_url(self, path="/"): + """ + Returns a URL that can be used for accessing the server (e.g. http://192.168.1.3:4321/) + + :param path: Path to append to URL (e.g. if path were /foobar.html you would get a URL like + http://192.168.1.3:4321/foobar.html). Default is `/`. + """ + if not self.httpd: + return None + + return "http://%s:%s%s" % (self.host, self.httpd.server_port, path) + + __del__ = stop + + +def main(args=sys.argv[1:]): + # parse command line options + parser = ArgumentParser( + description="Basic python webserver.", + formatter_class=ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "-p", + "--port", + dest="port", + type=int, + default=8888, + help="port to run the server on", + ) + parser.add_argument( + "-H", "--host", dest="host", default="127.0.0.1", help="host address" + ) + parser.add_argument( + "-i", + "--external-ip", + action="store_true", + dest="external_ip", + default=False, + help="find and use external ip for host", + ) + parser.add_argument( + "-d", + "--docroot", + dest="docroot", + default=os.getcwd(), + help="directory to serve files from", + ) + args = parser.parse_args() + + if args.external_ip: + host = moznetwork.get_lan_ip() + else: + host = args.host + + # create the server + server = MozHttpd(host=host, port=args.port, docroot=args.docroot) + + print("Serving '%s' at %s:%s" % (server.docroot, server.host, server.port)) + server.start(block=True) + + +if __name__ == "__main__": + main() diff --git a/testing/mozbase/mozhttpd/setup.py b/testing/mozbase/mozhttpd/setup.py new file mode 100644 index 0000000000..4d4f689113 --- /dev/null +++ b/testing/mozbase/mozhttpd/setup.py @@ -0,0 +1,34 @@ +# 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 setuptools import setup + +PACKAGE_VERSION = "0.7.1" +deps = ["moznetwork >= 0.24", "mozinfo >= 1.0.0", "six >= 1.13.0"] + +setup( + name="mozhttpd", + version=PACKAGE_VERSION, + description="Python webserver intended for use with Mozilla testing", + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + classifiers=[ + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 2 :: Only", + ], + # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + keywords="mozilla", + author="Mozilla Automation and Testing Team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + license="MPL", + packages=["mozhttpd"], + include_package_data=True, + zip_safe=False, + install_requires=deps, + entry_points=""" + # -*- Entry points: -*- + [console_scripts] + mozhttpd = mozhttpd:main + """, +) diff --git a/testing/mozbase/mozhttpd/tests/api.py b/testing/mozbase/mozhttpd/tests/api.py new file mode 100644 index 0000000000..c2fce58be9 --- /dev/null +++ b/testing/mozbase/mozhttpd/tests/api.py @@ -0,0 +1,381 @@ +#!/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/. + +import collections +import json +import os + +import mozhttpd +import mozunit +import pytest +from six import ensure_binary, ensure_str +from six.moves.urllib.error import HTTPError +from six.moves.urllib.request import ( + HTTPHandler, + ProxyHandler, + Request, + build_opener, + install_opener, + urlopen, +) + + +def httpd_url(httpd, path, querystr=None): + """Return the URL to a started MozHttpd server for the given info.""" + + url = "http://127.0.0.1:{port}{path}".format( + port=httpd.httpd.server_port, + path=path, + ) + + if querystr is not None: + url = "{url}?{querystr}".format( + url=url, + querystr=querystr, + ) + + return url + + +@pytest.fixture(name="num_requests") +def fixture_num_requests(): + """Return a defaultdict to count requests to HTTP handlers.""" + return collections.defaultdict(int) + + +@pytest.fixture(name="try_get") +def fixture_try_get(num_requests): + """Return a function to try GET requests to the server.""" + + def try_get(httpd, querystr): + """Try GET requests to the server.""" + + num_requests["get_handler"] = 0 + + f = urlopen(httpd_url(httpd, "/api/resource/1", querystr)) + + assert f.getcode() == 200 + assert json.loads(f.read()) == {"called": 1, "id": "1", "query": querystr} + assert num_requests["get_handler"] == 1 + + return try_get + + +@pytest.fixture(name="try_post") +def fixture_try_post(num_requests): + """Return a function to try POST calls to the server.""" + + def try_post(httpd, querystr): + """Try POST calls to the server.""" + + num_requests["post_handler"] = 0 + + postdata = {"hamburgers": "1234"} + + f = urlopen( + httpd_url(httpd, "/api/resource/", querystr), + data=ensure_binary(json.dumps(postdata)), + ) + + assert f.getcode() == 201 + assert json.loads(f.read()) == { + "called": 1, + "data": postdata, + "query": querystr, + } + assert num_requests["post_handler"] == 1 + + return try_post + + +@pytest.fixture(name="try_del") +def fixture_try_del(num_requests): + """Return a function to try DEL calls to the server.""" + + def try_del(httpd, querystr): + """Try DEL calls to the server.""" + + num_requests["del_handler"] = 0 + + opener = build_opener(HTTPHandler) + request = Request(httpd_url(httpd, "/api/resource/1", querystr)) + request.get_method = lambda: "DEL" + f = opener.open(request) + + assert f.getcode() == 200 + assert json.loads(f.read()) == {"called": 1, "id": "1", "query": querystr} + assert num_requests["del_handler"] == 1 + + return try_del + + +@pytest.fixture(name="httpd_no_urlhandlers") +def fixture_httpd_no_urlhandlers(): + """Yields a started MozHttpd server with no URL handlers.""" + httpd = mozhttpd.MozHttpd(port=0) + httpd.start(block=False) + yield httpd + httpd.stop() + + +@pytest.fixture(name="httpd_with_docroot") +def fixture_httpd_with_docroot(num_requests): + """Yields a started MozHttpd server with docroot set.""" + + @mozhttpd.handlers.json_response + def get_handler(request, objid): + """Handler for HTTP GET requests.""" + + num_requests["get_handler"] += 1 + + return ( + 200, + { + "called": num_requests["get_handler"], + "id": objid, + "query": request.query, + }, + ) + + httpd = mozhttpd.MozHttpd( + port=0, + docroot=os.path.dirname(os.path.abspath(__file__)), + urlhandlers=[ + { + "method": "GET", + "path": "/api/resource/([^/]+)/?", + "function": get_handler, + } + ], + ) + + httpd.start(block=False) + yield httpd + httpd.stop() + + +@pytest.fixture(name="httpd") +def fixture_httpd(num_requests): + """Yield a started MozHttpd server.""" + + @mozhttpd.handlers.json_response + def get_handler(request, objid): + """Handler for HTTP GET requests.""" + + num_requests["get_handler"] += 1 + + return ( + 200, + { + "called": num_requests["get_handler"], + "id": objid, + "query": request.query, + }, + ) + + @mozhttpd.handlers.json_response + def post_handler(request): + """Handler for HTTP POST requests.""" + + num_requests["post_handler"] += 1 + + return ( + 201, + { + "called": num_requests["post_handler"], + "data": json.loads(request.body), + "query": request.query, + }, + ) + + @mozhttpd.handlers.json_response + def del_handler(request, objid): + """Handler for HTTP DEL requests.""" + + num_requests["del_handler"] += 1 + + return ( + 200, + { + "called": num_requests["del_handler"], + "id": objid, + "query": request.query, + }, + ) + + httpd = mozhttpd.MozHttpd( + port=0, + urlhandlers=[ + { + "method": "GET", + "path": "/api/resource/([^/]+)/?", + "function": get_handler, + }, + { + "method": "POST", + "path": "/api/resource/?", + "function": post_handler, + }, + { + "method": "DEL", + "path": "/api/resource/([^/]+)/?", + "function": del_handler, + }, + ], + ) + + httpd.start(block=False) + yield httpd + httpd.stop() + + +def test_api(httpd, try_get, try_post, try_del): + # GET requests + try_get(httpd, "") + try_get(httpd, "?foo=bar") + + # POST requests + try_post(httpd, "") + try_post(httpd, "?foo=bar") + + # DEL requests + try_del(httpd, "") + try_del(httpd, "?foo=bar") + + # GET: By default we don't serve any files if we just define an API + with pytest.raises(HTTPError) as exc_info: + urlopen(httpd_url(httpd, "/")) + + assert exc_info.value.code == 404 + + +def test_nonexistent_resources(httpd_no_urlhandlers): + # GET: Return 404 for non-existent endpoint + with pytest.raises(HTTPError) as excinfo: + urlopen(httpd_url(httpd_no_urlhandlers, "/api/resource/")) + assert excinfo.value.code == 404 + + # POST: POST should also return 404 + with pytest.raises(HTTPError) as excinfo: + urlopen( + httpd_url(httpd_no_urlhandlers, "/api/resource/"), + data=ensure_binary(json.dumps({})), + ) + assert excinfo.value.code == 404 + + # DEL: DEL should also return 404 + opener = build_opener(HTTPHandler) + request = Request(httpd_url(httpd_no_urlhandlers, "/api/resource/")) + request.get_method = lambda: "DEL" + + with pytest.raises(HTTPError) as excinfo: + opener.open(request) + assert excinfo.value.code == 404 + + +def test_api_with_docroot(httpd_with_docroot, try_get): + f = urlopen(httpd_url(httpd_with_docroot, "/")) + assert f.getcode() == 200 + assert "Directory listing for" in ensure_str(f.read()) + + # Make sure API methods still work + try_get(httpd_with_docroot, "") + try_get(httpd_with_docroot, "?foo=bar") + + +def index_contents(host): + """Return the expected index contents for the given host.""" + return "{host} index".format(host=host) + + +@pytest.fixture(name="hosts") +def fixture_hosts(): + """Returns a tuple of hosts.""" + return ("mozilla.com", "mozilla.org") + + +@pytest.fixture(name="docroot") +def fixture_docroot(tmpdir): + """Returns a path object to a temporary docroot directory.""" + docroot = tmpdir.mkdir("docroot") + index_file = docroot.join("index.html") + index_file.write(index_contents("*")) + + yield docroot + + docroot.remove() + + +@pytest.fixture(name="httpd_with_proxy_handler") +def fixture_httpd_with_proxy_handler(docroot): + """Yields a started MozHttpd server for the proxy test.""" + + httpd = mozhttpd.MozHttpd(port=0, docroot=str(docroot)) + httpd.start(block=False) + + port = httpd.httpd.server_port + proxy_support = ProxyHandler( + { + "http": "http://127.0.0.1:{port:d}".format(port=port), + } + ) + install_opener(build_opener(proxy_support)) + + yield httpd + + httpd.stop() + + # Reset proxy opener in case it changed + install_opener(None) + + +def test_proxy(httpd_with_proxy_handler, hosts): + for host in hosts: + f = urlopen("http://{host}/".format(host=host)) + assert f.getcode() == 200 + assert f.read() == ensure_binary(index_contents("*")) + + +@pytest.fixture(name="httpd_with_proxy_host_dirs") +def fixture_httpd_with_proxy_host_dirs(docroot, hosts): + for host in hosts: + index_file = docroot.mkdir(host).join("index.html") + index_file.write(index_contents(host)) + + httpd = mozhttpd.MozHttpd(port=0, docroot=str(docroot), proxy_host_dirs=True) + + httpd.start(block=False) + + port = httpd.httpd.server_port + proxy_support = ProxyHandler( + {"http": "http://127.0.0.1:{port:d}".format(port=port)} + ) + install_opener(build_opener(proxy_support)) + + yield httpd + + httpd.stop() + + # Reset proxy opener in case it changed + install_opener(None) + + +def test_proxy_separate_directories(httpd_with_proxy_host_dirs, hosts): + for host in hosts: + f = urlopen("http://{host}/".format(host=host)) + assert f.getcode() == 200 + assert f.read() == ensure_binary(index_contents(host)) + + unproxied_host = "notmozilla.org" + + with pytest.raises(HTTPError) as excinfo: + urlopen("http://{host}/".format(host=unproxied_host)) + + assert excinfo.value.code == 404 + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozhttpd/tests/baseurl.py b/testing/mozbase/mozhttpd/tests/baseurl.py new file mode 100644 index 0000000000..4bf923a8d7 --- /dev/null +++ b/testing/mozbase/mozhttpd/tests/baseurl.py @@ -0,0 +1,33 @@ +import mozhttpd +import mozunit +import pytest + + +@pytest.fixture(name="httpd") +def fixture_httpd(): + """Yields a started MozHttpd server.""" + httpd = mozhttpd.MozHttpd(port=0) + httpd.start(block=False) + yield httpd + httpd.stop() + + +def test_base_url(httpd): + port = httpd.httpd.server_port + + want = "http://127.0.0.1:{}/".format(port) + got = httpd.get_url() + assert got == want + + want = "http://127.0.0.1:{}/cheezburgers.html".format(port) + got = httpd.get_url(path="/cheezburgers.html") + assert got == want + + +def test_base_url_when_not_started(): + httpd = mozhttpd.MozHttpd(port=0) + assert httpd.get_url() is None + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozhttpd/tests/basic.py b/testing/mozbase/mozhttpd/tests/basic.py new file mode 100644 index 0000000000..a9dcf109e0 --- /dev/null +++ b/testing/mozbase/mozhttpd/tests/basic.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python + +import os + +import mozfile +import mozhttpd +import mozunit +import pytest + + +@pytest.fixture(name="files") +def fixture_files(): + """Return a list of tuples with name and binary_string.""" + return [("small", os.urandom(128)), ("large", os.urandom(16384))] + + +@pytest.fixture(name="docroot") +def fixture_docroot(tmpdir, files): + """Yield a str path to docroot.""" + docroot = tmpdir.mkdir("docroot") + + for name, binary_string in files: + filename = docroot.join(name) + filename.write_binary(binary_string) + + yield str(docroot) + + docroot.remove() + + +@pytest.fixture(name="httpd_url") +def fixture_httpd_url(docroot): + """Yield the URL to a started MozHttpd server.""" + httpd = mozhttpd.MozHttpd(docroot=docroot) + httpd.start() + yield httpd.get_url() + httpd.stop() + + +def test_basic(httpd_url, files): + """Test that mozhttpd can serve files.""" + + # Retrieve file and check contents matchup + for name, binary_string in files: + retrieved_content = mozfile.load(httpd_url + name).read() + assert retrieved_content == binary_string + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozhttpd/tests/filelisting.py b/testing/mozbase/mozhttpd/tests/filelisting.py new file mode 100644 index 0000000000..195059a261 --- /dev/null +++ b/testing/mozbase/mozhttpd/tests/filelisting.py @@ -0,0 +1,68 @@ +#!/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/. + +import os +import re + +import mozhttpd +import mozunit +import pytest +from six import ensure_str +from six.moves.urllib.request import urlopen + + +@pytest.fixture(name="docroot") +def fixture_docroot(): + """Returns a docroot path.""" + return os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(name="httpd") +def fixture_httpd(docroot): + """Yields a started MozHttpd server.""" + httpd = mozhttpd.MozHttpd(port=0, docroot=docroot) + httpd.start(block=False) + yield httpd + httpd.stop() + + +@pytest.mark.parametrize( + "path", + [ + pytest.param("", id="no_params"), + pytest.param("?foo=bar&fleem=&foo=fleem", id="with_params"), + ], +) +def test_filelist(httpd, docroot, path): + f = urlopen( + "http://{host}:{port}/{path}".format( + host="127.0.0.1", port=httpd.httpd.server_port, path=path + ) + ) + + filelist = os.listdir(docroot) + + pattern = "\<[a-zA-Z0-9\-\_\.\=\"'\/\\\%\!\@\#\$\^\&\*\(\) :;]*\>" + + for line in f.readlines(): + subbed_lined = re.sub(pattern, "", ensure_str(line).strip("\n")) + webline = subbed_lined.strip("/").strip().strip("@") + + if ( + webline + and not webline.startswith("Directory listing for") + and not webline.startswith("<!DOCTYPE") + ): + msg = "File {} in dir listing corresponds to a file".format(webline) + assert webline in filelist, msg + filelist.remove(webline) + + msg = "Should have no items in filelist ({}) unaccounted for".format(filelist) + assert len(filelist) == 0, msg + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozhttpd/tests/manifest.toml b/testing/mozbase/mozhttpd/tests/manifest.toml new file mode 100644 index 0000000000..59c9be5ed0 --- /dev/null +++ b/testing/mozbase/mozhttpd/tests/manifest.toml @@ -0,0 +1,16 @@ +[DEFAULT] +subsuite = "mozbase" + +["api.py"] +skip-if = ["true"] + +["baseurl.py"] + +["basic.py"] + +["filelisting.py"] +skip-if = ["true"] + +["paths.py"] + +["requestlog.py"] diff --git a/testing/mozbase/mozhttpd/tests/paths.py b/testing/mozbase/mozhttpd/tests/paths.py new file mode 100644 index 0000000000..6d4c2ce953 --- /dev/null +++ b/testing/mozbase/mozhttpd/tests/paths.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python + +# Any copyright is dedicated to the Public Domain. +# http://creativecommons.org/publicdomain/zero/1.0/ + +import mozhttpd +import mozunit +import pytest +from six.moves.urllib.error import HTTPError +from six.moves.urllib.request import urlopen + + +def try_get(url, expected_contents): + f = urlopen(url) + assert f.getcode() == 200 + assert f.read() == expected_contents + + +def try_get_expect_404(url): + with pytest.raises(HTTPError) as excinfo: + urlopen(url) + assert excinfo.value.code == 404 + + +@pytest.fixture(name="httpd_basic") +def fixture_httpd_basic(tmpdir): + d1 = tmpdir.mkdir("d1") + d1.join("test1.txt").write("test 1 contents") + + d2 = tmpdir.mkdir("d2") + d2.join("test2.txt").write("test 2 contents") + + httpd = mozhttpd.MozHttpd( + port=0, + docroot=str(d1), + path_mappings={"/files": str(d2)}, + ) + httpd.start(block=False) + + yield httpd + + httpd.stop() + d1.remove() + d2.remove() + + +def test_basic(httpd_basic): + """Test that requests to docroot and a path mapping work as expected.""" + try_get(httpd_basic.get_url("/test1.txt"), b"test 1 contents") + try_get(httpd_basic.get_url("/files/test2.txt"), b"test 2 contents") + try_get_expect_404(httpd_basic.get_url("/files/test2_nope.txt")) + + +@pytest.fixture(name="httpd_substring_mappings") +def fixture_httpd_substring_mappings(tmpdir): + d1 = tmpdir.mkdir("d1") + d1.join("test1.txt").write("test 1 contents") + + d2 = tmpdir.mkdir("d2") + d2.join("test2.txt").write("test 2 contents") + + httpd = mozhttpd.MozHttpd( + port=0, + path_mappings={"/abcxyz": str(d1), "/abc": str(d2)}, + ) + httpd.start(block=False) + yield httpd + httpd.stop() + d1.remove() + d2.remove() + + +def test_substring_mappings(httpd_substring_mappings): + httpd = httpd_substring_mappings + try_get(httpd.get_url("/abcxyz/test1.txt"), b"test 1 contents") + try_get(httpd.get_url("/abc/test2.txt"), b"test 2 contents") + + +@pytest.fixture(name="httpd_multipart_path_mapping") +def fixture_httpd_multipart_path_mapping(tmpdir): + d1 = tmpdir.mkdir("d1") + d1.join("test1.txt").write("test 1 contents") + + httpd = mozhttpd.MozHttpd( + port=0, + path_mappings={"/abc/def/ghi": str(d1)}, + ) + httpd.start(block=False) + yield httpd + httpd.stop() + d1.remove() + + +def test_multipart_path_mapping(httpd_multipart_path_mapping): + """Test that a path mapping with multiple directories works.""" + httpd = httpd_multipart_path_mapping + try_get(httpd.get_url("/abc/def/ghi/test1.txt"), b"test 1 contents") + try_get_expect_404(httpd.get_url("/abc/test1.txt")) + try_get_expect_404(httpd.get_url("/abc/def/test1.txt")) + + +@pytest.fixture(name="httpd_no_docroot") +def fixture_httpd_no_docroot(tmpdir): + d1 = tmpdir.mkdir("d1") + httpd = mozhttpd.MozHttpd( + port=0, + path_mappings={"/foo": str(d1)}, + ) + httpd.start(block=False) + yield httpd + httpd.stop() + d1.remove() + + +def test_no_docroot(httpd_no_docroot): + """Test that path mappings with no docroot work.""" + try_get_expect_404(httpd_no_docroot.get_url()) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozhttpd/tests/requestlog.py b/testing/mozbase/mozhttpd/tests/requestlog.py new file mode 100644 index 0000000000..8e7b065f3d --- /dev/null +++ b/testing/mozbase/mozhttpd/tests/requestlog.py @@ -0,0 +1,62 @@ +# 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/. + +import os + +import mozhttpd +import mozunit +import pytest +from six.moves.urllib.request import urlopen + + +def log_requests(enabled): + """Decorator to change the log_requests parameter for MozHttpd.""" + param_id = "enabled" if enabled else "disabled" + return pytest.mark.parametrize("log_requests", [enabled], ids=[param_id]) + + +@pytest.fixture(name="docroot") +def fixture_docroot(): + """Return a docroot path.""" + return os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(name="request_log") +def fixture_request_log(docroot, log_requests): + """Yields the request log of a started MozHttpd server.""" + httpd = mozhttpd.MozHttpd( + port=0, + docroot=docroot, + log_requests=log_requests, + ) + httpd.start(block=False) + + url = "http://{host}:{port}/".format( + host="127.0.0.1", + port=httpd.httpd.server_port, + ) + f = urlopen(url) + f.read() + + yield httpd.request_log + + httpd.stop() + + +@log_requests(True) +def test_logging_enabled(request_log): + assert len(request_log) == 1 + log_entry = request_log[0] + assert log_entry["method"] == "GET" + assert log_entry["path"] == "/" + assert type(log_entry["time"]) == float + + +@log_requests(False) +def test_logging_disabled(request_log): + assert len(request_log) == 0 + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozinfo/mozinfo/__init__.py b/testing/mozbase/mozinfo/mozinfo/__init__.py new file mode 100644 index 0000000000..9e091e0c91 --- /dev/null +++ b/testing/mozbase/mozinfo/mozinfo/__init__.py @@ -0,0 +1,58 @@ +# flake8: noqa +# 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/. + +""" +interface to transform introspected system information to a format palatable to +Mozilla + +Module variables: + +.. attribute:: bits + + 32 or 64 + +.. attribute:: isBsd + + Returns ``True`` if the operating system is BSD + +.. attribute:: isLinux + + Returns ``True`` if the operating system is Linux + +.. attribute:: isMac + + Returns ``True`` if the operating system is Mac + +.. attribute:: isWin + + Returns ``True`` if the operating system is Windows + +.. attribute:: os + + Operating system [``'win'``, ``'mac'``, ``'linux'``, ...] + +.. attribute:: processor + + Processor architecture [``'x86'``, ``'x86_64'``, ``'ppc'``, ...] + +.. attribute:: version + + Operating system version string. For windows, the service pack information is also included + +.. attribute:: info + + Returns information identifying the current system. + + * :attr:`bits` + * :attr:`os` + * :attr:`processor` + * :attr:`version` + +""" + +from . import mozinfo +from .mozinfo import * + +__all__ = mozinfo.__all__ diff --git a/testing/mozbase/mozinfo/mozinfo/mozinfo.py b/testing/mozbase/mozinfo/mozinfo/mozinfo.py new file mode 100755 index 0000000000..bb04be54c8 --- /dev/null +++ b/testing/mozbase/mozinfo/mozinfo/mozinfo.py @@ -0,0 +1,363 @@ +#!/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/. + +# TODO: it might be a good idea of adding a system name (e.g. 'Ubuntu' for +# linux) to the information; I certainly wouldn't want anyone parsing this +# information and having behaviour depend on it + +import os +import platform +import re +import sys +from ctypes.util import find_library + +from .string_version import StringVersion + +# keep a copy of the os module since updating globals overrides this +_os = os + + +class unknown(object): + """marker class for unknown information""" + + # pylint: disable=W1629 + def __nonzero__(self): + return False + + def __bool__(self): + return False + + def __str__(self): + return "UNKNOWN" + + +unknown = unknown() # singleton + + +# get system information +info = { + "os": unknown, + "processor": unknown, + "version": unknown, + "os_version": unknown, + "bits": unknown, + "has_sandbox": unknown, + "display": None, + "automation": bool(os.environ.get("MOZ_AUTOMATION", False)), +} +(system, node, release, version, machine, processor) = platform.uname() +(bits, linkage) = platform.architecture() + +# get os information and related data +if system in ["Microsoft", "Windows"]: + info["os"] = "win" + # There is a Python bug on Windows to determine platform values + # http://bugs.python.org/issue7860 + if "PROCESSOR_ARCHITEW6432" in os.environ: + processor = os.environ.get("PROCESSOR_ARCHITEW6432", processor) + else: + processor = os.environ.get("PROCESSOR_ARCHITECTURE", processor) + system = os.environ.get("OS", system).replace("_", " ") + (major, minor, build_number, _, _) = os.sys.getwindowsversion() + version = "%d.%d.%d" % (major, minor, build_number) + if major == 10 and minor == 0 and build_number >= 22000: + major = 11 + os_version = "%d.%d" % (major, minor) +elif system.startswith(("MINGW", "MSYS_NT")): + # windows/mingw python build (msys) + info["os"] = "win" + os_version = version = unknown +elif system == "Linux": + # Attempt to use distro package to determine Linux distribution first. + # Failing that, fall back to use the platform method. + # Note that platform.linux_distribution() will be deprecated as of 3.8 + # and this block will be removed once support for 2.7/3.5 is dropped. + try: + from distro import linux_distribution + except ImportError: + from platform import linux_distribution + + output = linux_distribution() + (distribution, os_version, codename) = tuple(str(item.title()) for item in output) + + if not processor: + processor = machine + if not distribution: + distribution = "lfs" + if not os_version: + os_version = release + if not codename: + codename = "unknown" + version = "%s %s" % (distribution, os_version) + + if os.environ.get("WAYLAND_DISPLAY"): + info["display"] = "wayland" + elif os.environ.get("DISPLAY"): + info["display"] = "x11" + + info["os"] = "linux" + info["linux_distro"] = distribution +elif system in ["DragonFly", "FreeBSD", "NetBSD", "OpenBSD"]: + info["os"] = "bsd" + version = os_version = sys.platform +elif system == "Darwin": + (release, versioninfo, machine) = platform.mac_ver() + version = "OS X %s" % release + versionNums = release.split(".")[:2] + os_version = "%s.%s" % (versionNums[0], versionNums[1]) + info["os"] = "mac" +elif sys.platform in ("solaris", "sunos5"): + info["os"] = "unix" + os_version = version = sys.platform +else: + os_version = version = unknown + +info["apple_silicon"] = False +if ( + info["os"] == "mac" + and float(os_version) > 10.15 + and processor == "arm" + and bits == "64bit" +): + info["apple_silicon"] = True + +info["apple_catalina"] = False +if info["os"] == "mac" and float(os_version) == 10.15: + info["apple_catalina"] = True + +info["win10_2009"] = False +if info["os"] == "win" and version == "10.0.19045": + info["win10_2009"] = True + +info["win11_2009"] = False +if info["os"] == "win" and version == "10.0.22621": + info["win11_2009"] = True + +info["version"] = version +info["os_version"] = StringVersion(os_version) +info["is_ubuntu"] = "Ubuntu" in version + + +# processor type and bits +if processor in ["i386", "i686"]: + if bits == "32bit": + processor = "x86" + elif bits == "64bit": + processor = "x86_64" +elif processor.upper() == "AMD64": + bits = "64bit" + processor = "x86_64" +elif processor.upper() == "ARM64": + bits = "64bit" + processor = "aarch64" +elif processor == "Power Macintosh": + processor = "ppc" +elif processor == "arm" and bits == "64bit": + processor = "aarch64" + +bits = re.search(r"(\d+)bit", bits).group(1) +info.update( + { + "processor": processor, + "bits": int(bits), + } +) + +# we want to transition to this instead of using `!debug`, etc. +info["arch"] = info["processor"] + + +if info["os"] == "linux": + import ctypes + import errno + + PR_SET_SECCOMP = 22 + SECCOMP_MODE_FILTER = 2 + ctypes.CDLL(find_library("c"), use_errno=True).prctl( + PR_SET_SECCOMP, SECCOMP_MODE_FILTER, 0 + ) + info["has_sandbox"] = ctypes.get_errno() == errno.EFAULT +else: + info["has_sandbox"] = True + +# standard value of choices, for easy inspection +choices = { + "os": ["linux", "bsd", "win", "mac", "unix"], + "bits": [32, 64], + "processor": ["x86", "x86_64", "ppc"], +} + + +def sanitize(info): + """Do some sanitization of input values, primarily + to handle universal Mac builds.""" + if "processor" in info and info["processor"] == "universal-x86-x86_64": + # If we're running on OS X 10.6 or newer, assume 64-bit + if release[:4] >= "10.6": # Note this is a string comparison + info["processor"] = "x86_64" + info["bits"] = 64 + else: + info["processor"] = "x86" + info["bits"] = 32 + + +# method for updating information + + +def update(new_info): + """ + Update the info. + + :param new_info: Either a dict containing the new info or a path/url + to a json file containing the new info. + """ + from six import string_types + + if isinstance(new_info, string_types): + # lazy import + import json + + import mozfile + + f = mozfile.load(new_info) + new_info = json.loads(f.read()) + f.close() + + info.update(new_info) + sanitize(info) + globals().update(info) + + # convenience data for os access + for os_name in choices["os"]: + globals()["is" + os_name.title()] = info["os"] == os_name + # unix is special + if isLinux or isBsd: # noqa + globals()["isUnix"] = True + + +def find_and_update_from_json(*dirs, **kwargs): + """Find a mozinfo.json file, load it, and update global symbol table. + + This method will first check the relevant objdir directory for the + necessary mozinfo.json file, if the current script is being run from a + Mozilla objdir. + + If the objdir directory did not supply the necessary data, this method + will then look for the required mozinfo.json file from the provided + tuple of directories. + + If file is found, the global symbols table is updated via a helper method. + + If no valid files are found, this method no-ops unless the raise_exception + kwargs is provided with explicit boolean value of True. + + :param tuple dirs: Directories in which to look for the file. + :param dict kwargs: optional values: + raise_exception: if True, exceptions are raised. + False by default. + :returns: None: default behavior if mozinfo.json cannot be found. + json_path: string representation of mozinfo.json path. + :raises: IOError: if raise_exception is True and file is not found. + """ + # First, see if we're in an objdir + try: + from mozboot.mozconfig import MozconfigFindException + from mozbuild.base import BuildEnvironmentNotFoundException, MozbuildObject + + build = MozbuildObject.from_environment() + json_path = _os.path.join(build.topobjdir, "mozinfo.json") + if _os.path.isfile(json_path): + update(json_path) + return json_path + except ImportError: + pass + except (BuildEnvironmentNotFoundException, MozconfigFindException): + pass + + for d in dirs: + d = _os.path.abspath(d) + json_path = _os.path.join(d, "mozinfo.json") + if _os.path.isfile(json_path): + update(json_path) + return json_path + + # by default, exceptions are suppressed. Set this to True if otherwise + # desired. + if kwargs.get("raise_exception", False): + raise IOError("mozinfo.json could not be found.") + return None + + +def output_to_file(path): + import json + + with open(path, "w") as f: + f.write(json.dumps(info)) + + +update({}) + +# exports +__all__ = list(info.keys()) +__all__ += ["is" + os_name.title() for os_name in choices["os"]] +__all__ += [ + "info", + "unknown", + "main", + "choices", + "update", + "find_and_update_from_json", + "output_to_file", + "StringVersion", +] + + +def main(args=None): + # parse the command line + from optparse import OptionParser + + parser = OptionParser(description=__doc__) + for key in choices: + parser.add_option( + "--%s" % key, + dest=key, + action="store_true", + default=False, + help="display choices for %s" % key, + ) + options, args = parser.parse_args() + + # args are JSON blobs to override info + if args: + # lazy import + import json + + for arg in args: + if _os.path.exists(arg): + string = open(arg).read() + else: + string = arg + update(json.loads(string)) + + # print out choices if requested + flag = False + for key, value in options.__dict__.items(): + if value is True: + print( + "%s choices: %s" + % (key, " ".join([str(choice) for choice in choices[key]])) + ) + flag = True + if flag: + return + + # otherwise, print out all info + for key, value in info.items(): + print("%s: %s" % (key, value)) + + +if __name__ == "__main__": + main() diff --git a/testing/mozbase/mozinfo/mozinfo/string_version.py b/testing/mozbase/mozinfo/mozinfo/string_version.py new file mode 100644 index 0000000000..fc1c5b46c6 --- /dev/null +++ b/testing/mozbase/mozinfo/mozinfo/string_version.py @@ -0,0 +1,73 @@ +# 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/. + +import re + +import six + + +class StringVersion(six.text_type): + """ + A string version that can be compared with comparison operators. + """ + + # Pick out numeric and non-numeric parts (a match group for each type). + pat = re.compile(r"(\d+)|([^\d.]+)") + + def __init__(self, vstring): + super(StringVersion, self).__init__() + + # We'll use unicode internally. + # This check is mainly for python2 strings (which are bytes). + if isinstance(vstring, bytes): + vstring = vstring.decode("ascii") + + self.vstring = vstring + + # Store parts as strings to ease comparisons. + self.version = [] + parts = self.pat.findall(vstring) + # Pad numeric parts with leading zeros for ordering. + for i, obj in enumerate(parts): + if obj[0]: + self.version.append(obj[0].zfill(8)) + else: + self.version.append(obj[1]) + + def __str__(self): + return self.vstring + + def __repr__(self): + return "StringVersion ('%s')" % str(self) + + def _cmp(self, other): + if not isinstance(other, StringVersion): + other = StringVersion(other) + + if self.version == other.version: + return 0 + if self.version < other.version: + return -1 + if self.version > other.version: + return 1 + + def __hash__(self): + # pylint --py3k: W1641 + return hash(self.version) + + # operator overloads + def __eq__(self, other): + return self._cmp(other) == 0 + + def __lt__(self, other): + return self._cmp(other) < 0 + + def __le__(self, other): + return self._cmp(other) <= 0 + + def __gt__(self, other): + return self._cmp(other) > 0 + + def __ge__(self, other): + return self._cmp(other) >= 0 diff --git a/testing/mozbase/mozinfo/setup.cfg b/testing/mozbase/mozinfo/setup.cfg new file mode 100644 index 0000000000..3c6e79cf31 --- /dev/null +++ b/testing/mozbase/mozinfo/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 diff --git a/testing/mozbase/mozinfo/setup.py b/testing/mozbase/mozinfo/setup.py new file mode 100644 index 0000000000..87db88d1e4 --- /dev/null +++ b/testing/mozbase/mozinfo/setup.py @@ -0,0 +1,41 @@ +# 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 setuptools import setup + +PACKAGE_VERSION = "1.2.3" + +# dependencies +deps = [ + "distro >= 1.4.0", + "mozfile >= 0.12", +] + +setup( + name="mozinfo", + version=PACKAGE_VERSION, + description="Library to get system information for use in Mozilla testing", + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + classifiers=[ + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Development Status :: 5 - Production/Stable", + ], + # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + keywords="mozilla", + author="Mozilla Automation and Testing Team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + license="MPL", + packages=["mozinfo"], + include_package_data=True, + zip_safe=False, + install_requires=deps, + entry_points=""" + # -*- Entry points: -*- + [console_scripts] + mozinfo = mozinfo:main + """, +) diff --git a/testing/mozbase/mozinfo/tests/manifest.toml b/testing/mozbase/mozinfo/tests/manifest.toml new file mode 100644 index 0000000000..147e23872e --- /dev/null +++ b/testing/mozbase/mozinfo/tests/manifest.toml @@ -0,0 +1,4 @@ +[DEFAULT] +subsuite = "mozbase" + +["test.py"] diff --git a/testing/mozbase/mozinfo/tests/test.py b/testing/mozbase/mozinfo/tests/test.py new file mode 100644 index 0000000000..f1d971d317 --- /dev/null +++ b/testing/mozbase/mozinfo/tests/test.py @@ -0,0 +1,176 @@ +#!/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/. + +import json +import os +import sys +from importlib import reload +from unittest import mock + +import mozinfo +import mozunit +import pytest + + +@pytest.fixture(autouse=True) +def on_every_test(): + # per-test set up + reload(mozinfo) + + # When running from an objdir mozinfo will use a build generated json file + # instead of the ones created for testing. Prevent that from happening. + # See bug 896038 for details. + sys.modules["mozbuild"] = None + + yield + + # per-test tear down + del sys.modules["mozbuild"] + + +def test_basic(): + """Test that mozinfo has a few attributes.""" + assert mozinfo.os is not None + # should have isFoo == True where os == "foo" + assert getattr(mozinfo, "is" + mozinfo.os[0].upper() + mozinfo.os[1:]) + + +def test_update(): + """Test that mozinfo.update works.""" + mozinfo.update({"foo": 123}) + assert mozinfo.info["foo"] == 123 + + +def test_update_file(tmpdir): + """Test that mozinfo.update can load a JSON file.""" + j = os.path.join(tmpdir, "mozinfo.json") + with open(j, "w") as f: + f.write(json.dumps({"foo": "xyz"})) + mozinfo.update(j) + assert mozinfo.info["foo"] == "xyz" + + +def test_update_file_invalid_json(tmpdir): + """Test that mozinfo.update handles invalid JSON correctly""" + j = os.path.join(tmpdir, "test.json") + with open(j, "w") as f: + f.write('invalid{"json":') + with pytest.raises(ValueError): + mozinfo.update([j]) + + +def test_find_and_update_file(tmpdir): + """Test that mozinfo.find_and_update_from_json can + find mozinfo.json in a directory passed to it.""" + j = os.path.join(tmpdir, "mozinfo.json") + with open(j, "w") as f: + f.write(json.dumps({"foo": "abcdefg"})) + assert mozinfo.find_and_update_from_json(tmpdir) == j + assert mozinfo.info["foo"] == "abcdefg" + + +def test_find_and_update_file_no_argument(): + """Test that mozinfo.find_and_update_from_json no-ops on not being + given any arguments. + """ + assert mozinfo.find_and_update_from_json() is None + + +def test_find_and_update_file_invalid_json(tmpdir): + """Test that mozinfo.find_and_update_from_json can + handle invalid JSON""" + j = os.path.join(tmpdir, "mozinfo.json") + with open(j, "w") as f: + f.write('invalid{"json":') + with pytest.raises(ValueError): + mozinfo.find_and_update_from_json(tmpdir) + + +def test_find_and_update_file_raise_exception(): + """Test that mozinfo.find_and_update_from_json raises + an IOError when exceptions are unsuppressed. + """ + with pytest.raises(IOError): + mozinfo.find_and_update_from_json(raise_exception=True) + + +def test_find_and_update_file_suppress_exception(): + """Test that mozinfo.find_and_update_from_json suppresses + an IOError exception if a False boolean value is + provided as the only argument. + """ + assert mozinfo.find_and_update_from_json(raise_exception=False) is None + + +def test_find_and_update_file_mozbuild(tmpdir): + """Test that mozinfo.find_and_update_from_json can + find mozinfo.json using the mozbuild module.""" + j = os.path.join(tmpdir, "mozinfo.json") + with open(j, "w") as f: + f.write(json.dumps({"foo": "123456"})) + m = mock.MagicMock() + # Mock the value of MozbuildObject.from_environment().topobjdir. + m.MozbuildObject.from_environment.return_value.topobjdir = tmpdir + + mocked_modules = { + "mozbuild": m, + "mozbuild.base": m, + "mozbuild.mozconfig": m, + } + with mock.patch.dict(sys.modules, mocked_modules): + assert mozinfo.find_and_update_from_json() == j + assert mozinfo.info["foo"] == "123456" + + +def test_output_to_file(tmpdir): + """Test that mozinfo.output_to_file works.""" + path = os.path.join(tmpdir, "mozinfo.json") + mozinfo.output_to_file(path) + assert open(path).read() == json.dumps(mozinfo.info) + + +def test_os_version_is_a_StringVersion(): + assert isinstance(mozinfo.os_version, mozinfo.StringVersion) + + +def test_compare_to_string(): + version = mozinfo.StringVersion("10.10") + + assert version > "10.2" + assert "11" > version + assert version >= "10.10" + assert "10.11" >= version + assert version == "10.10" + assert "10.10" == version + assert version != "10.2" + assert "11" != version + assert version < "11.8.5" + assert "10.2" < version + assert version <= "11" + assert "10.10" <= version + + # Can have non-numeric versions (Bug 1654915) + assert version != mozinfo.StringVersion("Testing") + assert mozinfo.StringVersion("Testing") != version + assert mozinfo.StringVersion("") == "" + assert "" == mozinfo.StringVersion("") + + a = mozinfo.StringVersion("1.2.5a") + b = mozinfo.StringVersion("1.2.5b") + assert a < b + assert b > a + + # Make sure we can compare against unicode (for python 2). + assert a == "1.2.5a" + assert "1.2.5a" == a + + +def test_to_string(): + assert "10.10" == str(mozinfo.StringVersion("10.10")) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozinstall/mozinstall/__init__.py b/testing/mozbase/mozinstall/mozinstall/__init__.py new file mode 100644 index 0000000000..09c6d10a3d --- /dev/null +++ b/testing/mozbase/mozinstall/mozinstall/__init__.py @@ -0,0 +1,6 @@ +# flake8: noqa +# 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 .mozinstall import * diff --git a/testing/mozbase/mozinstall/mozinstall/mozinstall.py b/testing/mozbase/mozinstall/mozinstall/mozinstall.py new file mode 100644 index 0000000000..7ff5b52d18 --- /dev/null +++ b/testing/mozbase/mozinstall/mozinstall/mozinstall.py @@ -0,0 +1,443 @@ +# 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/. + +import os +import plistlib +import shutil +import subprocess +import sys +import tarfile +import tempfile +import time +import zipfile +from optparse import OptionParser + +import mozfile +import mozinfo +import requests +from six import PY3, reraise + +try: + import pefile + + has_pefile = True +except ImportError: + has_pefile = False + + +TIMEOUT_UNINSTALL = 60 + + +class InstallError(Exception): + """Thrown when installation fails. Includes traceback if available.""" + + +class InvalidBinary(Exception): + """Thrown when the binary cannot be found after the installation.""" + + +class InvalidSource(Exception): + """Thrown when the specified source is not a recognized file type. + + Supported types: + Linux: tar.gz, tar.bz2 + Mac: dmg + Windows: zip, exe + + """ + + +class UninstallError(Exception): + """Thrown when uninstallation fails. Includes traceback if available.""" + + +def _readPlist(path): + if PY3: + with open(path, "rb") as fp: + return plistlib.load(fp) + return plistlib.readPlist(path) + + +def get_binary(path, app_name): + """Find the binary in the specified path, and return its path. If binary is + not found throw an InvalidBinary exception. + + :param path: Path within to search for the binary + :param app_name: Application binary without file extension to look for + """ + binary = None + + # On OS X we can get the real binary from the app bundle + if mozinfo.isMac: + plist = "%s/Contents/Info.plist" % path + if not os.path.isfile(plist): + raise InvalidBinary("%s/Contents/Info.plist not found" % path) + + binary = os.path.join( + path, "Contents/MacOS/", _readPlist(plist)["CFBundleExecutable"] + ) + + else: + app_name = app_name.lower() + + if mozinfo.isWin: + app_name = app_name + ".exe" + + for root, dirs, files in os.walk(path): + for filename in files: + # os.access evaluates to False for some reason, so not using it + if filename.lower() == app_name: + binary = os.path.realpath(os.path.join(root, filename)) + break + + if not binary: + # The expected binary has not been found. + raise InvalidBinary('"%s" does not contain a valid binary.' % path) + + return binary + + +def install(src, dest): + """Install a zip, exe, tar.gz, tar.bz2 or dmg file, and return the path of + the installation folder. + + :param src: Path to the install file + :param dest: Path to install to (to ensure we do not overwrite any existent + files the folder should not exist yet) + """ + if not is_installer(src): + msg = "{} is not a valid installer file".format(src) + if "://" in src: + try: + return _install_url(src, dest) + except Exception: + exc, val, tb = sys.exc_info() + error = InvalidSource("{} ({})".format(msg, val)) + reraise(InvalidSource, error, tb) + raise InvalidSource(msg) + + src = os.path.realpath(src) + dest = os.path.realpath(dest) + + did_we_create = False + if not os.path.exists(dest): + did_we_create = True + os.makedirs(dest) + + trbk = None + try: + install_dir = None + if src.lower().endswith(".dmg"): + install_dir = _install_dmg(src, dest) + elif src.lower().endswith(".exe"): + install_dir = _install_exe(src, dest) + elif src.lower().endswith(".msix"): + install_dir = _install_msix(src) + elif zipfile.is_zipfile(src) or tarfile.is_tarfile(src): + install_dir = mozfile.extract(src, dest)[0] + + return install_dir + + except BaseException: + cls, exc, trbk = sys.exc_info() + if did_we_create: + try: + # try to uninstall this properly + uninstall(dest) + except Exception: + # uninstall may fail, let's just try to clean the folder + # in this case + try: + mozfile.remove(dest) + except Exception: + pass + if issubclass(cls, Exception): + error = InstallError('Failed to install "%s (%s)"' % (src, str(exc))) + reraise(InstallError, error, trbk) + # any other kind of exception like KeyboardInterrupt is just re-raised. + reraise(cls, exc, trbk) + + finally: + # trbk won't get GC'ed due to circular reference + # http://docs.python.org/library/sys.html#sys.exc_info + del trbk + + +def is_installer(src): + """Tests if the given file is a valid installer package. + + Supported types: + Linux: tar.gz, tar.bz2 + Mac: dmg + Windows: zip, exe + + On Windows pefile will be used to determine if the executable is the + right type, if it is installed on the system. + + :param src: Path to the install file. + """ + src = os.path.realpath(src) + + if not os.path.isfile(src): + return False + + if mozinfo.isLinux: + return tarfile.is_tarfile(src) + elif mozinfo.isMac: + return src.lower().endswith(".dmg") + elif mozinfo.isWin: + if zipfile.is_zipfile(src): + return True + + if os.access(src, os.X_OK) and src.lower().endswith(".exe"): + if has_pefile: + # try to determine if binary is actually a gecko installer + pe_data = pefile.PE(src) + data = {} + for info in getattr(pe_data, "FileInfo", []): + if info.Key == "StringFileInfo": + for string in info.StringTable: + data.update(string.entries) + return "BuildID" not in data + else: + # pefile not available, just assume a proper binary was passed in + return True + + return False + + +def uninstall(install_folder): + """Uninstalls the application in the specified path. If it has been + installed via an installer on Windows, use the uninstaller first. + + :param install_folder: Path of the installation folder + + """ + # Uninstallation for MSIX applications is totally different than + # any other installs... + if "WindowsApps" in install_folder: + # At the time of writing, the package installation directory is always + # the package full name, so this assumption is valid (for now....). + packageFullName = install_folder.split("WindowsApps\\")[1].split("\\")[0] + cmd = f"powershell.exe Remove-AppxPackage -Package {packageFullName}" + subprocess.check_call(cmd) + return + + install_folder = os.path.realpath(install_folder) + assert os.path.isdir(install_folder), ( + 'installation folder "%s" exists.' % install_folder + ) + + # On Windows we have to use the uninstaller. If it's not available fallback + # to the directory removal code + if mozinfo.isWin: + uninstall_folder = "%s\\uninstall" % install_folder + log_file = "%s\\uninstall.log" % uninstall_folder + + if os.path.isfile(log_file): + trbk = None + try: + cmdArgs = ["%s\\uninstall\\helper.exe" % install_folder, "/S"] + result = subprocess.call(cmdArgs) + if result != 0: + raise Exception("Execution of uninstaller failed.") + + # The uninstaller spawns another process so the subprocess call + # returns immediately. We have to wait until the uninstall + # folder has been removed or until we run into a timeout. + end_time = time.time() + TIMEOUT_UNINSTALL + while os.path.exists(uninstall_folder): + time.sleep(1) + + if time.time() > end_time: + raise Exception("Failure removing uninstall folder.") + + except Exception as ex: + cls, exc, trbk = sys.exc_info() + error = UninstallError( + "Failed to uninstall %s (%s)" % (install_folder, str(ex)) + ) + reraise(UninstallError, error, trbk) + + finally: + # trbk won't get GC'ed due to circular reference + # http://docs.python.org/library/sys.html#sys.exc_info + del trbk + + # Ensure that we remove any trace of the installation. Even the uninstaller + # on Windows leaves files behind we have to explicitely remove. + mozfile.remove(install_folder) + + +def _install_url(url, dest): + """Saves a url to a temporary file, and passes that through to the + install function. + + :param url: Url to the install file + :param dest: Path to install to (to ensure we do not overwrite any existent + files the folder should not exist yet) + """ + r = requests.get(url, stream=True) + name = tempfile.mkstemp()[1] + try: + with open(name, "w+b") as fh: + for chunk in r.iter_content(chunk_size=16 * 1024): + fh.write(chunk) + result = install(name, dest) + finally: + mozfile.remove(name) + return result + + +def _install_dmg(src, dest): + """Extract a dmg file into the destination folder and return the + application folder. + + src -- DMG image which has to be extracted + dest -- the path to extract to + + """ + appDir = None + try: + # According to the Apple doc, the hdiutil output is stable and is based on the tab + # separators + # Therefor, $3 should give us the mounted path + appDir = ( + subprocess.check_output( + 'hdiutil attach -nobrowse -noautoopen "%s"' + "|grep /Volumes/" + "|awk 'BEGIN{FS=\"\t\"} {print $3}'" % str(src), + shell=True, + ) + .strip() + .decode("ascii") + ) + + for appFile in os.listdir(appDir): + if appFile.endswith(".app"): + appName = appFile + break + + mounted_path = os.path.join(appDir, appName) + + dest = os.path.join(dest, appName) + + # copytree() would fail if dest already exists. + if os.path.exists(dest): + raise InstallError('App bundle "%s" already exists.' % dest) + + shutil.copytree(mounted_path, dest, False) + + finally: + if appDir: + subprocess.check_call('hdiutil detach "%s" -quiet' % appDir, shell=True) + + return dest + + +def _install_exe(src, dest): + """Run the MSI installer to silently install the application into the + destination folder. Return the folder path. + + Arguments: + src -- MSI installer to be executed + dest -- the path to install to + + """ + # The installer doesn't automatically create a sub folder. Lets guess the + # best name from the src file name + filename = os.path.basename(src) + dest = os.path.join(dest, filename.split(".")[0]) + + # possibly gets around UAC in vista (still need to run as administrator) + os.environ["__compat_layer"] = "RunAsInvoker" + cmd = '"%s" /extractdir=%s' % (src, os.path.realpath(dest)) + + subprocess.check_call(cmd) + + return dest + + +def _get_msix_install_location(pkg): + with zipfile.ZipFile(pkg) as zf: + # First, we pull the app identity out of the AppxManifest... + with zf.open("AppxManifest.xml") as am: + for line in am.readlines(): + line = line.decode("utf-8") + if "<Identity" in line: + for part in line.split(" "): + if part.startswith("Name"): + pkgname = part.split("=")[-1].strip('"\r\n') + + # ...then we can use it to find the install location + # with this cmdlet + cmd = ( + f'powershell.exe "Get-AppxPackage" "-Name" "{pkgname}"' + ) + for line in ( + subprocess.check_output(cmd) + .decode("utf-8") + .splitlines() + ): + if line.startswith("InstallLocation"): + return "C:{}".format(line.split(":")[-1].strip()) + + raise Exception(f"Couldn't find install location of {pkg}") + + +def _install_msix(src): + """Install the MSIX package and return the installation path. + + Arguments: + src -- MSIX package to install + + """ + # possibly gets around UAC in vista (still need to run as administrator) + cmd = f'powershell.exe "Add-AppxPackage" "-Path" "{src}"' + subprocess.check_call(cmd) + + return _get_msix_install_location(src) + + +def install_cli(argv=sys.argv[1:]): + parser = OptionParser(usage="usage: %prog [options] installer") + parser.add_option( + "-d", + "--destination", + dest="dest", + default=os.getcwd(), + help="Directory to install application into. " '[default: "%default"]', + ) + parser.add_option( + "--app", + dest="app", + default="firefox", + help="Application being installed. [default: %default]", + ) + + (options, args) = parser.parse_args(argv) + if not len(args) == 1: + parser.error("An installer file has to be specified.") + + src = args[0] + + # Run it + if os.path.isdir(src): + binary = get_binary(src, app_name=options.app) + else: + install_path = install(src, options.dest) + binary = get_binary(install_path, app_name=options.app) + + print(binary) + + +def uninstall_cli(argv=sys.argv[1:]): + parser = OptionParser(usage="usage: %prog install_path") + + (options, args) = parser.parse_args(argv) + if not len(args) == 1: + parser.error("An installation path has to be specified.") + + # Run it + uninstall(argv[0]) diff --git a/testing/mozbase/mozinstall/setup.cfg b/testing/mozbase/mozinstall/setup.cfg new file mode 100644 index 0000000000..2a9acf13da --- /dev/null +++ b/testing/mozbase/mozinstall/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 diff --git a/testing/mozbase/mozinstall/setup.py b/testing/mozbase/mozinstall/setup.py new file mode 100644 index 0000000000..ecd1dec578 --- /dev/null +++ b/testing/mozbase/mozinstall/setup.py @@ -0,0 +1,59 @@ +# 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/. + +import os + +from setuptools import setup + +try: + here = os.path.dirname(os.path.abspath(__file__)) + description = open(os.path.join(here, "README.md")).read() +except IOError: + description = None + +PACKAGE_VERSION = "2.1.0" + +deps = [ + "mozinfo >= 0.7", + "mozfile >= 1.0", + "requests", + "six >= 1.13.0", +] + +setup( + name="mozInstall", + version=PACKAGE_VERSION, + description="package for installing and uninstalling Mozilla applications", + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + classifiers=[ + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", + "Natural Language :: English", + "Operating System :: OS Independent", + "Topic :: Software Development :: Libraries :: Python Modules", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + ], + keywords="mozilla", + author="Mozilla Automation and Tools team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + license="MPL 2.0", + packages=["mozinstall"], + include_package_data=True, + zip_safe=False, + install_requires=deps, + # we have to generate two more executables for those systems that cannot run as Administrator + # and the filename containing "install" triggers the UAC + entry_points=""" + # -*- Entry points: -*- + [console_scripts] + mozinstall = mozinstall:install_cli + mozuninstall = mozinstall:uninstall_cli + moz_add_to_system = mozinstall:install_cli + moz_remove_from_system = mozinstall:uninstall_cli + """, +) diff --git a/testing/mozbase/mozinstall/tests/conftest.py b/testing/mozbase/mozinstall/tests/conftest.py new file mode 100644 index 0000000000..132547a96b --- /dev/null +++ b/testing/mozbase/mozinstall/tests/conftest.py @@ -0,0 +1,14 @@ +import pytest + + +@pytest.fixture +def get_installer(request): + def _get_installer(extension): + """Get path to the installer for the specified extension.""" + stub_dir = request.node.fspath.dirpath("installer_stubs") + + # We had to remove firefox.exe since it is not valid for mozinstall 1.12 and higher + # Bug 1157352 - We should grab a firefox.exe from the build process or download it + return stub_dir.join("firefox.{}".format(extension)).strpath + + return _get_installer diff --git a/testing/mozbase/mozinstall/tests/installer_stubs/firefox.dmg b/testing/mozbase/mozinstall/tests/installer_stubs/firefox.dmg Binary files differnew file mode 100644 index 0000000000..dd9c779dfa --- /dev/null +++ b/testing/mozbase/mozinstall/tests/installer_stubs/firefox.dmg diff --git a/testing/mozbase/mozinstall/tests/installer_stubs/firefox.tar.bz2 b/testing/mozbase/mozinstall/tests/installer_stubs/firefox.tar.bz2 Binary files differnew file mode 100644 index 0000000000..cb046a0e7f --- /dev/null +++ b/testing/mozbase/mozinstall/tests/installer_stubs/firefox.tar.bz2 diff --git a/testing/mozbase/mozinstall/tests/installer_stubs/firefox.zip b/testing/mozbase/mozinstall/tests/installer_stubs/firefox.zip Binary files differnew file mode 100644 index 0000000000..7c3f61a5e9 --- /dev/null +++ b/testing/mozbase/mozinstall/tests/installer_stubs/firefox.zip diff --git a/testing/mozbase/mozinstall/tests/manifest.toml b/testing/mozbase/mozinstall/tests/manifest.toml new file mode 100644 index 0000000000..43c0d29fdf --- /dev/null +++ b/testing/mozbase/mozinstall/tests/manifest.toml @@ -0,0 +1,12 @@ +[DEFAULT] +subsuite = "mozbase" + +["test_binary.py"] +skip-if = ["os == 'mac'"] + +["test_install.py"] +skip-if = ["os == 'mac'"] # intermittent + +["test_is_installer.py"] + +["test_uninstall.py"] diff --git a/testing/mozbase/mozinstall/tests/test_binary.py b/testing/mozbase/mozinstall/tests/test_binary.py new file mode 100644 index 0000000000..6454c78ef5 --- /dev/null +++ b/testing/mozbase/mozinstall/tests/test_binary.py @@ -0,0 +1,50 @@ +import os + +import mozinfo +import mozinstall +import mozunit +import pytest + + +@pytest.mark.skipif( + mozinfo.isWin, + reason="Bug 1157352 - New firefox.exe needed for mozinstall 1.12 and higher.", +) +def test_get_binary(tmpdir, get_installer): + """Test to retrieve binary from install path.""" + if mozinfo.isLinux: + installdir = mozinstall.install(get_installer("tar.bz2"), tmpdir.strpath) + binary = os.path.join(installdir, "firefox") + + assert mozinstall.get_binary(installdir, "firefox") == binary + + elif mozinfo.isWin: + installdir_exe = mozinstall.install( + get_installer("exe"), tmpdir.join("exe").strpath + ) + binary_exe = os.path.join(installdir_exe, "core", "firefox.exe") + + assert mozinstall.get_binary(installdir_exe, "firefox") == binary_exe + + installdir_zip = mozinstall.install( + get_installer("zip"), tmpdir.join("zip").strpath + ) + binary_zip = os.path.join(installdir_zip, "firefox.exe") + + assert mozinstall.get_binary(installdir_zip, "firefox") == binary_zip + + elif mozinfo.isMac: + installdir = mozinstall.install(get_installer("dmg"), tmpdir.strpath) + binary = os.path.join(installdir, "Contents", "MacOS", "firefox") + + assert mozinstall.get_binary(installdir, "firefox") == binary + + +def test_get_binary_error(tmpdir): + """Test that an InvalidBinary error is raised.""" + with pytest.raises(mozinstall.InvalidBinary): + mozinstall.get_binary(tmpdir.strpath, "firefox") + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozinstall/tests/test_install.py b/testing/mozbase/mozinstall/tests/test_install.py new file mode 100644 index 0000000000..2dceb2cc78 --- /dev/null +++ b/testing/mozbase/mozinstall/tests/test_install.py @@ -0,0 +1,90 @@ +import subprocess + +import mozinfo +import mozinstall +import mozunit +import pytest + + +@pytest.mark.skipif( + mozinfo.isWin, + reason="Bug 1157352 - New firefox.exe needed for mozinstall 1.12 and higher.", +) +def test_is_installer(request, get_installer): + """Test that we can identify a correct installer.""" + if mozinfo.isLinux: + assert mozinstall.is_installer(get_installer("tar.bz2")) + + if mozinfo.isWin: + # test zip installer + assert mozinstall.is_installer(get_installer("zip")) + + # test exe installer + assert mozinstall.is_installer(get_installer("exe")) + + try: + # test stub browser file + # without pefile on the system this test will fail + import pefile # noqa + + stub_exe = ( + request.node.fspath.dirpath("build_stub").join("firefox.exe").strpath + ) + assert not mozinstall.is_installer(stub_exe) + except ImportError: + pass + + if mozinfo.isMac: + assert mozinstall.is_installer(get_installer("dmg")) + + +def test_invalid_source_error(get_installer): + """Test that InvalidSource error is raised with an incorrect installer.""" + if mozinfo.isLinux: + with pytest.raises(mozinstall.InvalidSource): + mozinstall.install(get_installer("dmg"), "firefox") + + elif mozinfo.isWin: + with pytest.raises(mozinstall.InvalidSource): + mozinstall.install(get_installer("tar.bz2"), "firefox") + + elif mozinfo.isMac: + with pytest.raises(mozinstall.InvalidSource): + mozinstall.install(get_installer("tar.bz2"), "firefox") + + # Test an invalid url handler + with pytest.raises(mozinstall.InvalidSource): + mozinstall.install("file://foo.bar", "firefox") + + +@pytest.mark.skipif( + mozinfo.isWin, + reason="Bug 1157352 - New firefox.exe needed for mozinstall 1.12 and higher.", +) +def test_install(tmpdir, get_installer): + """Test to install an installer.""" + if mozinfo.isLinux: + installdir = mozinstall.install(get_installer("tar.bz2"), tmpdir.strpath) + assert installdir == tmpdir.join("firefox").strpath + + elif mozinfo.isWin: + installdir_exe = mozinstall.install( + get_installer("exe"), tmpdir.join("exe").strpath + ) + assert installdir_exe == tmpdir.join("exe", "firefox").strpath + + installdir_zip = mozinstall.install( + get_installer("zip"), tmpdir.join("zip").strpath + ) + assert installdir_zip == tmpdir.join("zip", "firefox").strpath + + elif mozinfo.isMac: + installdir = mozinstall.install(get_installer("dmg"), tmpdir.strpath) + assert installdir == tmpdir.realpath().join("Firefox Stub.app").strpath + + mounted_images = subprocess.check_output(["hdiutil", "info"]).decode("ascii") + assert get_installer("dmg") not in mounted_images + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozinstall/tests/test_is_installer.py b/testing/mozbase/mozinstall/tests/test_is_installer.py new file mode 100644 index 0000000000..057c29f968 --- /dev/null +++ b/testing/mozbase/mozinstall/tests/test_is_installer.py @@ -0,0 +1,40 @@ +import mozinfo +import mozinstall +import mozunit +import pytest + + +@pytest.mark.skipif( + mozinfo.isWin, + reason="Bug 1157352 - New firefox.exe needed for mozinstall 1.12 and higher.", +) +def test_is_installer(request, get_installer): + """Test that we can identify a correct installer.""" + if mozinfo.isLinux: + assert mozinstall.is_installer(get_installer("tar.bz2")) + + if mozinfo.isWin: + # test zip installer + assert mozinstall.is_installer(get_installer("zip")) + + # test exe installer + assert mozinstall.is_installer(get_installer("exe")) + + try: + # test stub browser file + # without pefile on the system this test will fail + import pefile # noqa + + stub_exe = ( + request.node.fspath.dirpath("build_stub").join("firefox.exe").strpath + ) + assert not mozinstall.is_installer(stub_exe) + except ImportError: + pass + + if mozinfo.isMac: + assert mozinstall.is_installer(get_installer("dmg")) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozinstall/tests/test_uninstall.py b/testing/mozbase/mozinstall/tests/test_uninstall.py new file mode 100644 index 0000000000..45298a834d --- /dev/null +++ b/testing/mozbase/mozinstall/tests/test_uninstall.py @@ -0,0 +1,39 @@ +import mozinfo +import mozinstall +import mozunit +import py +import pytest + + +@pytest.mark.skipif( + mozinfo.isWin, + reason="Bug 1157352 - New firefox.exe needed for mozinstall 1.12 and higher.", +) +def test_uninstall(tmpdir, get_installer): + """Test to uninstall an installed binary.""" + if mozinfo.isLinux: + installdir = mozinstall.install(get_installer("tar.bz2"), tmpdir.strpath) + mozinstall.uninstall(installdir) + assert not py.path.local(installdir).check() + + elif mozinfo.isWin: + installdir_exe = mozinstall.install( + get_installer("exe"), tmpdir.join("exe").strpath + ) + mozinstall.uninstall(installdir_exe) + assert not py.path.local(installdir).check() + + installdir_zip = mozinstall.install( + get_installer("zip"), tmpdir.join("zip").strpath + ) + mozinstall.uninstall(installdir_zip) + assert not py.path.local(installdir).check() + + elif mozinfo.isMac: + installdir = mozinstall.install(get_installer("dmg"), tmpdir.strpath) + mozinstall.uninstall(installdir) + assert not py.path.local(installdir).check() + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozleak/mozleak/__init__.py b/testing/mozbase/mozleak/mozleak/__init__.py new file mode 100644 index 0000000000..206806da0c --- /dev/null +++ b/testing/mozbase/mozleak/mozleak/__init__.py @@ -0,0 +1,12 @@ +# 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/. + +""" +mozleak is a library for extracting memory leaks from leak logs files. +""" + +from .leaklog import process_leak_log +from .lsan import LSANLeaks + +__all__ = ["process_leak_log", "LSANLeaks"] diff --git a/testing/mozbase/mozleak/mozleak/leaklog.py b/testing/mozbase/mozleak/mozleak/leaklog.py new file mode 100644 index 0000000000..8a3ee5aee3 --- /dev/null +++ b/testing/mozbase/mozleak/mozleak/leaklog.py @@ -0,0 +1,255 @@ +# 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/. + +import os +import re + +from geckoprocesstypes import process_types + + +def _get_default_logger(): + from mozlog import get_default_logger + + log = get_default_logger(component="mozleak") + + if not log: + import logging + + log = logging.getLogger(__name__) + return log + + +def process_single_leak_file( + leakLogFileName, + processType, + leakThreshold, + ignoreMissingLeaks, + log=None, + stackFixer=None, + scope=None, + allowed=None, +): + """Process a single leak log.""" + + # | |Per-Inst Leaked| Total Rem| + # 0 |TOTAL | 17 192| 419115886 2| + # 833 |nsTimerImpl | 60 120| 24726 2| + # 930 |Foo<Bar, Bar> | 32 8| 100 1| + lineRe = re.compile( + r"^\s*\d+ \|" + r"(?P<name>[^|]+)\|" + r"\s*(?P<size>-?\d+)\s+(?P<bytesLeaked>-?\d+)\s*\|" + r"\s*-?\d+\s+(?P<numLeaked>-?\d+)" + ) + # The class name can contain spaces. We remove trailing whitespace later. + + log = log or _get_default_logger() + + if allowed is None: + allowed = {} + + processString = "%s process:" % processType + crashedOnPurpose = False + totalBytesLeaked = None + leakedObjectAnalysis = [] + leakedObjectNames = [] + recordLeakedObjects = False + header = [] + log.info("leakcheck | Processing leak log file %s" % leakLogFileName) + + with open(leakLogFileName, "r") as leaks: + for line in leaks: + if line.find("purposefully crash") > -1: + crashedOnPurpose = True + matches = lineRe.match(line) + if not matches: + # eg: the leak table header row + strippedLine = line.rstrip() + logLine = stackFixer(strippedLine) if stackFixer else strippedLine + if recordLeakedObjects: + log.info(logLine) + else: + header.append(logLine) + continue + name = matches.group("name").rstrip() + size = int(matches.group("size")) + bytesLeaked = int(matches.group("bytesLeaked")) + numLeaked = int(matches.group("numLeaked")) + # Output the raw line from the leak log table if it is for an object + # row that has been leaked. + if numLeaked != 0: + # If this is the TOTAL line, first output the header lines. + if name == "TOTAL": + for logLine in header: + log.info(logLine) + log.info(line.rstrip()) + # If this is the TOTAL line, we're done with the header lines, + # whether or not it leaked. + if name == "TOTAL": + header = [] + # Analyse the leak log, but output later or it will interrupt the + # leak table + if name == "TOTAL": + # Multiple default processes can end up writing their bloat views into a single + # log, particularly on B2G. Eventually, these should be split into multiple + # logs (bug 1068869), but for now, we report the largest leak. + if totalBytesLeaked is not None: + log.warning( + "leakcheck | %s " + "multiple BloatView byte totals found" % processString + ) + else: + totalBytesLeaked = 0 + if bytesLeaked > totalBytesLeaked: + totalBytesLeaked = bytesLeaked + # Throw out the information we had about the previous bloat + # view. + leakedObjectNames = [] + leakedObjectAnalysis = [] + recordLeakedObjects = True + else: + recordLeakedObjects = False + if (size < 0 or bytesLeaked < 0 or numLeaked < 0) and leakThreshold >= 0: + log.error( + "TEST-UNEXPECTED-FAIL | leakcheck | %s negative leaks caught!" + % processString + ) + continue + if name != "TOTAL" and numLeaked != 0 and recordLeakedObjects: + leakedObjectNames.append(name) + leakedObjectAnalysis.append((numLeaked, name)) + + for numLeaked, name in leakedObjectAnalysis: + leak_allowed = False + if name in allowed: + limit = leak_allowed[name] + leak_allowed = limit is None or numLeaked <= limit + + log.mozleak_object( + processType, numLeaked, name, scope=scope, allowed=leak_allowed + ) + + log.mozleak_total( + processType, + totalBytesLeaked, + leakThreshold, + leakedObjectNames, + scope=scope, + induced_crash=crashedOnPurpose, + ignore_missing=ignoreMissingLeaks, + ) + + +def process_leak_log( + leak_log_file, + leak_thresholds=None, + ignore_missing_leaks=None, + log=None, + stack_fixer=None, + scope=None, + allowed=None, +): + """Process the leak log, including separate leak logs created + by child processes. + + Use this function if you want an additional PASS/FAIL summary. + It must be used with the |XPCOM_MEM_BLOAT_LOG| environment variable. + + The base of leak_log_file for a non-default process needs to end with + _proctype_pid12345.log + "proctype" is a string denoting the type of the process, which should + be the result of calling XRE_GeckoProcessTypeToString(). 12345 is + a series of digits that is the pid for the process. The .log is + optional. + + All other file names are treated as being for default processes. + + leak_thresholds should be a dict mapping process types to leak thresholds, + in bytes. If a process type is not present in the dict the threshold + will be 0. If the threshold is a negative number we additionally ignore + the case where there's negative leaks. + + allowed - A dictionary mapping process types to dictionaries containing + the number of objects of that type which are allowed to leak. + + scope - An identifier for the set of tests run during the browser session + (e.g. a directory name) + + ignore_missing_leaks should be a list of process types. If a process + creates a leak log without a TOTAL, then we report an error if it isn't + in the list ignore_missing_leaks. + + Returns a list of files that were processed. The caller is responsible for + cleaning these up. + """ + log = log or _get_default_logger() + + processed_files = [] + + leakLogFile = leak_log_file + if not os.path.exists(leakLogFile): + log.warning("leakcheck | refcount logging is off, so leaks can't be detected!") + return processed_files + + log.info( + "leakcheck | Processing log file %s%s" + % (leakLogFile, (" for scope %s" % scope) if scope is not None else "") + ) + + leakThresholds = leak_thresholds or {} + ignoreMissingLeaks = ignore_missing_leaks or [] + + # This list is based on XRE_GeckoProcessTypeToString. ipdlunittest processes likely + # are not going to produce leak logs we will ever see. + + knownProcessTypes = [ + p.string_name for p in process_types if p.string_name != "ipdlunittest" + ] + + for processType in knownProcessTypes: + log.info( + "TEST-INFO | leakcheck | %s process: leak threshold set at %d bytes" + % (processType, leakThresholds.get(processType, 0)) + ) + + for processType in leakThresholds: + if processType not in knownProcessTypes: + log.error( + "TEST-UNEXPECTED-FAIL | leakcheck | " + "Unknown process type %s in leakThresholds" % processType + ) + + (leakLogFileDir, leakFileBase) = os.path.split(leakLogFile) + if leakFileBase[-4:] == ".log": + leakFileBase = leakFileBase[:-4] + fileNameRegExp = re.compile(r"_([a-z]*)_pid\d*.log$") + else: + fileNameRegExp = re.compile(r"_([a-z]*)_pid\d*$") + + for fileName in os.listdir(leakLogFileDir): + if fileName.find(leakFileBase) != -1: + thisFile = os.path.join(leakLogFileDir, fileName) + m = fileNameRegExp.search(fileName) + if m: + processType = m.group(1) + else: + processType = "default" + if processType not in knownProcessTypes: + log.error( + "TEST-UNEXPECTED-FAIL | leakcheck | " + "Leak log with unknown process type %s" % processType + ) + leakThreshold = leakThresholds.get(processType, 0) + process_single_leak_file( + thisFile, + processType, + leakThreshold, + processType in ignoreMissingLeaks, + log=log, + stackFixer=stack_fixer, + scope=scope, + allowed=allowed, + ) + processed_files.append(thisFile) + return processed_files diff --git a/testing/mozbase/mozleak/mozleak/lsan.py b/testing/mozbase/mozleak/mozleak/lsan.py new file mode 100644 index 0000000000..f6555eff2d --- /dev/null +++ b/testing/mozbase/mozleak/mozleak/lsan.py @@ -0,0 +1,220 @@ +# 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/. + +import re + + +class LSANLeaks(object): + + """ + Parses the log when running an LSAN build, looking for interesting stack frames + in allocation stacks + """ + + def __init__( + self, + logger, + scope=None, + allowed=None, + maxNumRecordedFrames=None, + allowAll=False, + ): + self.logger = logger + self.inReport = False + self.fatalError = False + self.symbolizerError = False + self.foundFrames = set() + self.recordMoreFrames = None + self.currStack = None + self.maxNumRecordedFrames = maxNumRecordedFrames if maxNumRecordedFrames else 4 + self.summaryData = None + self.scope = scope + self.allowedMatch = None + self.allowAll = allowAll + self.sawError = False + + # Don't various allocation-related stack frames, as they do not help much to + # distinguish different leaks. + unescapedSkipList = [ + "malloc", + "js_malloc", + "malloc_", + "__interceptor_malloc", + "moz_xmalloc", + "calloc", + "js_calloc", + "calloc_", + "__interceptor_calloc", + "moz_xcalloc", + "realloc", + "js_realloc", + "realloc_", + "__interceptor_realloc", + "moz_xrealloc", + "new", + "js::MallocProvider", + ] + self.skipListRegExp = re.compile( + "^" + "|".join([re.escape(f) for f in unescapedSkipList]) + "$" + ) + + self.startRegExp = re.compile( + "==\d+==ERROR: LeakSanitizer: detected memory leaks" + ) + self.fatalErrorRegExp = re.compile( + "==\d+==LeakSanitizer has encountered a fatal error." + ) + self.symbolizerOomRegExp = re.compile( + "LLVMSymbolizer: error reading file: Cannot allocate memory" + ) + self.stackFrameRegExp = re.compile(" #\d+ 0x[0-9a-f]+ in ([^(</]+)") + self.sysLibStackFrameRegExp = re.compile( + " #\d+ 0x[0-9a-f]+ \(([^+]+)\+0x[0-9a-f]+\)" + ) + self.summaryRegexp = re.compile( + "SUMMARY: AddressSanitizer: (\d+) byte\(s\) leaked in (\d+) allocation\(s\)." + ) + self.rustRegexp = re.compile("::h[a-f0-9]{16}$") + self.setAllowed(allowed) + + def setAllowed(self, allowedLines): + if not allowedLines or self.allowAll: + self.allowedRegexp = None + else: + self.allowedRegexp = re.compile( + "^" + "|".join([re.escape(f) for f in allowedLines]) + ) + + def log(self, line): + if re.match(self.startRegExp, line): + self.inReport = True + # Downgrade this from an ERROR + self.sawError = True + return "LeakSanitizer: detected memory leaks" + + if re.match(self.fatalErrorRegExp, line): + self.fatalError = True + return line + + if re.match(self.symbolizerOomRegExp, line): + self.symbolizerError = True + return line + + if not self.inReport: + return line + + if line.startswith("Direct leak") or line.startswith("Indirect leak"): + self._finishStack() + self.recordMoreFrames = True + self.currStack = [] + return line + + summaryData = self.summaryRegexp.match(line) + if summaryData: + assert self.summaryData is None + self._finishStack() + self.inReport = False + self.summaryData = (int(item) for item in summaryData.groups()) + # We don't return the line here because we want to control whether the + # leak is seen as an expected failure later + return + + if not self.recordMoreFrames: + return line + + stackFrame = re.match(self.stackFrameRegExp, line) + if stackFrame: + # Split the frame to remove any return types. + frame = stackFrame.group(1).split()[-1] + if not re.match(self.skipListRegExp, frame): + self._recordFrame(frame) + return line + + sysLibStackFrame = re.match(self.sysLibStackFrameRegExp, line) + if sysLibStackFrame: + # System library stack frames will never match the skip list, + # so don't bother checking if they do. + self._recordFrame(sysLibStackFrame.group(1)) + + # If we don't match either of these, just ignore the frame. + # We'll end up with "unknown stack" if everything is ignored. + return line + + def process(self): + failures = 0 + + if self.allowAll: + self.logger.info("LeakSanitizer | Leak checks disabled") + return + + if self.summaryData: + allowed = all(allowed for _, allowed in self.foundFrames) + self.logger.lsan_summary(*self.summaryData, allowed=allowed) + self.summaryData = None + + if self.fatalError: + self.logger.error( + "LeakSanitizer | LeakSanitizer has encountered a fatal error." + ) + failures += 1 + + if self.symbolizerError: + self.logger.error( + "LeakSanitizer | LLVMSymbolizer was unable to allocate memory.\n" + "This will cause leaks that " + "should be ignored to instead be reported as an error" + ) + failures += 1 + + if self.foundFrames: + self.logger.info( + "LeakSanitizer | To show the " + "addresses of leaked objects add report_objects=1 to LSAN_OPTIONS\n" + "This can be done in testing/mozbase/mozrunner/mozrunner/utils.py" + ) + self.logger.info("Allowed depth was %d" % self.maxNumRecordedFrames) + + for frames, allowed in self.foundFrames: + self.logger.lsan_leak(frames, scope=self.scope, allowed_match=allowed) + if not allowed: + failures += 1 + + if self.sawError and not ( + self.summaryData + or self.foundFrames + or self.fatalError + or self.symbolizerError + ): + self.logger.error( + "LeakSanitizer | Memory leaks detected but no leak report generated" + ) + + self.sawError = False + + return failures + + def _finishStack(self): + if self.recordMoreFrames and len(self.currStack) == 0: + self.currStack = {"unknown stack"} + if self.currStack: + self.foundFrames.add((tuple(self.currStack), self.allowedMatch)) + self.currStack = None + self.allowedMatch = None + self.recordMoreFrames = False + self.numRecordedFrames = 0 + + def _recordFrame(self, frame): + if self.allowedMatch is None and self.allowedRegexp is not None: + self.allowedMatch = frame if self.allowedRegexp.match(frame) else None + frame = self._cleanFrame(frame) + self.currStack.append(frame) + self.numRecordedFrames += 1 + if self.numRecordedFrames >= self.maxNumRecordedFrames: + self.recordMoreFrames = False + + def _cleanFrame(self, frame): + # Rust frames aren't properly demangled and in particular can contain + # some trailing junk of the form ::h[a-f0-9]{16} that changes with + # compiler versions; see bug 1507350. + return self.rustRegexp.sub("", frame) diff --git a/testing/mozbase/mozleak/setup.cfg b/testing/mozbase/mozleak/setup.cfg new file mode 100644 index 0000000000..3c6e79cf31 --- /dev/null +++ b/testing/mozbase/mozleak/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 diff --git a/testing/mozbase/mozleak/setup.py b/testing/mozbase/mozleak/setup.py new file mode 100644 index 0000000000..0c1ecb74a2 --- /dev/null +++ b/testing/mozbase/mozleak/setup.py @@ -0,0 +1,29 @@ +# 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 setuptools import setup + +PACKAGE_NAME = "mozleak" +PACKAGE_VERSION = "1.0.0" + + +setup( + name=PACKAGE_NAME, + version=PACKAGE_VERSION, + description="Library for extracting memory leaks from leak logs files", + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + classifiers=[ + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.5", + ], + # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + keywords="mozilla", + author="Mozilla Automation and Tools team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + license="MPL", + packages=["mozleak"], + zip_safe=False, + install_requires=[], +) diff --git a/testing/mozbase/mozleak/tests/manifest.toml b/testing/mozbase/mozleak/tests/manifest.toml new file mode 100644 index 0000000000..133b0581e6 --- /dev/null +++ b/testing/mozbase/mozleak/tests/manifest.toml @@ -0,0 +1,4 @@ +[DEFAULT] +subsuite = "mozbase" + +["test_lsan.py"] diff --git a/testing/mozbase/mozleak/tests/test_lsan.py b/testing/mozbase/mozleak/tests/test_lsan.py new file mode 100644 index 0000000000..6a55a555b7 --- /dev/null +++ b/testing/mozbase/mozleak/tests/test_lsan.py @@ -0,0 +1,30 @@ +import mozunit +import pytest +from mozleak import lsan + + +@pytest.mark.parametrize( + ("input_", "expected"), + [ + ( + "alloc_system::platform::_$LT$impl$u20$core..alloc.." + "GlobalAlloc$u20$for$u20$alloc_system..System$GT$::" + "alloc::h5a1f0db41e296502", + "alloc_system::platform::_$LT$impl$u20$core..alloc.." + "GlobalAlloc$u20$for$u20$alloc_system..System$GT$::alloc", + ), + ( + "alloc_system::platform::_$LT$impl$u20$core..alloc.." + "GlobalAlloc$u20$for$u20$alloc_system..System$GT$::alloc", + "alloc_system::platform::_$LT$impl$u20$core..alloc.." + "GlobalAlloc$u20$for$u20$alloc_system..System$GT$::alloc", + ), + ], +) +def test_clean(input_, expected): + leaks = lsan.LSANLeaks(None) + assert leaks._cleanFrame(input_) == expected + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozlog/mozlog/__init__.py b/testing/mozbase/mozlog/mozlog/__init__.py new file mode 100644 index 0000000000..82d40b5c55 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/__init__.py @@ -0,0 +1,34 @@ +# 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/. + +""" +Mozlog aims to standardize log handling and formatting within Mozilla. + +It implements a JSON-based structured logging protocol with convenience +facilities for recording test results. + +The old unstructured module is deprecated. It simply wraps Python's +logging_ module and adds a few convenience methods for logging test +results and events. +""" + +import sys + +from . import commandline, structuredlog, unstructured +from .proxy import get_proxy_logger +from .structuredlog import get_default_logger, set_default_logger + +# Backwards compatibility shim for consumers that use mozlog.structured +structured = sys.modules[__name__] +sys.modules["{}.structured".format(__name__)] = structured + +__all__ = [ + "commandline", + "structuredlog", + "unstructured", + "get_default_logger", + "set_default_logger", + "get_proxy_logger", + "structured", +] diff --git a/testing/mozbase/mozlog/mozlog/capture.py b/testing/mozbase/mozlog/mozlog/capture.py new file mode 100644 index 0000000000..75717d62c8 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/capture.py @@ -0,0 +1,96 @@ +# 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/. + +import sys +import threading +from io import BytesIO + + +class LogThread(threading.Thread): + def __init__(self, queue, logger, level): + self.queue = queue + self.log_func = getattr(logger, level) + threading.Thread.__init__(self, name="Thread-Log") + self.daemon = True + + def run(self): + while True: + try: + msg = self.queue.get() + except (EOFError, IOError): + break + if msg is None: + break + else: + self.log_func(msg) + + +class LoggingWrapper(BytesIO): + """Wrapper for file like objects to redirect output to logger + instead""" + + def __init__(self, queue, prefix=None): + BytesIO.__init__(self) + self.queue = queue + self.prefix = prefix + self.buffer = self + + def write(self, data): + if isinstance(data, bytes): + try: + data = data.decode("utf8") + except UnicodeDecodeError: + data = data.decode("unicode_escape") + + if data.endswith("\n"): + data = data[:-1] + if data.endswith("\r"): + data = data[:-1] + if not data: + return + if self.prefix is not None: + data = "%s: %s" % (self.prefix, data) + self.queue.put(data) + + def flush(self): + pass + + +class CaptureIO(object): + def __init__(self, logger, do_capture, mp_context=None): + if mp_context is None: + import multiprocessing as mp_context + self.logger = logger + self.do_capture = do_capture + self.logging_queue = None + self.logging_thread = None + self.original_stdio = None + self.mp_context = mp_context + + def __enter__(self): + if self.do_capture: + self.original_stdio = (sys.stdout, sys.stderr) + self.logging_queue = self.mp_context.Queue() + self.logging_thread = LogThread(self.logging_queue, self.logger, "info") + sys.stdout = LoggingWrapper(self.logging_queue, prefix="STDOUT") + sys.stderr = LoggingWrapper(self.logging_queue, prefix="STDERR") + self.logging_thread.start() + + def __exit__(self, *args, **kwargs): + if self.do_capture: + sys.stdout, sys.stderr = self.original_stdio + if self.logging_queue is not None: + self.logger.info("Closing logging queue") + self.logging_queue.put(None) + if self.logging_thread is not None: + self.logging_thread.join(10) + while not self.logging_queue.empty(): + try: + self.logger.warning( + "Dropping log message: %r", self.logging_queue.get() + ) + except Exception: + pass + self.logging_queue.close() + self.logger.info("queue closed") diff --git a/testing/mozbase/mozlog/mozlog/commandline.py b/testing/mozbase/mozlog/mozlog/commandline.py new file mode 100644 index 0000000000..51e9ea6929 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/commandline.py @@ -0,0 +1,344 @@ +# 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/. + +import argparse +import optparse +import os +import sys +from collections import defaultdict + +import six + +from . import formatters, handlers +from .structuredlog import StructuredLogger, set_default_logger + +log_formatters = { + "raw": ( + formatters.JSONFormatter, + "Raw structured log messages " "(provided by mozlog)", + ), + "unittest": ( + formatters.UnittestFormatter, + "Unittest style output " "(provided by mozlog)", + ), + "xunit": ( + formatters.XUnitFormatter, + "xUnit compatible XML " "(provided by mozlog)", + ), + "html": (formatters.HTMLFormatter, "HTML report " "(provided by mozlog)"), + "mach": (formatters.MachFormatter, "Human-readable output " "(provided by mozlog)"), + "tbpl": (formatters.TbplFormatter, "TBPL style log format " "(provided by mozlog)"), + "grouped": ( + formatters.GroupingFormatter, + "Grouped summary of test results " "(provided by mozlog)", + ), + "errorsummary": (formatters.ErrorSummaryFormatter, argparse.SUPPRESS), +} + +TEXT_FORMATTERS = ("raw", "mach") +"""a subset of formatters for non test harnesses related applications""" + + +DOCS_URL = "https://firefox-source-docs.mozilla.org/mozbase/mozlog.html" + + +def level_filter_wrapper(formatter, level): + return handlers.LogLevelFilter(formatter, level) + + +def verbose_wrapper(formatter, verbose): + formatter.verbose = verbose + return formatter + + +def compact_wrapper(formatter, compact): + formatter.compact = compact + return formatter + + +def buffer_handler_wrapper(handler, buffer_limit): + if buffer_limit == "UNLIMITED": + buffer_limit = None + else: + buffer_limit = int(buffer_limit) + return handlers.BufferHandler(handler, buffer_limit) + + +def screenshot_wrapper(formatter, enable_screenshot): + formatter.enable_screenshot = enable_screenshot + return formatter + + +def valgrind_handler_wrapper(handler): + return handlers.ValgrindHandler(handler) + + +def default_formatter_options(log_type, overrides): + formatter_option_defaults = {"raw": {"level": "debug"}} + rv = {"verbose": False, "level": "info"} + rv.update(formatter_option_defaults.get(log_type, {})) + + if overrides is not None: + rv.update(overrides) + + return rv + + +fmt_options = { + # <option name>: (<wrapper function>, description, <applicable formatters>, action) + # "action" is used by the commandline parser in use. + "verbose": ( + verbose_wrapper, + "Enables verbose mode for the given formatter.", + {"mach"}, + "store_true", + ), + "compact": ( + compact_wrapper, + "Enables compact mode for the given formatter.", + {"tbpl"}, + "store_true", + ), + "level": ( + level_filter_wrapper, + "A least log level to subscribe to for the given formatter " + "(debug, info, error, etc.)", + {"mach", "raw", "tbpl"}, + "store", + ), + "buffer": ( + buffer_handler_wrapper, + "If specified, enables message buffering at the given buffer size limit.", + ["mach", "tbpl"], + "store", + ), + "screenshot": ( + screenshot_wrapper, + "Enable logging reftest-analyzer compatible screenshot data.", + {"mach"}, + "store_true", + ), + "no-screenshot": ( + screenshot_wrapper, + "Disable logging reftest-analyzer compatible screenshot data.", + {"mach"}, + "store_false", + ), +} + + +def log_file(name): + if name == "-": + return sys.stdout + # ensure we have a correct dirpath by using realpath + dirpath = os.path.dirname(os.path.realpath(name)) + if not os.path.exists(dirpath): + os.makedirs(dirpath) + return open(name, "w") + + +def add_logging_group(parser, include_formatters=None): + """ + Add logging options to an argparse ArgumentParser or + optparse OptionParser. + + Each formatter has a corresponding option of the form --log-{name} + where {name} is the name of the formatter. The option takes a value + which is either a filename or "-" to indicate stdout. + + :param parser: The ArgumentParser or OptionParser object that should have + logging options added. + :param include_formatters: List of formatter names that should be included + in the option group. Default to None, meaning + all the formatters are included. A common use + of this option is to specify + :data:`TEXT_FORMATTERS` to include only the + most useful formatters for a command line tool + that is not related to test harnesses. + """ + group_name = "Output Logging" + group_description = ( + "Each option represents a possible logging format " + "and takes a filename to write that format to, " + "or '-' to write to stdout. Some options are " + "provided by the mozlog utility; see %s " + "for extended documentation." % DOCS_URL + ) + + if include_formatters is None: + include_formatters = list(log_formatters.keys()) + + if isinstance(parser, optparse.OptionParser): + group = optparse.OptionGroup(parser, group_name, group_description) + parser.add_option_group(group) + opt_log_type = "str" + group_add = group.add_option + else: + group = parser.add_argument_group(group_name, group_description) + opt_log_type = log_file + group_add = group.add_argument + + for name, (cls, help_str) in six.iteritems(log_formatters): + if name in include_formatters: + group_add( + "--log-" + name, action="append", type=opt_log_type, help=help_str + ) + + for fmt in include_formatters: + for optname, (cls, help_str, formatters_, action) in six.iteritems(fmt_options): + if fmt not in formatters_: + continue + if optname.startswith("no-") and action == "store_false": + dest = optname.split("-", 1)[1] + else: + dest = optname + dest = dest.replace("-", "_") + group_add( + "--log-%s-%s" % (fmt, optname), + action=action, + help=help_str, + default=None, + dest="log_%s_%s" % (fmt, dest), + ) + + +def setup_handlers(logger, formatters, formatter_options, allow_unused_options=False): + """ + Add handlers to the given logger according to the formatters and + options provided. + + :param logger: The logger configured by this function. + :param formatters: A dict of {formatter, [streams]} to use in handlers. + :param formatter_options: a dict of {formatter: {option: value}} to + to use when configuring formatters. + """ + unused_options = set(formatter_options.keys()) - set(formatters.keys()) + if unused_options and not allow_unused_options: + msg = "Options specified for unused formatter(s) (%s) have no effect" % list( + unused_options + ) + raise ValueError(msg) + + for fmt, streams in six.iteritems(formatters): + formatter_cls = log_formatters[fmt][0] + formatter = formatter_cls() + handler_wrappers_and_options = [] + + for option, value in six.iteritems(formatter_options[fmt]): + wrapper, wrapper_args = None, () + if option == "valgrind": + wrapper = valgrind_handler_wrapper + elif option == "buffer": + wrapper, wrapper_args = fmt_options[option][0], (value,) + else: + formatter = fmt_options[option][0](formatter, value) + + if wrapper is not None: + handler_wrappers_and_options.append((wrapper, wrapper_args)) + + for value in streams: + handler = handlers.StreamHandler(stream=value, formatter=formatter) + for wrapper, wrapper_args in handler_wrappers_and_options: + handler = wrapper(handler, *wrapper_args) + logger.add_handler(handler) + + +def setup_logging( + logger, args, defaults=None, formatter_defaults=None, allow_unused_options=False +): + """ + Configure a structuredlogger based on command line arguments. + + The created structuredlogger will also be set as the default logger, and + can be retrieved with :py:func:`~mozlog.get_default_logger`. + + :param logger: A StructuredLogger instance or string name. If a string, a + new StructuredLogger instance will be created using + `logger` as the name. + :param args: A dictionary of {argument_name:value} produced from + parsing the command line arguments for the application + :param defaults: A dictionary of {formatter name: output stream} to apply + when there is no logging supplied on the command line. If + this isn't supplied, reasonable defaults are chosen + (coloured mach formatting if stdout is a terminal, or raw + logs otherwise). + :param formatter_defaults: A dictionary of {option_name: default_value} to provide + to the formatters in the absence of command line overrides. + :rtype: StructuredLogger + """ + + if not isinstance(logger, StructuredLogger): + logger = StructuredLogger(logger) + # The likely intent when using this function is to get a brand new + # logger, so reset state in case it was previously initialized. + logger.reset_state() + + # Keep track of any options passed for formatters. + formatter_options = {} + # Keep track of formatters and list of streams specified. + formatters = defaultdict(list) + found = False + found_stdout_logger = False + if args is None: + args = {} + if not isinstance(args, dict): + args = vars(args) + + if defaults is None: + if sys.__stdout__.isatty(): + defaults = {"mach": sys.stdout} + else: + defaults = {"raw": sys.stdout} + + for name, values in six.iteritems(args): + parts = name.split("_") + if len(parts) > 3: + continue + # Our args will be ['log', <formatter>] + # or ['log', <formatter>, <option>] + # or ['valgrind'] + if parts[0] == "log" and values is not None: + if len(parts) == 1 or parts[1] not in log_formatters: + continue + if len(parts) == 2: + _, formatter = parts + for value in values: + found = True + if isinstance(value, six.string_types): + value = log_file(value) + if value == sys.stdout: + found_stdout_logger = True + formatters[formatter].append(value) + if len(parts) == 3: + _, formatter, opt = parts + if formatter not in formatter_options: + formatter_options[formatter] = default_formatter_options( + formatter, formatter_defaults + ) + formatter_options[formatter][opt] = values + + # If there is no user-specified logging, go with the default options + if not found: + for name, value in six.iteritems(defaults): + formatters[name].append(value) + + elif not found_stdout_logger and sys.stdout in list(defaults.values()): + for name, value in six.iteritems(defaults): + if value == sys.stdout: + formatters[name].append(value) + + for name in formatters: + if name not in formatter_options: + formatter_options[name] = default_formatter_options( + name, formatter_defaults + ) + + # If the user specified --valgrind, add it as an option for all formatters + if args.get("valgrind", None) is not None: + for name in formatters: + formatter_options[name]["valgrind"] = True + setup_handlers(logger, formatters, formatter_options, allow_unused_options) + set_default_logger(logger) + + return logger diff --git a/testing/mozbase/mozlog/mozlog/formatters/__init__.py b/testing/mozbase/mozlog/mozlog/formatters/__init__.py new file mode 100644 index 0000000000..962fb92ca4 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/formatters/__init__.py @@ -0,0 +1,32 @@ +# 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 .errorsummary import ErrorSummaryFormatter +from .grouping import GroupingFormatter +from .html import HTMLFormatter +from .machformatter import MachFormatter +from .tbplformatter import TbplFormatter +from .unittest import UnittestFormatter +from .xunit import XUnitFormatter + +try: + import ujson as json +except ImportError: + import json + + +def JSONFormatter(): + return lambda x: json.dumps(x) + "\n" + + +__all__ = [ + "UnittestFormatter", + "XUnitFormatter", + "HTMLFormatter", + "MachFormatter", + "TbplFormatter", + "ErrorSummaryFormatter", + "JSONFormatter", + "GroupingFormatter", +] diff --git a/testing/mozbase/mozlog/mozlog/formatters/base.py b/testing/mozbase/mozlog/mozlog/formatters/base.py new file mode 100644 index 0000000000..407514b741 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/formatters/base.py @@ -0,0 +1,25 @@ +# 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 mozlog.handlers.messagehandler import MessageHandler + +from ..reader import LogHandler + + +class BaseFormatter(LogHandler): + """Base class for implementing non-trivial formatters. + + Subclasses are expected to provide a method for each action type they + wish to handle, each taking a single argument for the test data. + For example a trivial subclass that just produces the id of each test as + it starts might be:: + + class StartIdFormatter(BaseFormatter); + def test_start(data): + #For simplicity in the example pretend the id is always a string + return data["test"] + """ + + def __init__(self): + self.message_handler = MessageHandler() diff --git a/testing/mozbase/mozlog/mozlog/formatters/errorsummary.py b/testing/mozbase/mozlog/mozlog/formatters/errorsummary.py new file mode 100644 index 0000000000..fcedb7ebd4 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/formatters/errorsummary.py @@ -0,0 +1,208 @@ +# 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/. + + +import json +import os +from collections import defaultdict + +from .base import BaseFormatter + + +class ErrorSummaryFormatter(BaseFormatter): + def __init__(self): + self.test_to_group = {} + self.groups = defaultdict( + lambda: { + "status": None, + "test_times": [], + "start": None, + "end": None, + } + ) + self.line_count = 0 + self.dump_passing_tests = False + + if os.environ.get("MOZLOG_DUMP_ALL_TESTS", False): + self.dump_passing_tests = True + + def __call__(self, data): + rv = BaseFormatter.__call__(self, data) + self.line_count += 1 + return rv + + def _output(self, data_type, data): + data["action"] = data_type + data["line"] = self.line_count + return "%s\n" % json.dumps(data) + + def _output_test(self, test, subtest, item): + data = { + "test": test, + "subtest": subtest, + "group": self.test_to_group.get(test, ""), + "status": item["status"], + "expected": item["expected"], + "message": item.get("message"), + "stack": item.get("stack"), + "known_intermittent": item.get("known_intermittent", []), + } + return self._output("test_result", data) + + def _get_group_result(self, group, item): + group_info = self.groups[group] + result = group_info["status"] + + if result == "ERROR": + return result + + # If status == expected, we delete item[expected] + test_status = item["status"] + test_expected = item.get("expected", test_status) + known_intermittent = item.get("known_intermittent", []) + + if test_status == "SKIP": + self.groups[group]["start"] = None + if result is None: + result = "SKIP" + elif test_status == test_expected or test_status in known_intermittent: + # If the test status is expected, or it's a known intermittent + # the group has at least one passing test + result = "OK" + else: + result = "ERROR" + + return result + + def _clean_test_name(self, test): + retVal = test + # remove extra stuff like "(finished)" + if "(finished)" in test: + retVal = test.split(" ")[0] + return retVal + + def suite_start(self, item): + self.test_to_group = {v: k for k in item["tests"] for v in item["tests"][k]} + return self._output("test_groups", {"groups": list(item["tests"].keys())}) + + def suite_end(self, data): + output = [] + for group, info in self.groups.items(): + duration = sum(info["test_times"]) + + output.append( + self._output( + "group_result", + { + "group": group, + "status": info["status"], + "duration": duration, + }, + ) + ) + + return "".join(output) + + def test_start(self, item): + group = item.get( + "group", self.test_to_group.get(self._clean_test_name(item["test"]), None) + ) + if group and self.groups[group]["start"] is None: + self.groups[group]["start"] = item["time"] + + def test_status(self, item): + group = item.get( + "group", self.test_to_group.get(self._clean_test_name(item["test"]), None) + ) + if group: + self.groups[group]["status"] = self._get_group_result(group, item) + + if not self.dump_passing_tests and "expected" not in item: + return + + if item.get("expected", "") == "": + item["expected"] = item["status"] + + return self._output_test( + self._clean_test_name(item["test"]), item["subtest"], item + ) + + def test_end(self, item): + group = item.get( + "group", self.test_to_group.get(self._clean_test_name(item["test"]), None) + ) + if group: + self.groups[group]["status"] = self._get_group_result(group, item) + if self.groups[group]["start"]: + self.groups[group]["test_times"].append( + item["time"] - self.groups[group]["start"] + ) + self.groups[group]["start"] = None + + if not self.dump_passing_tests and "expected" not in item: + return + + if item.get("expected", "") == "": + item["expected"] = item["status"] + + return self._output_test(self._clean_test_name(item["test"]), None, item) + + def log(self, item): + if item["level"] not in ("ERROR", "CRITICAL"): + return + + data = {"level": item["level"], "message": item["message"]} + return self._output("log", data) + + def shutdown_failure(self, item): + data = {"status": "FAIL", "test": item["group"], "message": item["message"]} + data["group"] = [g for g in self.groups if item["group"].endswith(g)][0] + self.groups[data["group"]]["status"] = "FAIL" + return self._output("log", data) + + def crash(self, item): + data = { + "test": item.get("test"), + "signature": item["signature"], + "stackwalk_stdout": item.get("stackwalk_stdout"), + "stackwalk_stderr": item.get("stackwalk_stderr"), + } + + if item.get("test"): + data["group"] = self.test_to_group.get( + self._clean_test_name(item["test"]), "" + ) + if data["group"] == "": + # item['test'] could be the group name, not a test name + if self._clean_test_name(item["test"]) in self.groups: + data["group"] = self._clean_test_name(item["test"]) + + # unlike test group summary, if we crash expect error unless expected + if ( + ( + "expected" in item + and "status" in item + and item["status"] in item["expected"] + ) + or ("expected" in item and "CRASH" == item["expected"]) + or "status" in item + and item["status"] in item.get("known_intermittent", []) + ): + self.groups[data["group"]]["status"] = "PASS" + else: + self.groups[data["group"]]["status"] = "ERROR" + + return self._output("crash", data) + + def lint(self, item): + data = { + "level": item["level"], + "path": item["path"], + "message": item["message"], + "lineno": item["lineno"], + "column": item.get("column"), + "rule": item.get("rule"), + "linter": item.get("linter"), + } + self._output("lint", data) diff --git a/testing/mozbase/mozlog/mozlog/formatters/grouping.py b/testing/mozbase/mozlog/mozlog/formatters/grouping.py new file mode 100644 index 0000000000..89f5d24783 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/formatters/grouping.py @@ -0,0 +1,391 @@ +# 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/. +import collections +import os +import platform +import subprocess +import sys + +import six + +from mozlog.formatters import base + +DEFAULT_MOVE_UP_CODE = "\x1b[A" +DEFAULT_CLEAR_EOL_CODE = "\x1b[K" + + +class GroupingFormatter(base.BaseFormatter): + """Formatter designed to produce unexpected test results grouped + together in a readable format.""" + + def __init__(self): + super(GroupingFormatter, self).__init__() + self.number_of_tests = 0 + self.completed_tests = 0 + self.need_to_erase_last_line = False + self.current_display = "" + self.running_tests = {} + self.test_output = collections.defaultdict(str) + self.subtest_failures = collections.defaultdict(list) + self.test_failure_text = "" + self.tests_with_failing_subtests = [] + self.interactive = os.isatty(sys.stdout.fileno()) + self.show_logs = False + + self.message_handler.register_message_handlers( + "show_logs", + { + "on": self._enable_show_logs, + "off": self._disable_show_logs, + }, + ) + + # TODO(mrobinson, 8313): We need to add support for Windows terminals here. + if self.interactive: + self.move_up, self.clear_eol = self.get_move_up_and_clear_eol_codes() + if platform.system() != "Windows": + self.line_width = int( + subprocess.check_output(["stty", "size"]).split()[1] + ) + else: + # Until we figure out proper Windows support, + # this makes things work well enough to run. + self.line_width = 80 + + self.expected = { + "OK": 0, + "PASS": 0, + "FAIL": 0, + "PRECONDITION_FAILED": 0, + "ERROR": 0, + "TIMEOUT": 0, + "SKIP": 0, + "CRASH": 0, + } + + self.unexpected_tests = { + "OK": [], + "PASS": [], + "FAIL": [], + "PRECONDITION_FAILED": [], + "ERROR": [], + "TIMEOUT": [], + "CRASH": [], + } + + # Follows the format of {(<test>, <subtest>): <data>}, where + # (<test>, None) represents a top level test. + self.known_intermittent_results = {} + + def _enable_show_logs(self): + self.show_logs = True + + def _disable_show_logs(self): + self.show_logs = False + + def get_move_up_and_clear_eol_codes(self): + try: + import blessed + except ImportError: + return DEFAULT_MOVE_UP_CODE, DEFAULT_CLEAR_EOL_CODE + + try: + self.terminal = blessed.Terminal() + return self.terminal.move_up, self.terminal.clear_eol + except Exception as exception: + sys.stderr.write( + "GroupingFormatter: Could not get terminal " + "control characters: %s\n" % exception + ) + return DEFAULT_MOVE_UP_CODE, DEFAULT_CLEAR_EOL_CODE + + def text_to_erase_display(self): + if not self.interactive or not self.current_display: + return "" + return (self.move_up + self.clear_eol) * self.current_display.count("\n") + + def generate_output(self, text=None, new_display=None): + if not self.interactive: + return text + + output = self.text_to_erase_display() + if text: + output += text + if new_display is not None: + self.current_display = new_display + return output + self.current_display + + def build_status_line(self): + if self.number_of_tests == 0: + new_display = " [%i] " % self.completed_tests + else: + new_display = " [%i/%i] " % (self.completed_tests, self.number_of_tests) + + if self.running_tests: + indent = " " * len(new_display) + if self.interactive: + max_width = self.line_width - len(new_display) + else: + max_width = sys.maxsize + return ( + new_display + + ("\n%s" % indent).join( + val[:max_width] for val in self.running_tests.values() + ) + + "\n" + ) + else: + return new_display + "No tests running.\n" + + def suite_start(self, data): + self.number_of_tests = sum( + len(tests) for tests in six.itervalues(data["tests"]) + ) + self.start_time = data["time"] + + if self.number_of_tests == 0: + return "Running tests in %s\n\n" % data["source"] + else: + return "Running %i tests in %s\n\n" % ( + self.number_of_tests, + data["source"], + ) + + def test_start(self, data): + self.running_tests[data["thread"]] = data["test"] + return self.generate_output(text=None, new_display=self.build_status_line()) + + def wrap_and_indent_lines(self, lines, indent): + assert len(lines) > 0 + + output = indent + "\u25B6 %s\n" % lines[0] + for line in lines[1:-1]: + output += indent + "\u2502 %s\n" % line + if len(lines) > 1: + output += indent + "\u2514 %s\n" % lines[-1] + return output + + def get_lines_for_unexpected_result( + self, test_name, status, expected, message, stack + ): + # Test names sometimes contain control characters, which we want + # to be printed in their raw form, and not their interpreted form. + test_name = test_name.encode("unicode-escape").decode("utf-8") + + if expected: + expected_text = " [expected %s]" % expected + else: + expected_text = "" + + lines = ["%s%s %s" % (status, expected_text, test_name)] + if message: + lines.append(" \u2192 %s" % message) + if stack: + lines.append("") + lines += [stackline for stackline in stack.splitlines()] + return lines + + def get_lines_for_known_intermittents(self, known_intermittent_results): + lines = [] + + for (test, subtest), data in six.iteritems(self.known_intermittent_results): + status = data["status"] + known_intermittent = ", ".join(data["known_intermittent"]) + expected = " [expected %s, known intermittent [%s]" % ( + data["expected"], + known_intermittent, + ) + lines += [ + "%s%s %s%s" + % ( + status, + expected, + test, + (", %s" % subtest) if subtest is not None else "", + ) + ] + output = self.wrap_and_indent_lines(lines, " ") + "\n" + return output + + def get_output_for_unexpected_subtests(self, test_name, unexpected_subtests): + if not unexpected_subtests: + return "" + + def add_subtest_failure(lines, subtest, stack=None): + lines += self.get_lines_for_unexpected_result( + subtest.get("subtest", None), + subtest.get("status", None), + subtest.get("expected", None), + subtest.get("message", None), + stack, + ) + + def make_subtests_failure(test_name, subtests, stack=None): + lines = ["Unexpected subtest result in %s:" % test_name] + for subtest in subtests[:-1]: + add_subtest_failure(lines, subtest, None) + add_subtest_failure(lines, subtests[-1], stack) + return self.wrap_and_indent_lines(lines, " ") + "\n" + + # Organize the failures by stack trace so we don't print the same stack trace + # more than once. They are really tall and we don't want to flood the screen + # with duplicate information. + output = "" + failures_by_stack = collections.defaultdict(list) + for failure in unexpected_subtests: + # Print stackless results first. They are all separate. + if "stack" not in failure: + output += make_subtests_failure(test_name, [failure], None) + else: + failures_by_stack[failure["stack"]].append(failure) + + for stack, failures in six.iteritems(failures_by_stack): + output += make_subtests_failure(test_name, failures, stack) + return output + + def test_end(self, data): + self.completed_tests += 1 + test_status = data["status"] + test_name = data["test"] + known_intermittent_statuses = data.get("known_intermittent", []) + subtest_failures = self.subtest_failures.pop(test_name, []) + if "expected" in data and test_status not in known_intermittent_statuses: + had_unexpected_test_result = True + else: + had_unexpected_test_result = False + + del self.running_tests[data["thread"]] + new_display = self.build_status_line() + + if not had_unexpected_test_result and not subtest_failures: + self.expected[test_status] += 1 + if self.interactive: + return self.generate_output(text=None, new_display=new_display) + else: + return self.generate_output( + text=" %s\n" % test_name, new_display=new_display + ) + + if test_status in known_intermittent_statuses: + self.known_intermittent_results[(test_name, None)] = data + + # If the test crashed or timed out, we also include any process output, + # because there is a good chance that the test produced a stack trace + # or other error messages. + if test_status in ("CRASH", "TIMEOUT"): + stack = self.test_output[test_name] + data.get("stack", "") + else: + stack = data.get("stack", None) + + output = "" + if had_unexpected_test_result: + self.unexpected_tests[test_status].append(data) + lines = self.get_lines_for_unexpected_result( + test_name, + test_status, + data.get("expected", None), + data.get("message", None), + stack, + ) + output += self.wrap_and_indent_lines(lines, " ") + "\n" + + if subtest_failures: + self.tests_with_failing_subtests.append(test_name) + output += self.get_output_for_unexpected_subtests( + test_name, subtest_failures + ) + self.test_failure_text += output + + return self.generate_output(text=output, new_display=new_display) + + def test_status(self, data): + if "expected" in data and data["status"] not in data.get( + "known_intermittent", [] + ): + self.subtest_failures[data["test"]].append(data) + elif data["status"] in data.get("known_intermittent", []): + self.known_intermittent_results[(data["test"], data["subtest"])] = data + + def suite_end(self, data): + self.end_time = data["time"] + + if not self.interactive: + output = "\n" + else: + output = "" + + output += "Ran %i tests finished in %.1f seconds.\n" % ( + self.completed_tests, + (self.end_time - self.start_time) / 1000.0, + ) + output += " \u2022 %i ran as expected. %i tests skipped.\n" % ( + sum(self.expected.values()), + self.expected["SKIP"], + ) + if self.known_intermittent_results: + output += " \u2022 %i known intermittent results.\n" % ( + len(self.known_intermittent_results) + ) + + def text_for_unexpected_list(text, section): + tests = self.unexpected_tests[section] + if not tests: + return "" + return " \u2022 %i tests %s\n" % (len(tests), text) + + output += text_for_unexpected_list("crashed unexpectedly", "CRASH") + output += text_for_unexpected_list("had errors unexpectedly", "ERROR") + output += text_for_unexpected_list("failed unexpectedly", "FAIL") + output += text_for_unexpected_list( + "precondition failed unexpectedly", "PRECONDITION_FAILED" + ) + output += text_for_unexpected_list("timed out unexpectedly", "TIMEOUT") + output += text_for_unexpected_list("passed unexpectedly", "PASS") + output += text_for_unexpected_list("unexpectedly okay", "OK") + + num_with_failing_subtests = len(self.tests_with_failing_subtests) + if num_with_failing_subtests: + output += ( + " \u2022 %i tests had unexpected subtest results\n" + % num_with_failing_subtests + ) + output += "\n" + + # Repeat failing test output, so that it is easier to find, since the + # non-interactive version prints all the test names. + if not self.interactive and self.test_failure_text: + output += "Tests with unexpected results:\n" + self.test_failure_text + + if self.known_intermittent_results: + results = self.get_lines_for_known_intermittents( + self.known_intermittent_results + ) + output += "Tests with known intermittent results:\n" + results + + return self.generate_output(text=output, new_display="") + + def process_output(self, data): + if data["thread"] not in self.running_tests: + return + test_name = self.running_tests[data["thread"]] + self.test_output[test_name] += data["data"] + "\n" + + def log(self, data): + if data.get("component"): + message = "%s %s %s" % (data["component"], data["level"], data["message"]) + else: + message = "%s %s" % (data["level"], data["message"]) + if "stack" in data: + message += "\n%s" % data["stack"] + + # We are logging messages that begin with STDERR, because that is how exceptions + # in this formatter are indicated. + if data["message"].startswith("STDERR"): + return self.generate_output(text=message + "\n") + + if data["level"] in ("CRITICAL", "ERROR"): + return self.generate_output(text=message + "\n") + # Show all messages if show_logs switched on. + if self.show_logs: + return self.generate_output(text=message + "\n") diff --git a/testing/mozbase/mozlog/mozlog/formatters/html/__init__.py b/testing/mozbase/mozlog/mozlog/formatters/html/__init__.py new file mode 100644 index 0000000000..01348f8dfc --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/formatters/html/__init__.py @@ -0,0 +1,7 @@ +# 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 .html import HTMLFormatter + +__all__ = ["HTMLFormatter"] diff --git a/testing/mozbase/mozlog/mozlog/formatters/html/html.py b/testing/mozbase/mozlog/mozlog/formatters/html/html.py new file mode 100755 index 0000000000..d1b053e6a4 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/formatters/html/html.py @@ -0,0 +1,343 @@ +#!/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/. + +import base64 +import json +import os +from collections import defaultdict +from datetime import datetime + +import six + +from .. import base + +html = None +raw = None + +if six.PY2: + from cgi import escape +else: + from html import escape + +base_path = os.path.split(__file__)[0] + + +def do_defered_imports(): + global html + global raw + + from .xmlgen import html, raw + + +class HTMLFormatter(base.BaseFormatter): + """Formatter that produces a simple HTML-formatted report.""" + + def __init__(self): + do_defered_imports() + self.suite_name = None + self.result_rows = [] + self.test_count = defaultdict(int) + self.start_times = {} + self.suite_times = {"start": None, "end": None} + self.head = None + self.env = {} + + def suite_start(self, data): + self.suite_times["start"] = data["time"] + self.suite_name = data["source"] + with open(os.path.join(base_path, "style.css")) as f: + self.head = html.head( + html.meta(charset="utf-8"), + html.title(data["source"]), + html.style(raw(f.read())), + ) + + date_format = "%d %b %Y %H:%M:%S" + version_info = data.get("version_info") + if version_info: + self.env["Device identifier"] = version_info.get("device_id") + self.env["Device firmware (base)"] = version_info.get( + "device_firmware_version_base" + ) + self.env["Device firmware (date)"] = ( + datetime.utcfromtimestamp( + int(version_info.get("device_firmware_date")) + ).strftime(date_format) + if "device_firmware_date" in version_info + else None + ) + self.env["Device firmware (incremental)"] = version_info.get( + "device_firmware_version_incremental" + ) + self.env["Device firmware (release)"] = version_info.get( + "device_firmware_version_release" + ) + self.env["Gaia date"] = ( + datetime.utcfromtimestamp(int(version_info.get("gaia_date"))).strftime( + date_format + ) + if "gaia_date" in version_info + else None + ) + self.env["Gecko version"] = version_info.get("application_version") + self.env["Gecko build"] = version_info.get("application_buildid") + + if version_info.get("application_changeset"): + self.env["Gecko revision"] = version_info.get("application_changeset") + if version_info.get("application_repository"): + self.env["Gecko revision"] = html.a( + version_info.get("application_changeset"), + href="/rev/".join( + [ + version_info.get("application_repository"), + version_info.get("application_changeset"), + ] + ), + target="_blank", + ) + + if version_info.get("gaia_changeset"): + self.env["Gaia revision"] = html.a( + version_info.get("gaia_changeset")[:12], + href="https://github.com/mozilla-b2g/gaia/commit/%s" + % version_info.get("gaia_changeset"), + target="_blank", + ) + + device_info = data.get("device_info") + if device_info: + self.env["Device uptime"] = device_info.get("uptime") + self.env["Device memory"] = device_info.get("memtotal") + self.env["Device serial"] = device_info.get("id") + + def suite_end(self, data): + self.suite_times["end"] = data["time"] + return self.generate_html() + + def test_start(self, data): + self.start_times[data["test"]] = data["time"] + + def test_end(self, data): + self.make_result_html(data) + + def make_result_html(self, data): + tc_time = (data["time"] - self.start_times.pop(data["test"])) / 1000.0 + additional_html = [] + debug = data.get("extra", {}) + # Add support for log exported from wptrunner. The structure of + # reftest_screenshots is listed in wptrunner/executors/base.py. + if debug.get("reftest_screenshots"): + log_data = debug.get("reftest_screenshots", {}) + debug = { + "image1": "data:image/png;base64," + log_data[0].get("screenshot", {}), + "image2": "data:image/png;base64," + log_data[2].get("screenshot", {}), + "differences": "Not Implemented", + } + + links_html = [] + + status = status_name = data["status"] + expected = data.get("expected", status) + known_intermittent = data.get("known_intermittent", []) + + if status != expected and status not in known_intermittent: + status_name = "UNEXPECTED_" + status + elif status in known_intermittent: + status_name = "KNOWN_INTERMITTENT" + elif status not in ("PASS", "SKIP"): + status_name = "EXPECTED_" + status + + self.test_count[status_name] += 1 + + if status in ["SKIP", "FAIL", "PRECONDITION_FAILED", "ERROR"]: + if debug.get("differences"): + images = [ + ("image1", "Image 1 (test)"), + ("image2", "Image 2 (reference)"), + ] + for title, description in images: + screenshot = "%s" % debug[title] + additional_html.append( + html.div( + html.a(html.img(src=screenshot), href="#"), + html.br(), + html.a(description), + class_="screenshot", + ) + ) + + if debug.get("screenshot"): + screenshot = "%s" % debug["screenshot"] + screenshot = "data:image/png;base64," + screenshot + + additional_html.append( + html.div( + html.a(html.img(src=screenshot), href="#"), class_="screenshot" + ) + ) + + for name, content in debug.items(): + if name in ["screenshot", "image1", "image2"]: + if not content.startswith("data:image/png;base64,"): + href = "data:image/png;base64,%s" % content + else: + href = content + else: + if not isinstance(content, (six.text_type, six.binary_type)): + # All types must be json serializable + content = json.dumps(content) + # Decode to text type if JSON output is byte string + if not isinstance(content, six.text_type): + content = content.decode("utf-8") + # Encode base64 to avoid that some browsers (such as Firefox, Opera) + # treats '#' as the start of another link if it is contained in the data URL. + if isinstance(content, six.text_type): + is_known_utf8 = True + content_bytes = six.text_type(content).encode( + "utf-8", "xmlcharrefreplace" + ) + else: + is_known_utf8 = False + content_bytes = content + + meta = ["text/html"] + if is_known_utf8: + meta.append("charset=utf-8") + + # base64 is ascii only, which means we don't have to care about encoding + # in the case where we don't know the encoding of the input + b64_bytes = base64.b64encode(content_bytes) + b64_text = b64_bytes.decode() + href = "data:%s;base64,%s" % (";".join(meta), b64_text) + links_html.append( + html.a(name.title(), class_=name, href=href, target="_blank") + ) + links_html.append(" ") + + log = html.div(class_="log") + output = data.get("stack", "").splitlines() + output.extend(data.get("message", "").splitlines()) + for line in output: + separator = line.startswith(" " * 10) + if separator: + log.append(line[:80]) + else: + if ( + line.lower().find("error") != -1 + or line.lower().find("exception") != -1 + ): + log.append(html.span(raw(escape(line)), class_="error")) + else: + log.append(raw(escape(line))) + log.append(html.br()) + additional_html.append(log) + + self.result_rows.append( + html.tr( + [ + html.td(status_name, class_="col-result"), + html.td(data["test"], class_="col-name"), + html.td("%.2f" % tc_time, class_="col-duration"), + html.td(links_html, class_="col-links"), + html.td(additional_html, class_="debug"), + ], + class_=status_name.lower() + " results-table-row", + ) + ) + + def generate_html(self): + generated = datetime.utcnow() + with open(os.path.join(base_path, "main.js")) as main_f: + doc = html.html( + self.head, + html.body( + html.script(raw(main_f.read())), + html.p( + "Report generated on %s at %s" + % ( + generated.strftime("%d-%b-%Y"), + generated.strftime("%H:%M:%S"), + ) + ), + html.h2("Environment"), + html.table( + [ + html.tr(html.td(k), html.td(v)) + for k, v in sorted(self.env.items()) + if v + ], + id="environment", + ), + html.h2("Summary"), + html.p( + "%i tests ran in %.1f seconds." + % ( + sum(six.itervalues(self.test_count)), + (self.suite_times["end"] - self.suite_times["start"]) + / 1000.0, + ), + html.br(), + html.span("%i passed" % self.test_count["PASS"], class_="pass"), + ", ", + html.span( + "%i skipped" % self.test_count["SKIP"], class_="skip" + ), + ", ", + html.span( + "%i failed" % self.test_count["UNEXPECTED_FAIL"], + class_="fail", + ), + ", ", + html.span( + "%i errors" % self.test_count["UNEXPECTED_ERROR"], + class_="error", + ), + ".", + html.br(), + html.span( + "%i expected failures" % self.test_count["EXPECTED_FAIL"], + class_="expected_fail", + ), + ", ", + html.span( + "%i unexpected passes" % self.test_count["UNEXPECTED_PASS"], + class_="unexpected_pass", + ), + ", ", + html.span( + "%i known intermittent results" + % self.test_count["KNOWN_INTERMITTENT"], + class_="known_intermittent", + ), + ".", + ), + html.h2("Results"), + html.table( + [ + html.thead( + html.tr( + [ + html.th( + "Result", class_="sortable", col="result" + ), + html.th("Test", class_="sortable", col="name"), + html.th( + "Duration", + class_="sortable numeric", + col="duration", + ), + html.th("Links"), + ] + ), + id="results-table-head", + ), + html.tbody(self.result_rows, id="results-table-body"), + ], + id="results-table", + ), + ), + ) + + return "<!DOCTYPE html>\n" + doc.unicode(indent=2) diff --git a/testing/mozbase/mozlog/mozlog/formatters/html/main.js b/testing/mozbase/mozlog/mozlog/formatters/html/main.js new file mode 100644 index 0000000000..61403f611b --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/formatters/html/main.js @@ -0,0 +1,166 @@ +/* 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/. */ + +function toArray(iter) { + if (iter === null) { + return null; + } + return Array.prototype.slice.call(iter); +} + +function find(selector, elem) { + if (!elem) { + elem = document; + } + return elem.querySelector(selector); +} + +function find_all(selector, elem) { + if (!elem) { + elem = document; + } + return toArray(elem.querySelectorAll(selector)); +} + +addEventListener("DOMContentLoaded", function () { + reset_sort_headers(); + + split_debug_onto_two_rows(); + + find_all(".col-links a.screenshot").forEach(function (elem) { + elem.addEventListener("click", function (event) { + var node = elem; + while (node && !node.classList.contains("results-table-row")) { + node = node.parentNode; + } + if (node != null) { + if (node.nextSibling && node.nextSibling.classList.contains("debug")) { + var href = find(".screenshot img", node.nextSibling).src; + window.open(href); + } + } + event.preventDefault(); + }); + }); + + find_all(".screenshot a").forEach(function (elem) { + elem.addEventListener("click", function (event) { + window.open(find("img", elem).getAttribute("src")); + event.preventDefault(); + }); + }); + + find_all(".sortable").forEach(function (elem) { + elem.addEventListener("click", function (event) { + toggle_sort_states(elem); + var colIndex = toArray(elem.parentNode.childNodes).indexOf(elem); + var key = elem.classList.contains("numeric") ? key_num : key_alpha; + sort_table(elem, key(colIndex)); + }); + }); +}); + +function sort_table(clicked, key_func) { + one_row_for_data(); + var rows = find_all(".results-table-row"); + var reversed = !clicked.classList.contains("asc"); + + var sorted_rows = sort(rows, key_func, reversed); + + var parent = document.getElementById("results-table-body"); + sorted_rows.forEach(function (elem) { + parent.appendChild(elem); + }); + + split_debug_onto_two_rows(); +} + +function sort(items, key_func, reversed) { + var sort_array = items.map(function (item, i) { + return [key_func(item), i]; + }); + var multiplier = reversed ? -1 : 1; + + sort_array.sort(function (a, b) { + var key_a = a[0]; + var key_b = b[0]; + return multiplier * (key_a >= key_b ? 1 : -1); + }); + + return sort_array.map(function (item) { + var index = item[1]; + return items[index]; + }); +} + +function key_alpha(col_index) { + return function (elem) { + return elem.childNodes[col_index].firstChild.data.toLowerCase(); + }; +} + +function key_num(col_index) { + return function (elem) { + return parseFloat(elem.childNodes[col_index].firstChild.data); + }; +} + +function reset_sort_headers() { + find_all(".sort-icon").forEach(function (elem) { + elem.remove(); + }); + find_all(".sortable").forEach(function (elem) { + var icon = document.createElement("div"); + icon.className = "sort-icon"; + icon.textContent = "vvv"; + elem.insertBefore(icon, elem.firstChild); + elem.classList.remove("desc", "active"); + elem.classList.add("asc", "inactive"); + }); +} + +function toggle_sort_states(elem) { + // if active, toggle between asc and desc + if (elem.classList.contains("active")) { + elem.classList.toggle("asc"); + elem.classList.toggle("desc"); + } + + // if inactive, reset all other functions and add ascending active + if (elem.classList.contains("inactive")) { + reset_sort_headers(); + elem.classList.remove("inactive"); + elem.classList.add("active"); + } +} + +function split_debug_onto_two_rows() { + find_all("tr.results-table-row").forEach(function (elem) { + var new_row = document.createElement("tr"); + new_row.className = "debug"; + elem.parentNode.insertBefore(new_row, elem.nextSibling); + find_all(".debug", elem).forEach(function (td_elem) { + if (find(".log", td_elem)) { + new_row.appendChild(td_elem); + td_elem.colSpan = 5; + } else { + td_elem.remove(); + } + }); + }); +} + +function one_row_for_data() { + find_all("tr.results-table-row").forEach(function (elem) { + if (elem.nextSibling.classList.contains("debug")) { + toArray(elem.nextSibling.childNodes).forEach(function (td_elem) { + elem.appendChild(td_elem); + }); + } else { + var new_td = document.createElement("td"); + new_td.className = "debug"; + elem.appendChild(new_td); + } + }); +} diff --git a/testing/mozbase/mozlog/mozlog/formatters/html/style.css b/testing/mozbase/mozlog/mozlog/formatters/html/style.css new file mode 100644 index 0000000000..427742dd1f --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/formatters/html/style.css @@ -0,0 +1,155 @@ +/* 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/. */ + +body { + font-family: Helvetica, Arial, sans-serif; + font-size: 12px; + min-width: 1200px; + color: #999; +} +h2 { + font-size: 16px; + color: black; +} + +p { + color: black; +} + +a { + color: #999; +} + +table { + border-collapse: collapse; +} + +/****************************** + * SUMMARY INFORMATION + ******************************/ + +#environment td { + padding: 5px; + border: 1px solid #E6E6E6; +} + +#environment tr:nth-child(odd) { + background-color: #f6f6f6; +} + +/****************************** + * TEST RESULT COLORS + ******************************/ +span.pass, .pass .col-result { + color: green; +} +span.expected_fail, .expected_fail .col-result, +span.expected_skip, .expected_skip .col-result, +span.skip, .skip .col-result, +span.known_intermittent, .known_intermittent .col-result { + color: orange; +} +span.error, .error .col-result, +span.fail, .fail .col-result, +span.unexpected_error, .unexpected_error .col-result, +span.unexpected_fail, .unexpected_fail .col-result, +span.unexpected_pass, .unexpected_pass .col-result { + color: red; +} + +/****************************** + * RESULTS TABLE + * + * 1. Table Layout + * 2. Debug + * 3. Sorting items + * + ******************************/ + +/*------------------ + * 1. Table Layout + *------------------*/ + +#results-table { + border: 1px solid #e6e6e6; + color: #999; + font-size: 12px; + width: 100% +} + +#results-table th, #results-table td { + padding: 5px; + border: 1px solid #E6E6E6; + text-align: left +} +#results-table th { + font-weight: bold +} + +/*------------------ + * 2. Debug + *------------------*/ + +.log:only-child { + height: inherit +} +.log { + background-color: #e6e6e6; + border: 1px solid #e6e6e6; + color: black; + display: block; + font-family: "Courier New", Courier, monospace; + height: 230px; + overflow-y: scroll; + padding: 5px; + white-space: pre-wrap +} +div.screenshot { + border: 1px solid #e6e6e6; + float: left; + margin-left: 5px; + height: 220px +} +div.screenshot img { + height: 220px +} + +/*if the result is passed or xpassed don't show debug row*/ +.passed + .debug, .unexpected.pass + .debug { + display: none; +} + +/*------------------ + * 3. Sorting items + *------------------*/ +.sortable { + cursor: pointer; +} + +.sort-icon { + font-size: 0px; + float: left; + margin-right: 5px; + margin-top: 5px; + /*triangle*/ + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; +} + +.inactive .sort-icon { + /*finish triangle*/ + border-top: 8px solid #E6E6E6; +} + +.asc.active .sort-icon { + /*finish triangle*/ + border-bottom: 8px solid #999; +} + +.desc.active .sort-icon { + /*finish triangle*/ + border-top: 8px solid #999; +} diff --git a/testing/mozbase/mozlog/mozlog/formatters/html/xmlgen.py b/testing/mozbase/mozlog/mozlog/formatters/html/xmlgen.py new file mode 100644 index 0000000000..eda2d8d36a --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/formatters/html/xmlgen.py @@ -0,0 +1,310 @@ +""" +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +This file is originally from: https://bitbucket.org/hpk42/py, specifically: +https://bitbucket.org/hpk42/py/src/980c8d526463958ee7cae678a7e4e9b054f36b94/py/_xmlgen.py?at=default +by holger krekel, holger at merlinux eu. 2009 +""" + +import re +import sys + +if sys.version_info >= (3, 0): + + def u(s): + return s + + def unicode(x): + if hasattr(x, "__unicode__"): + return x.__unicode__() + return str(x) + +else: + + def u(s): + return unicode(s) + + # pylint: disable=W1612 + unicode = unicode + + +class NamespaceMetaclass(type): + def __getattr__(self, name): + if name[:1] == "_": + raise AttributeError(name) + if self == Namespace: + raise ValueError("Namespace class is abstract") + tagspec = self.__tagspec__ + if tagspec is not None and name not in tagspec: + raise AttributeError(name) + classattr = {} + if self.__stickyname__: + classattr["xmlname"] = name + cls = type(name, (self.__tagclass__,), classattr) + setattr(self, name, cls) + return cls + + +class Tag(list): + class Attr(object): + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + def __init__(self, *args, **kwargs): + super(Tag, self).__init__(args) + self.attr = self.Attr(**kwargs) + + def __unicode__(self): + return self.unicode(indent=0) + + __str__ = __unicode__ + + def unicode(self, indent=2): + l = [] + SimpleUnicodeVisitor(l.append, indent).visit(self) + return u("").join(l) + + def __repr__(self): + name = self.__class__.__name__ + return "<%r tag object %d>" % (name, id(self)) + + +Namespace = NamespaceMetaclass( + "Namespace", + (object,), + { + "__tagspec__": None, + "__tagclass__": Tag, + "__stickyname__": False, + }, +) + + +class HtmlTag(Tag): + def unicode(self, indent=2): + l = [] + HtmlVisitor(l.append, indent, shortempty=False).visit(self) + return u("").join(l) + + +# exported plain html namespace + + +class html(Namespace): + __tagclass__ = HtmlTag + __stickyname__ = True + __tagspec__ = dict( + [ + (x, 1) + for x in ( + "a,abbr,acronym,address,applet,area,b,bdo,big,blink," + "blockquote,body,br,button,caption,center,cite,code,col," + "colgroup,comment,dd,del,dfn,dir,div,dl,dt,em,embed," + "fieldset,font,form,frameset,h1,h2,h3,h4,h5,h6,head,html," + "i,iframe,img,input,ins,kbd,label,legend,li,link,listing," + "map,marquee,menu,meta,multicol,nobr,noembed,noframes," + "noscript,object,ol,optgroup,option,p,pre,q,s,script," + "select,small,span,strike,strong,style,sub,sup,table," + "tbody,td,textarea,tfoot,th,thead,title,tr,tt,u,ul,xmp," + "base,basefont,frame,hr,isindex,param,samp,var" + ).split(",") + if x + ] + ) + + class Style(object): + def __init__(self, **kw): + for x, y in kw.items(): + x = x.replace("_", "-") + setattr(self, x, y) + + +class raw(object): + """just a box that can contain a unicode string that will be + included directly in the output""" + + def __init__(self, uniobj): + self.uniobj = uniobj + + +class SimpleUnicodeVisitor(object): + """recursive visitor to write unicode.""" + + def __init__(self, write, indent=0, curindent=0, shortempty=True): + self.write = write + self.cache = {} + self.visited = {} # for detection of recursion + self.indent = indent + self.curindent = curindent + self.parents = [] + self.shortempty = shortempty # short empty tags or not + + def visit(self, node): + """dispatcher on node's class/bases name.""" + cls = node.__class__ + try: + visitmethod = self.cache[cls] + except KeyError: + for subclass in cls.__mro__: + visitmethod = getattr(self, subclass.__name__, None) + if visitmethod is not None: + break + else: + visitmethod = self.__object + self.cache[cls] = visitmethod + visitmethod(node) + + # the default fallback handler is marked private + # to avoid clashes with the tag name object + def __object(self, obj): + # self.write(obj) + self.write(escape(unicode(obj))) + + def raw(self, obj): + self.write(obj.uniobj) + + def list(self, obj): + assert id(obj) not in self.visited + self.visited[id(obj)] = 1 + for elem in obj: + self.visit(elem) + + def Tag(self, tag): + assert id(tag) not in self.visited + try: + tag.parent = self.parents[-1] + except IndexError: + tag.parent = None + self.visited[id(tag)] = 1 + tagname = getattr(tag, "xmlname", tag.__class__.__name__) + if self.curindent and not self._isinline(tagname): + self.write("\n" + u(" ") * self.curindent) + if tag: + self.curindent += self.indent + self.write(u("<%s%s>") % (tagname, self.attributes(tag))) + self.parents.append(tag) + for x in tag: + self.visit(x) + self.parents.pop() + self.write(u("</%s>") % tagname) + self.curindent -= self.indent + else: + nameattr = tagname + self.attributes(tag) + if self._issingleton(tagname): + self.write(u("<%s/>") % (nameattr,)) + else: + self.write(u("<%s></%s>") % (nameattr, tagname)) + + def attributes(self, tag): + # serialize attributes + attrlist = dir(tag.attr) + attrlist.sort() + l = [] + for name in attrlist: + res = self.repr_attribute(tag.attr, name) + if res is not None: + l.append(res) + l.extend(self.getstyle(tag)) + return u("").join(l) + + def repr_attribute(self, attrs, name): + if name[:2] != "__": + value = getattr(attrs, name) + if name.endswith("_"): + name = name[:-1] + if isinstance(value, raw): + insert = value.uniobj + else: + insert = escape(unicode(value)) + return ' %s="%s"' % (name, insert) + + def getstyle(self, tag): + """return attribute list suitable for styling.""" + try: + styledict = tag.style.__dict__ + except AttributeError: + return [] + else: + stylelist = [x + ": " + y for x, y in styledict.items()] + return [u(' style="%s"') % u("; ").join(stylelist)] + + def _issingleton(self, tagname): + """can (and will) be overridden in subclasses""" + return self.shortempty + + def _isinline(self, tagname): + """can (and will) be overridden in subclasses""" + return False + + +class HtmlVisitor(SimpleUnicodeVisitor): + single = dict( + [ + (x, 1) + for x in ("br,img,area,param,col,hr,meta,link,base," "input,frame").split( + "," + ) + ] + ) + inline = dict( + [ + (x, 1) + for x in ( + "a abbr acronym b basefont bdo big br cite code dfn em font " + "i img input kbd label q s samp select small span strike " + "strong sub sup textarea tt u var".split(" ") + ) + ] + ) + + def repr_attribute(self, attrs, name): + if name == "class_": + value = getattr(attrs, name) + if value is None: + return + return super(HtmlVisitor, self).repr_attribute(attrs, name) + + def _issingleton(self, tagname): + return tagname in self.single + + def _isinline(self, tagname): + return tagname in self.inline + + +class _escape: + def __init__(self): + self.escape = { + u('"'): u("""), + u("<"): u("<"), + u(">"): u(">"), + u("&"): u("&"), + u("'"): u("'"), + } + self.charef_rex = re.compile(u("|").join(self.escape.keys())) + + def _replacer(self, match): + return self.escape[match.group(0)] + + def __call__(self, ustring): + """xml-escape the given unicode string.""" + ustring = unicode(ustring) + return self.charef_rex.sub(self._replacer, ustring) + + +escape = _escape() diff --git a/testing/mozbase/mozlog/mozlog/formatters/machformatter.py b/testing/mozbase/mozlog/mozlog/formatters/machformatter.py new file mode 100644 index 0000000000..2658df10a9 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/formatters/machformatter.py @@ -0,0 +1,657 @@ +# 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/. + + +import time +from functools import reduce + +import six +from mozterm import Terminal + +from ..handlers import SummaryHandler +from . import base +from .process import strstatus +from .tbplformatter import TbplFormatter + +color_dict = { + "log_test_status_fail": "red", + "log_process_output": "blue", + "log_test_status_pass": "green", + "log_test_status_unexpected_fail": "red", + "log_test_status_known_intermittent": "yellow", + "time": "cyan", + "action": "yellow", + "pid": "cyan", + "heading": "bold_yellow", + "sub_heading": "yellow", + "error": "red", + "warning": "yellow", + "bold": "bold", + "grey": "grey", + "normal": "normal", + "bright_black": "bright_black", +} + +DEFAULT = "\x1b(B\x1b[m" + + +def format_seconds(total): + """Format number of seconds to MM:SS.DD form.""" + minutes, seconds = divmod(total, 60) + return "%2d:%05.2f" % (minutes, seconds) + + +class TerminalColors(object): + def __init__(self, term, color_dict): + for key, value in color_dict.items(): + attribute = getattr(term, value) + # In Blessed, these attributes aren't always callable. We can assume + # that if they're not, they're just the raw ANSI Escape Sequences. + # This TerminalColors class is basically just a lookup table for + # what function to call to format/color an input string a certain way. + # So if the attribute above is a callable, we can just proceed, but + # if it's not, we need to create our own function that prepends the + # raw ANSI Escape Sequences to the input string, so that everything + # has the same behavior. We append DEFAULT to reset to no formatting + # at the end of our string, to prevent text that comes afterwards + # from inheriting the prepended formatting. + if not callable(attribute): + + def apply_formatting(text): + return attribute + text + DEFAULT + + attribute = apply_formatting + setattr(self, key, attribute) + + +class MachFormatter(base.BaseFormatter): + def __init__( + self, + start_time=None, + write_interval=False, + write_times=True, + terminal=None, + disable_colors=False, + summary_on_shutdown=False, + verbose=False, + enable_screenshot=False, + **kwargs + ): + super(MachFormatter, self).__init__(**kwargs) + + if start_time is None: + start_time = time.time() + start_time = int(start_time * 1000) + self.start_time = start_time + self.write_interval = write_interval + self.write_times = write_times + self.status_buffer = {} + self.has_unexpected = {} + self.last_time = None + self.color_formatter = TerminalColors( + Terminal(disable_styling=disable_colors), color_dict + ) + self.verbose = verbose + self._known_pids = set() + self.tbpl_formatter = None + self.enable_screenshot = enable_screenshot + self.summary = SummaryHandler() + self.summary_on_shutdown = summary_on_shutdown + + message_handlers = { + "colors": { + "on": self._enable_colors, + "off": self._disable_colors, + }, + "summary_on_shutdown": { + "on": self._enable_summary_on_shutdown, + "off": self._disable_summary_on_shutdown, + }, + } + + for topic, handlers in message_handlers.items(): + self.message_handler.register_message_handlers(topic, handlers) + + def __call__(self, data): + self.summary(data) + + s = super(MachFormatter, self).__call__(data) + if s is None: + return + + time = self.color_formatter.time(format_seconds(self._time(data))) + return "%s %s\n" % (time, s) + + def _enable_colors(self): + self.disable_colors = False + + def _disable_colors(self): + self.disable_colors = True + + def _enable_summary_on_shutdown(self): + self.summary_on_shutdown = True + + def _disable_summary_on_shutdown(self): + self.summary_on_shutdown = False + + def _get_test_id(self, data): + test_id = data.get("test") + if isinstance(test_id, list): + test_id = tuple(test_id) + return test_id + + def _get_file_name(self, test_id): + if isinstance(test_id, (str, six.text_type)): + return test_id + + if isinstance(test_id, tuple): + return "".join(test_id) + + assert False, "unexpected test_id" + + def suite_start(self, data): + num_tests = reduce(lambda x, y: x + len(y), six.itervalues(data["tests"]), 0) + action = self.color_formatter.action(data["action"].upper()) + name = "" + if "name" in data: + name = " %s -" % (data["name"],) + return "%s:%s running %i tests" % (action, name, num_tests) + + def suite_end(self, data): + action = self.color_formatter.action(data["action"].upper()) + rv = [action] + if not self.summary_on_shutdown: + rv.append( + self._format_suite_summary( + self.summary.current_suite, self.summary.current + ) + ) + return "\n".join(rv) + + def _format_expected(self, status, expected, known_intermittent=[]): + if status == expected: + color = self.color_formatter.log_test_status_pass + if expected not in ("PASS", "OK"): + color = self.color_formatter.log_test_status_fail + status = "EXPECTED-%s" % status + else: + if status in known_intermittent: + color = self.color_formatter.log_test_status_known_intermittent + status = "KNOWN-INTERMITTENT-%s" % status + else: + color = self.color_formatter.log_test_status_fail + if status in ("PASS", "OK"): + status = "UNEXPECTED-%s" % status + return color(status) + + def _format_status(self, test, data): + name = data.get("subtest", test) + rv = "%s %s" % ( + self._format_expected( + data["status"], + data.get("expected", data["status"]), + data.get("known_intermittent", []), + ), + name, + ) + if "message" in data: + rv += " - %s" % data["message"] + if "stack" in data: + rv += self._format_stack(data["stack"]) + return rv + + def _format_stack(self, stack): + return "\n%s\n" % self.color_formatter.bright_black(stack.strip("\n")) + + def _format_suite_summary(self, suite, summary): + count = summary["counts"] + logs = summary["unexpected_logs"] + intermittent_logs = summary["intermittent_logs"] + harness_errors = summary["harness_errors"] + + rv = [ + "", + self.color_formatter.sub_heading(suite), + self.color_formatter.sub_heading("~" * len(suite)), + ] + + # Format check counts + checks = self.summary.aggregate("count", count) + rv.append( + "Ran {} checks ({})".format( + sum(checks.values()), + ", ".join( + ["{} {}s".format(v, k) for k, v in sorted(checks.items()) if v] + ), + ) + ) + + # Format expected counts + checks = self.summary.aggregate("expected", count, include_skip=False) + intermittent_checks = self.summary.aggregate( + "known_intermittent", count, include_skip=False + ) + intermittents = sum(intermittent_checks.values()) + known = ( + " ({} known intermittents)".format(intermittents) if intermittents else "" + ) + rv.append("Expected results: {}{}".format(sum(checks.values()), known)) + + # Format skip counts + skip_tests = count["test"]["expected"]["skip"] + skip_subtests = count["subtest"]["expected"]["skip"] + if skip_tests: + skipped = "Skipped: {} tests".format(skip_tests) + if skip_subtests: + skipped = "{}, {} subtests".format(skipped, skip_subtests) + rv.append(skipped) + + # Format unexpected counts + checks = self.summary.aggregate("unexpected", count) + unexpected_count = sum(checks.values()) + rv.append("Unexpected results: {}".format(unexpected_count)) + if unexpected_count: + for key in ("test", "subtest", "assert"): + if not count[key]["unexpected"]: + continue + status_str = ", ".join( + [ + "{} {}".format(n, s) + for s, n in sorted(count[key]["unexpected"].items()) + ] + ) + rv.append( + " {}: {} ({})".format( + key, sum(count[key]["unexpected"].values()), status_str + ) + ) + + # Format intermittents + if intermittents > 0: + heading = "Known Intermittent Results" + rv.extend( + [ + "", + self.color_formatter.heading(heading), + self.color_formatter.heading("-" * len(heading)), + ] + ) + if count["subtest"]["count"]: + for test_id, results in intermittent_logs.items(): + test = self._get_file_name(test_id) + rv.append(self.color_formatter.bold(test)) + for data in results: + rv.append(" %s" % self._format_status(test, data).rstrip()) + else: + for test_id, results in intermittent_logs.items(): + test = self._get_file_name(test_id) + for data in results: + assert "subtest" not in data + rv.append(self._format_status(test, data).rstrip()) + + # Format status + testfailed = any( + count[key]["unexpected"] for key in ("test", "subtest", "assert") + ) + if not testfailed and not harness_errors: + rv.append(self.color_formatter.log_test_status_pass("OK")) + else: + # Format test failures + heading = "Unexpected Results" + rv.extend( + [ + "", + self.color_formatter.heading(heading), + self.color_formatter.heading("-" * len(heading)), + ] + ) + if count["subtest"]["count"]: + for test_id, results in logs.items(): + test = self._get_file_name(test_id) + rv.append(self.color_formatter.bold(test)) + for data in results: + rv.append(" %s" % self._format_status(test, data).rstrip()) + else: + for test_id, results in logs.items(): + test = self._get_file_name(test_id) + for data in results: + assert "subtest" not in data + rv.append(self._format_status(test, data).rstrip()) + + # Format harness errors + if harness_errors: + for data in harness_errors: + rv.append(self.log(data)) + + return "\n".join(rv) + + def test_start(self, data): + action = self.color_formatter.action(data["action"].upper()) + return "%s: %s" % (action, self._get_test_id(data)) + + def test_end(self, data): + subtests = self._get_subtest_data(data) + + if "expected" in data and data["status"] not in data.get( + "known_intermittent", [] + ): + parent_unexpected = True + expected_str = ", expected %s" % data["expected"] + else: + parent_unexpected = False + expected_str = "" + + has_screenshots = "reftest_screenshots" in data.get("extra", {}) + + test = self._get_test_id(data) + + # Reset the counts to 0 + self.status_buffer[test] = {"count": 0, "unexpected": 0, "pass": 0} + self.has_unexpected[test] = bool(subtests["unexpected"]) + + if subtests["count"] != 0: + rv = "Test %s%s. Subtests passed %i/%i. Unexpected %s" % ( + data["status"], + expected_str, + subtests["pass"], + subtests["count"], + subtests["unexpected"], + ) + else: + rv = "%s%s" % (data["status"], expected_str) + + unexpected = self.summary.current["unexpected_logs"].get(data["test"]) + if unexpected: + if len(unexpected) == 1 and parent_unexpected: + message = unexpected[0].get("message", "") + if message: + rv += " - %s" % message + if "stack" in data: + rv += self._format_stack(data["stack"]) + elif not self.verbose: + rv += "\n" + for d in unexpected: + rv += self._format_status(data["test"], d) + + intermittents = self.summary.current["intermittent_logs"].get(data["test"]) + if intermittents: + rv += "\n" + for d in intermittents: + rv += self._format_status(data["test"], d) + + if "expected" not in data and not bool(subtests["unexpected"]): + color = self.color_formatter.log_test_status_pass + else: + color = self.color_formatter.log_test_status_unexpected_fail + + action = color(data["action"].upper()) + rv = "%s: %s" % (action, rv) + if has_screenshots and self.enable_screenshot: + if self.tbpl_formatter is None: + self.tbpl_formatter = TbplFormatter() + # Create TBPL-like output that can be pasted into the reftest analyser + rv = "\n".join((rv, self.tbpl_formatter.test_end(data))) + return rv + + def valgrind_error(self, data): + rv = " " + data["primary"] + "\n" + for line in data["secondary"]: + rv = rv + line + "\n" + + return rv + + def lsan_leak(self, data): + allowed = data.get("allowed_match") + if allowed: + prefix = self.color_formatter.log_test_status_fail("FAIL") + else: + prefix = self.color_formatter.log_test_status_unexpected_fail( + "UNEXPECTED-FAIL" + ) + + return "%s LeakSanitizer: leak at %s" % (prefix, ", ".join(data["frames"])) + + def lsan_summary(self, data): + allowed = data.get("allowed", False) + if allowed: + prefix = self.color_formatter.warning("WARNING") + else: + prefix = self.color_formatter.error("ERROR") + + return ( + "%s | LeakSanitizer | " + "SUMMARY: AddressSanitizer: %d byte(s) leaked in %d allocation(s)." + % (prefix, data["bytes"], data["allocations"]) + ) + + def mozleak_object(self, data): + data_log = data.copy() + data_log["level"] = "INFO" + data_log["message"] = "leakcheck: %s leaked %d %s" % ( + data["process"], + data["bytes"], + data["name"], + ) + return self.log(data_log) + + def mozleak_total(self, data): + if data["bytes"] is None: + # We didn't see a line with name 'TOTAL' + if data.get("induced_crash", False): + data_log = data.copy() + data_log["level"] = "INFO" + data_log["message"] = ( + "leakcheck: %s deliberate crash and thus no leak log\n" + % data["process"] + ) + return self.log(data_log) + if data.get("ignore_missing", False): + return ( + "%s ignoring missing output line for total leaks\n" + % data["process"] + ) + + status = self.color_formatter.log_test_status_pass("FAIL") + return "%s leakcheck: " "%s missing output line for total leaks!\n" % ( + status, + data["process"], + ) + + if data["bytes"] == 0: + return "%s leakcheck: %s no leaks detected!\n" % ( + self.color_formatter.log_test_status_pass("PASS"), + data["process"], + ) + + message = "leakcheck: %s %d bytes leaked\n" % (data["process"], data["bytes"]) + + # data["bytes"] will include any expected leaks, so it can be off + # by a few thousand bytes. + failure = data["bytes"] > data["threshold"] + status = ( + self.color_formatter.log_test_status_unexpected_fail("UNEXPECTED-FAIL") + if failure + else self.color_formatter.log_test_status_fail("FAIL") + ) + return "%s %s\n" % (status, message) + + def test_status(self, data): + test = self._get_test_id(data) + if test not in self.status_buffer: + self.status_buffer[test] = {"count": 0, "unexpected": 0, "pass": 0} + self.status_buffer[test]["count"] += 1 + + if data["status"] == "PASS": + self.status_buffer[test]["pass"] += 1 + + if "expected" in data and data["status"] not in data.get( + "known_intermittent", [] + ): + self.status_buffer[test]["unexpected"] += 1 + + if self.verbose: + return self._format_status(test, data).rstrip("\n") + + def assertion_count(self, data): + if data["min_expected"] <= data["count"] <= data["max_expected"]: + return + + if data["min_expected"] != data["max_expected"]: + expected = "%i to %i" % (data["min_expected"], data["max_expected"]) + else: + expected = "%i" % data["min_expected"] + + action = self.color_formatter.log_test_status_fail("ASSERT") + return "%s: Assertion count %i, expected %s assertions\n" % ( + action, + data["count"], + expected, + ) + + def process_output(self, data): + rv = [] + + pid = data["process"] + if pid.isdigit(): + pid = "pid:%s" % pid + pid = self.color_formatter.pid(pid) + + if "command" in data and data["process"] not in self._known_pids: + self._known_pids.add(data["process"]) + rv.append("%s Full command: %s" % (pid, data["command"])) + + rv.append("%s %s" % (pid, data["data"])) + return "\n".join(rv) + + def crash(self, data): + test = self._get_test_id(data) + + if data.get("stackwalk_returncode", 0) != 0 and not data.get( + "stackwalk_stderr" + ): + success = True + else: + success = False + + rv = [ + "pid:%s. Process type: %s. Test:%s. Minidump analysed:%s. Signature:[%s]" + % ( + data.get("pid", "unknown"), + data.get("process_type", None), + test, + success, + data["signature"], + ) + ] + + if data.get("java_stack"): + rv.append("Java exception: %s" % data["java_stack"]) + else: + if data.get("reason"): + rv.append("Mozilla crash reason: %s" % data["reason"]) + + if data.get("minidump_path"): + rv.append("Crash dump filename: %s" % data["minidump_path"]) + + if data.get("stackwalk_returncode", 0) != 0: + rv.append( + "minidump-stackwalk exited with return code %d" + % data["stackwalk_returncode"] + ) + + if data.get("stackwalk_stderr"): + rv.append("stderr from minidump-stackwalk:") + rv.append(data["stackwalk_stderr"]) + elif data.get("stackwalk_stdout"): + rv.append(data["stackwalk_stdout"]) + + if data.get("stackwalk_errors"): + rv.extend(data.get("stackwalk_errors")) + + rv = "\n".join(rv) + if not rv[-1] == "\n": + rv += "\n" + + action = self.color_formatter.action(data["action"].upper()) + return "%s: %s" % (action, rv) + + def process_start(self, data): + rv = "Started process `%s`" % data["process"] + desc = data.get("command") + if desc: + rv = "%s (%s)" % (rv, desc) + return rv + + def process_exit(self, data): + return "%s: %s" % (data["process"], strstatus(data["exitcode"])) + + def log(self, data): + level = data.get("level").upper() + + if level in ("CRITICAL", "ERROR"): + level = self.color_formatter.error(level) + elif level == "WARNING": + level = self.color_formatter.warning(level) + elif level == "INFO": + level = self.color_formatter.log_process_output(level) + + if data.get("component"): + rv = " ".join([data["component"], level, data["message"]]) + else: + rv = "%s %s" % (level, data["message"]) + + if "stack" in data: + rv += "\n%s" % data["stack"] + + return rv + + def lint(self, data): + fmt = ( + "{path} {c1}{lineno}{column} {c2}{level}{normal} {message}" + " {c1}{rule}({linter}){normal}" + ) + message = fmt.format( + path=data["path"], + normal=self.color_formatter.normal, + c1=self.color_formatter.grey, + c2=self.color_formatter.error + if data["level"] == "error" + else (self.color_formatter.log_test_status_fail), + lineno=str(data["lineno"]), + column=(":" + str(data["column"])) if data.get("column") else "", + level=data["level"], + message=data["message"], + rule="{} ".format(data["rule"]) if data.get("rule") else "", + linter=data["linter"].lower() if data.get("linter") else "", + ) + + return message + + def shutdown(self, data): + if not self.summary_on_shutdown: + return + + heading = "Overall Summary" + rv = [ + "", + self.color_formatter.heading(heading), + self.color_formatter.heading("=" * len(heading)), + ] + for suite, summary in self.summary: + rv.append(self._format_suite_summary(suite, summary)) + return "\n".join(rv) + + def _get_subtest_data(self, data): + test = self._get_test_id(data) + return self.status_buffer.get(test, {"count": 0, "unexpected": 0, "pass": 0}) + + def _time(self, data): + entry_time = data["time"] + if self.write_interval and self.last_time is not None: + t = entry_time - self.last_time + self.last_time = entry_time + else: + t = entry_time - self.start_time + + return t / 1000.0 diff --git a/testing/mozbase/mozlog/mozlog/formatters/process.py b/testing/mozbase/mozlog/mozlog/formatters/process.py new file mode 100644 index 0000000000..012c7a0803 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/formatters/process.py @@ -0,0 +1,59 @@ +# 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/. + +import os +import signal + +from six.moves import range + +# a dict cache of signal number -> signal name +_SIG_NAME = None + + +def strsig(n): + """ + Translate a process signal identifier to a human readable string. + """ + global _SIG_NAME + + if _SIG_NAME is None: + # cache signal names + _SIG_NAME = {} + for k in dir(signal): + if ( + k.startswith("SIG") + and not k.startswith("SIG_") + and k != "SIGCLD" + and k != "SIGPOLL" + ): + _SIG_NAME[getattr(signal, k)] = k + + # Realtime signals mostly have no names + if hasattr(signal, "SIGRTMIN") and hasattr(signal, "SIGRTMAX"): + for r in range(signal.SIGRTMIN + 1, signal.SIGRTMAX + 1): + _SIG_NAME[r] = "SIGRTMIN+" + str(r - signal.SIGRTMIN) + + if n < 0 or n >= signal.NSIG: + return "out-of-range signal, number %s" % n + try: + return _SIG_NAME[n] + except KeyError: + return "unrecognized signal, number %s" % n + + +def strstatus(status): + """ + Returns a human readable string of a process exit code, as returned + by the subprocess module. + """ + # 'status' is the exit status + if os.name != "posix": + # Windows error codes are easier to look up if printed in hexadecimal + if status < 0: + status += 2**32 + return "exit %x" % status + elif status >= 0: + return "exit %d" % status + else: + return "killed by %s" % strsig(-status) diff --git a/testing/mozbase/mozlog/mozlog/formatters/tbplformatter.py b/testing/mozbase/mozlog/mozlog/formatters/tbplformatter.py new file mode 100644 index 0000000000..103b9a2d54 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/formatters/tbplformatter.py @@ -0,0 +1,473 @@ +# 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/. + +import functools +from collections import deque +from functools import reduce + +import six + +from ..handlers import SummaryHandler +from .base import BaseFormatter +from .process import strstatus + + +def output_subtests(func): + @functools.wraps(func) + def inner(self, data): + if self.subtests_count: + return self._format_subtests(data.get("component")) + func(self, data) + else: + return func(self, data) + + return inner + + +class TbplFormatter(BaseFormatter): + """Formatter that formats logs in the legacy formatting format used by TBPL + This is intended to be used to preserve backward compatibility with existing tools + hand-parsing this format. + """ + + def __init__(self, compact=False, summary_on_shutdown=False, **kwargs): + super(TbplFormatter, self).__init__(**kwargs) + self.suite_start_time = None + self.test_start_times = {} + self.buffer = None + self.compact = compact + self.subtests_count = 0 + + self.summary = SummaryHandler() + self.summary_on_shutdown = summary_on_shutdown + + def __call__(self, data): + if self.summary_on_shutdown: + self.summary(data) + return super(TbplFormatter, self).__call__(data) + + @property + def compact(self): + return self._compact + + @compact.setter + def compact(self, value): + self._compact = value + if value: + self.buffer = deque([], 10) + else: + self.buffer = None + + def _format_subtests(self, component, subtract_context=False): + count = self.subtests_count + if subtract_context: + count -= len(self.buffer) + self.subtests_count = 0 + return self._log( + {"level": "INFO", "message": "." * count, "component": component} + ) + + @output_subtests + def log(self, data): + return self._log(data) + + def _log(self, data): + if data.get("component"): + message = "%s %s" % (data["component"], data["message"]) + else: + message = data["message"] + + if "stack" in data: + message += "\n%s" % data["stack"] + + return "%s\n" % message + + @output_subtests + def process_output(self, data): + pid = data["process"] + if pid.isdigit(): + pid = "PID %s" % pid + return "%s | %s\n" % (pid, data["data"]) + + @output_subtests + def process_start(self, data): + msg = "TEST-INFO | started process %s" % data["process"] + if "command" in data: + msg = "%s (%s)" % (msg, data["command"]) + return msg + "\n" + + @output_subtests + def process_exit(self, data): + return "TEST-INFO | %s: %s\n" % (data["process"], strstatus(data["exitcode"])) + + @output_subtests + def shutdown_failure(self, data): + return "TEST-UNEXPECTED-FAIL | %s | %s\n" % (data["group"], data["message"]) + + @output_subtests + def crash(self, data): + id = data["test"] if "test" in data else "pid: %s" % data["process"] + + if data.get("java_stack"): + # use "<exception> at <top frame>" as a crash signature for java exception + sig = data["java_stack"].split("\n") + sig = " ".join(sig[0:2]) + rv = ["PROCESS-CRASH | %s | %s\n[%s]" % (id, sig, data["java_stack"])] + + if data.get("reason"): + rv.append("Mozilla crash reason: %s" % data["reason"]) + + if data.get("minidump_path"): + rv.append("Crash dump filename: %s" % data["minidump_path"]) + + else: + signature = data["signature"] if data["signature"] else "unknown top frame" + reason = data.get("reason", "application crashed") + rv = ["PROCESS-CRASH | %s [%s] | %s " % (reason, signature, id)] + + if data.get("process_type"): + rv.append("Process type: {}".format(data["process_type"])) + + rv.append("Process pid: {}".format(data.get("pid", "unknown"))) + + if data.get("reason"): + rv.append("Mozilla crash reason: %s" % data["reason"]) + + if data.get("minidump_path"): + rv.append("Crash dump filename: %s" % data["minidump_path"]) + + if data.get("stackwalk_stderr"): + rv.append("stderr from minidump-stackwalk:") + rv.append(data["stackwalk_stderr"]) + elif data.get("stackwalk_stdout"): + rv.append(data["stackwalk_stdout"]) + + if data.get("stackwalk_returncode", 0) != 0: + rv.append( + "minidump-stackwalk exited with return code %d" + % data["stackwalk_returncode"] + ) + + if data.get("stackwalk_errors"): + rv.extend(data.get("stackwalk_errors")) + + rv = "\n".join(rv) + if not rv[-1] == "\n": + rv += "\n" + + return rv + + def suite_start(self, data): + self.suite_start_time = data["time"] + num_tests = reduce(lambda x, y: x + len(y), six.itervalues(data["tests"]), 0) + return "SUITE-START | Running %i tests\n" % num_tests + + def test_start(self, data): + self.test_start_times[self.test_id(data["test"])] = data["time"] + + return "TEST-START | %s\n" % data["test"] + + def test_status(self, data): + if self.compact: + if "expected" in data: + rv = [] + rv.append( + self._format_subtests(data.get("component"), subtract_context=True) + ) + rv.extend(self._format_status(item) for item in self.buffer) + rv.append(self._format_status(data)) + self.buffer.clear() + return "".join(rv) + else: + self.subtests_count += 1 + self.buffer.append(data) + else: + return self._format_status(data) + + def assertion_count(self, data): + if data["min_expected"] != data["max_expected"]: + expected = "%i to %i" % (data["min_expected"], data["max_expected"]) + else: + expected = "%i" % data["min_expected"] + + if data["count"] < data["min_expected"]: + status, comparison = "TEST-UNEXPECTED-PASS", "is less than" + elif data["count"] > data["max_expected"]: + status, comparison = "TEST-UNEXPECTED-FAIL", "is more than" + elif data["count"] > 0: + status, comparison = "TEST-KNOWN-FAIL", "matches" + else: + return + + return "%s | %s | assertion count %i %s expected %s assertions\n" % ( + status, + data["test"], + data["count"], + comparison, + expected, + ) + + def _format_status(self, data): + message = "- " + data["message"] if "message" in data else "" + if "stack" in data: + message += "\n%s" % data["stack"] + if message and message[-1] == "\n": + message = message[:-1] + + status = data["status"] + + if "expected" in data: + if status in data.get("known_intermittent", []): + status = "KNOWN-INTERMITTENT-%s" % status + else: + if not message: + message = "- expected %s" % data["expected"] + failure_line = "TEST-UNEXPECTED-%s | %s | %s %s\n" % ( + status, + data["test"], + data["subtest"], + message, + ) + if data["expected"] != "PASS": + info_line = "TEST-INFO | expected %s\n" % data["expected"] + return failure_line + info_line + return failure_line + + return "TEST-%s | %s | %s %s\n" % ( + status, + data["test"], + data["subtest"], + message, + ) + + def test_end(self, data): + rv = [] + if self.compact and self.subtests_count: + print_context = "expected" in data + rv.append( + self._format_subtests( + data.get("component"), subtract_context=print_context + ) + ) + if print_context: + rv.extend(self._format_status(item) for item in self.buffer) + self.buffer.clear() + + test_id = self.test_id(data["test"]) + duration_msg = "" + + if test_id in self.test_start_times: + start_time = self.test_start_times.pop(test_id) + time = data["time"] - start_time + duration_msg = "took %ims" % time + + screenshot_msg = "" + extra = data.get("extra", {}) + if "reftest_screenshots" in extra: + screenshots = extra["reftest_screenshots"] + if len(screenshots) == 3: + screenshot_msg = ( + "\nREFTEST IMAGE 1 (TEST): data:image/png;base64,%s\n" + "REFTEST IMAGE 2 (REFERENCE): data:image/png;base64,%s" + ) % (screenshots[0]["screenshot"], screenshots[2]["screenshot"]) + elif len(screenshots) == 1: + screenshot_msg = ( + "\nREFTEST IMAGE: data:image/png;base64,%s" + % screenshots[0]["screenshot"] + ) + + status = data["status"] + + if "expected" in data: + if status in data.get("known_intermittent", []): + status = "KNOWN-INTERMITTENT-%s" % status + else: + message = data.get("message", "") + if not message: + message = "expected %s" % data["expected"] + if "stack" in data: + message += "\n%s" % data["stack"] + if message and message[-1] == "\n": + message = message[:-1] + + message += screenshot_msg + + failure_line = "TEST-UNEXPECTED-%s | %s | %s\n" % ( + data["status"], + test_id, + message, + ) + + if data["expected"] not in ("PASS", "OK"): + expected_msg = "expected %s | " % data["expected"] + else: + expected_msg = "" + info_line = "TEST-INFO %s%s\n" % (expected_msg, duration_msg) + + return failure_line + info_line + + sections = ["TEST-%s" % status, test_id] + if duration_msg: + sections.append(duration_msg) + rv.append(" | ".join(sections) + "\n") + if screenshot_msg: + rv.append(screenshot_msg[1:] + "\n") + return "".join(rv) + + def suite_end(self, data): + start_time = self.suite_start_time + # pylint --py3k W1619 + # in wpt --repeat mode sometimes we miss suite_start() + if start_time is None: + start_time = data["time"] + time = int((data["time"] - start_time) / 1000) + + return "SUITE-END | took %is\n" % time + + def test_id(self, test_id): + if isinstance(test_id, (str, six.text_type)): + return test_id + else: + return tuple(test_id) + + @output_subtests + def valgrind_error(self, data): + rv = "TEST-UNEXPECTED-VALGRIND-ERROR | " + data["primary"] + "\n" + for line in data["secondary"]: + rv = rv + line + "\n" + + return rv + + def lint(self, data): + fmt = "TEST-UNEXPECTED-{level} | {path}:{lineno}{column} | {message} ({rule})" + data["column"] = ":%s" % data["column"] if data["column"] else "" + data["rule"] = data["rule"] or data["linter"] or "" + return fmt.append(fmt.format(**data)) + + def lsan_leak(self, data): + frames = data.get("frames") + allowed_match = data.get("allowed_match") + frame_list = ", ".join(frames) + prefix = "TEST-UNEXPECTED-FAIL" if not allowed_match else "TEST-FAIL" + suffix = ( + "" + if not allowed_match + else "INFO | LeakSanitizer | Frame %s matched a expected leak\n" + % allowed_match + ) + return "%s | LeakSanitizer | leak at %s\n%s" % (prefix, frame_list, suffix) + + def lsan_summary(self, data): + level = "INFO" if data.get("allowed", False) else "ERROR" + return ( + "%s | LeakSanitizer | " + "SUMMARY: AddressSanitizer: %d byte(s) leaked in %d allocation(s)." + % (level, data["bytes"], data["allocations"]) + ) + + def mozleak_object(self, data): + return "TEST-INFO | leakcheck | %s leaked %d %s\n" % ( + data["process"], + data["bytes"], + data["name"], + ) + + def mozleak_total(self, data): + if data["bytes"] is None: + # We didn't see a line with name 'TOTAL' + if data.get("induced_crash", False): + return ( + "TEST-INFO | leakcheck | %s deliberate crash and thus no leak log\n" + % data["process"] + ) + if data.get("ignore_missing", False): + return ( + "TEST-INFO | leakcheck | " + "%s ignoring missing output line for total leaks\n" + % data["process"] + ) + + return ( + "TEST-UNEXPECTED-FAIL | leakcheck | " + "%s missing output line for total leaks!\n" % data["process"] + ) + + if data["bytes"] == 0: + return "TEST-PASS | leakcheck | %s no leaks detected!\n" % data["process"] + + message = "" + bigLeakers = [ + "nsGlobalWindowInner", + "nsGlobalWindowOuter", + "Document", + "nsDocShell", + "BrowsingContext", + "BackstagePass", + ] + for bigLeakName in bigLeakers: + if bigLeakName in data["objects"]: + message = "leakcheck large %s | %s" % (bigLeakName, data["scope"]) + break + + # Create a comma delimited string of the first N leaked objects found, + # to aid with bug summary matching in TBPL. Note: The order of the objects + # had no significance (they're sorted alphabetically). + if message == "": + max_objects = 5 + object_summary = ", ".join(data["objects"][:max_objects]) + if len(data["objects"]) > max_objects: + object_summary += ", ..." + + message = "leakcheck | %s %d bytes leaked (%s)\n" % ( + data["process"], + data["bytes"], + object_summary, + ) + + # data["bytes"] will include any expected leaks, so it can be off + # by a few thousand bytes. + if data["bytes"] > data["threshold"]: + return "TEST-UNEXPECTED-FAIL | %s\n" % message + else: + return "WARNING | %s\n" % message + + def _format_suite_summary(self, suite, summary): + counts = summary["counts"] + logs = summary["unexpected_logs"] + intermittent_logs = summary["intermittent_logs"] + + total = sum(self.summary.aggregate("count", counts).values()) + expected = sum(self.summary.aggregate("expected", counts).values()) + intermittents = sum( + self.summary.aggregate("known_intermittent", counts).values() + ) + known = ( + " ({} known intermittent tests)".format(intermittents) + if intermittents + else "" + ) + status_str = "{}/{}{}".format(expected, total, known) + rv = ["{}: {}".format(suite, status_str)] + + for results in logs.values(): + for data in results: + rv.append(" {}".format(self._format_status(data))) + + if intermittent_logs: + rv.append("Known Intermittent tests:") + for results in intermittent_logs.values(): + for data in results: + data["subtest"] = data.get("subtest", "") + rv.append(" {}".format(self._format_status(data))) + + return "\n".join(rv) + + def shutdown(self, data): + if not self.summary_on_shutdown: + return + + rv = ["", "Overall Summary"] + for suite, summary in self.summary: + rv.append(self._format_suite_summary(suite, summary)) + rv.append("") + return "\n".join(rv) diff --git a/testing/mozbase/mozlog/mozlog/formatters/unittest.py b/testing/mozbase/mozlog/mozlog/formatters/unittest.py new file mode 100755 index 0000000000..3eba864d85 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/formatters/unittest.py @@ -0,0 +1,83 @@ +#!/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 . import base + + +class UnittestFormatter(base.BaseFormatter): + """Formatter designed to produce output in a format like that used by + the ``unittest`` module in the standard library.""" + + def __init__(self): + self.fails = [] + self.errors = [] + self.tests_run = 0 + self.start_time = None + self.end_time = None + + def suite_start(self, data): + self.start_time = data["time"] + + def test_start(self, data): + self.tests_run += 1 + + def test_end(self, data): + char = "." + if "expected" in data: + status = data["status"] + char = {"FAIL": "F", "PRECONDITION_FAILED": "F", "ERROR": "E", "PASS": "X"}[ + status + ] + + if status == "FAIL" or status == "PRECONDITION_FAILED": + self.fails.append(data) + elif status == "ERROR": + self.errors.append(data) + + elif data["status"] == "SKIP": + char = "S" + return char + + def assertion_count(self, data): + if data["count"] < data["min_expected"]: + char = "X" + elif data["count"] > data["max_expected"]: + char = "F" + self.fails.append( + { + "test": data["test"], + "message": ( + "assertion count %i is greated than %i" + % (data["count"], data["max_expected"]) + ), + } + ) + elif data["count"] > 0: + char = "." + else: + char = "." + + return char + + def suite_end(self, data): + self.end_time = data["time"] + summary = "\n".join( + [self.output_fails(), self.output_errors(), self.output_summary()] + ) + return "\n%s\n" % summary + + def output_fails(self): + return "\n".join("FAIL %(test)s\n%(message)s\n" % data for data in self.fails) + + def output_errors(self): + return "\n".join("ERROR %(test)s\n%(message)s" % data for data in self.errors) + + def output_summary(self): + # pylint --py3k W1619 + return "Ran %i tests in %.1fs" % ( + self.tests_run, + (self.end_time - self.start_time) / 1000, + ) diff --git a/testing/mozbase/mozlog/mozlog/formatters/xunit.py b/testing/mozbase/mozlog/mozlog/formatters/xunit.py new file mode 100644 index 0000000000..02966d713a --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/formatters/xunit.py @@ -0,0 +1,115 @@ +# 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 xml.etree import ElementTree + +import six + +from . import base + + +def format_test_id(test_id): + """Take a test id and return something that looks a bit like + a class path""" + if not isinstance(test_id, six.string_types): + # Not sure how to deal with reftests yet + raise NotImplementedError + + # Turn a path into something like a class heirachy + return test_id.replace(".", "_").replace("/", ".") + + +class XUnitFormatter(base.BaseFormatter): + """Formatter that produces XUnit-style XML output. + + The tree is created in-memory so this formatter may be problematic + with very large log files. + + Note that the data model isn't a perfect match. In + particular XUnit assumes that each test has a unittest-style + class name and function name, which isn't the case for us. The + implementation currently replaces path names with something that + looks like class names, but this doesn't work for test types that + actually produce class names, or for test types that have multiple + components in their test id (e.g. reftests).""" + + def __init__(self): + self.tree = ElementTree.ElementTree() + self.root = None + self.suite_start_time = None + self.test_start_time = None + + self.tests_run = 0 + self.errors = 0 + self.failures = 0 + self.skips = 0 + + def suite_start(self, data): + self.root = ElementTree.Element("testsuite") + self.tree.root = self.root + self.suite_start_time = data["time"] + + def test_start(self, data): + self.tests_run += 1 + self.test_start_time = data["time"] + + def _create_result(self, data): + test = ElementTree.SubElement(self.root, "testcase") + name = format_test_id(data["test"]) + extra = data.get("extra") or {} + test.attrib["classname"] = extra.get("class_name") or name + + if "subtest" in data: + test.attrib["name"] = data["subtest"] + # We generally don't know how long subtests take + test.attrib["time"] = "0" + else: + if "." in name: + test_name = name.rsplit(".", 1)[1] + else: + test_name = name + test.attrib["name"] = extra.get("method_name") or test_name + test.attrib["time"] = "%.2f" % ( + (data["time"] - self.test_start_time) / 1000.0 + ) + + if "expected" in data and data["expected"] != data["status"]: + if data["status"] in ("NOTRUN", "ASSERT", "ERROR"): + result = ElementTree.SubElement(test, "error") + self.errors += 1 + else: + result = ElementTree.SubElement(test, "failure") + self.failures += 1 + + result.attrib["message"] = "Expected %s, got %s" % ( + data["expected"], + data["status"], + ) + result.text = "%s\n%s" % (data.get("stack", ""), data.get("message", "")) + + elif data["status"] == "SKIP": + result = ElementTree.SubElement(test, "skipped") + self.skips += 1 + + def test_status(self, data): + self._create_result(data) + + def test_end(self, data): + self._create_result(data) + + def suite_end(self, data): + self.root.attrib.update( + { + "tests": str(self.tests_run), + "errors": str(self.errors), + "failures": str(self.failures), + "skips": str(self.skips), + "time": "%.2f" % ((data["time"] - self.suite_start_time) / 1000.0), + } + ) + xml_string = ElementTree.tostring(self.root, encoding="utf8") + # pretty printing can not be done from xml.etree + from xml.dom import minidom + + return minidom.parseString(xml_string).toprettyxml() diff --git a/testing/mozbase/mozlog/mozlog/handlers/__init__.py b/testing/mozbase/mozlog/mozlog/handlers/__init__.py new file mode 100644 index 0000000000..e14bdf675f --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/handlers/__init__.py @@ -0,0 +1,19 @@ +# 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 .base import BaseHandler, LogLevelFilter, StreamHandler +from .bufferhandler import BufferHandler +from .statushandler import StatusHandler +from .summaryhandler import SummaryHandler +from .valgrindhandler import ValgrindHandler + +__all__ = [ + "LogLevelFilter", + "StreamHandler", + "BaseHandler", + "StatusHandler", + "SummaryHandler", + "BufferHandler", + "ValgrindHandler", +] diff --git a/testing/mozbase/mozlog/mozlog/handlers/base.py b/testing/mozbase/mozlog/mozlog/handlers/base.py new file mode 100644 index 0000000000..064b1dee0b --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/handlers/base.py @@ -0,0 +1,124 @@ +# 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/. + + +import codecs +import locale +from threading import Lock + +import six + +from mozlog.handlers.messagehandler import MessageHandler +from mozlog.structuredlog import log_levels + + +class BaseHandler(object): + """A base handler providing message handling facilities to + derived classes. + """ + + def __init__(self, inner): + self.message_handler = MessageHandler() + if hasattr(inner, "message_handler"): + self.message_handler.wrapped.append(inner) + + +class LogLevelFilter(BaseHandler): + """Handler that filters out messages with action of log and a level + lower than some specified level. + + :param inner: Handler to use for messages that pass this filter + :param level: Minimum log level to process + """ + + def __init__(self, inner, level): + BaseHandler.__init__(self, inner) + self.inner = inner + self.level = log_levels[level.upper()] + + def __call__(self, item): + if item["action"] != "log" or log_levels[item["level"].upper()] <= self.level: + return self.inner(item) + + +class StreamHandler(BaseHandler): + """Handler for writing to a file-like object + + :param stream: File-like object to write log messages to + :param formatter: formatter to convert messages to string format + """ + + _lock = Lock() + + def __init__(self, stream, formatter): + BaseHandler.__init__(self, formatter) + assert stream is not None + if six.PY2: + # This is a hack to deal with the case where we are passed a + # StreamWriter (e.g. by mach for stdout). A StreamWriter requires + # the code to handle unicode in exactly the opposite way compared + # to a normal stream i.e. you always have to pass in a Unicode + # object rather than a string object. Cope with that by extracting + # the underlying raw stream. + if isinstance(stream, codecs.StreamWriter): + stream = stream.stream + + self.formatter = formatter + self.stream = stream + + def __call__(self, data): + """Write a log message. + + :param data: Structured log message dictionary.""" + formatted = self.formatter(data) + if not formatted: + return + with self._lock: + if six.PY3: + import io + + import mozfile + + source_enc = "utf-8" + target_enc = "utf-8" + if isinstance(self.stream, io.BytesIO): + target_enc = None + if hasattr(self.stream, "encoding"): + target_enc = self.stream.encoding + if target_enc is None: + target_enc = locale.getpreferredencoding() + + if isinstance(self.stream, io.StringIO) and isinstance( + formatted, bytes + ): + formatted = formatted.decode(source_enc, "replace") + elif ( + isinstance(self.stream, io.BytesIO) + or isinstance(self.stream, mozfile.NamedTemporaryFile) + ) and isinstance(formatted, str): + formatted = formatted.encode(target_enc, "replace") + elif isinstance(formatted, str): + # Suppress eventual surrogates, but keep as string. + # TODO: It is yet unclear how we can end up with + # surrogates here, see comment on patch on bug 1794401. + formatted_bin = formatted.encode(target_enc, "replace") + formatted = formatted_bin.decode(target_enc, "ignore") + + # It seems that under Windows we can have cp1252 encoding + # for the output stream and that not all unicode chars map + # well. We just ignore those errors here (they have no + # consequences for the executed tests, anyways). + try: + self.stream.write(formatted) + except UnicodeEncodeError: + return + else: + if isinstance(formatted, six.text_type): + self.stream.write(formatted.encode("utf-8", "replace")) + elif isinstance(formatted, str): + self.stream.write(formatted) + else: + assert False, "Got output from the formatter of an unexpected type" + + self.stream.flush() diff --git a/testing/mozbase/mozlog/mozlog/handlers/bufferhandler.py b/testing/mozbase/mozlog/mozlog/handlers/bufferhandler.py new file mode 100644 index 0000000000..5fcdcf967a --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/handlers/bufferhandler.py @@ -0,0 +1,86 @@ +# 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 .base import BaseHandler + + +class BufferHandler(BaseHandler): + """Handler that maintains a circular buffer of messages based on the + size and actions specified by a user. + + :param inner: The underlying handler used to emit messages. + :param message_limit: The maximum number of messages to retain for + context. If None, the buffer will grow without limit. + :param buffered_actions: The set of actions to include in the buffer + rather than log directly. + """ + + def __init__(self, inner, message_limit=100, buffered_actions=None): + BaseHandler.__init__(self, inner) + self.inner = inner + self.message_limit = message_limit + if buffered_actions is None: + buffered_actions = ["log", "test_status"] + self.buffered_actions = set(buffered_actions) + self._buffering = True + + if self.message_limit is not None: + self._buffer = [None] * self.message_limit + self._buffer_pos = 0 + else: + self._buffer = [] + + self.message_handler.register_message_handlers( + "buffer", + { + "on": self._enable_buffering, + "off": self._disable_buffering, + "flush": self._flush_buffered, + "clear": self._clear_buffer, + }, + ) + + def __call__(self, data): + action = data["action"] + if "bypass_mozlog_buffer" in data: + data.pop("bypass_mozlog_buffer") + self.inner(data) + return + if not self._buffering or action not in self.buffered_actions: + self.inner(data) + return + + self._add_message(data) + + def _add_message(self, data): + if self.message_limit is None: + self._buffer.append(data) + else: + self._buffer[self._buffer_pos] = data + self._buffer_pos = (self._buffer_pos + 1) % self.message_limit + + def _enable_buffering(self): + self._buffering = True + + def _disable_buffering(self): + self._buffering = False + + def _clear_buffer(self): + """Clear the buffer of unwanted messages.""" + current_size = len([m for m in self._buffer if m is not None]) + if self.message_limit is not None: + self._buffer = [None] * self.message_limit + else: + self._buffer = [] + return current_size + + def _flush_buffered(self): + """Logs the contents of the current buffer""" + for msg in self._buffer[self._buffer_pos :]: + if msg is not None: + self.inner(msg) + for msg in self._buffer[: self._buffer_pos]: + if msg is not None: + self.inner(msg) + return self._clear_buffer() diff --git a/testing/mozbase/mozlog/mozlog/handlers/messagehandler.py b/testing/mozbase/mozlog/mozlog/handlers/messagehandler.py new file mode 100644 index 0000000000..606dd83926 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/handlers/messagehandler.py @@ -0,0 +1,39 @@ +# 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/. + + +class MessageHandler(object): + """A message handler providing message handling facilities to + classes derived from BaseHandler and BaseFormatter. This is a + composition class, to ensure handlers and formatters remain separate. + + :param inner: A handler-like callable that may receive messages + from a log user. + """ + + def __init__(self): + self.message_handlers = {} + self.wrapped = [] + + def register_message_handlers(self, topic, handlers): + self.message_handlers[topic] = handlers + + def handle_message(self, topic, cmd, *args): + """Handles a message for the given topic by calling a subclass-defined + callback for the command. + + :param topic: The topic of the broadcasted message. Handlers opt-in to + receiving messages by identifying a topic when calling + register_message_handlers. + :param command: The command to issue. This is a string that corresponds + to a callback provided by the target. + :param arg: Arguments to pass to the identified message callback, if any. + """ + rv = [] + if topic in self.message_handlers and cmd in self.message_handlers[topic]: + rv.append(self.message_handlers[topic][cmd](*args)) + if self.wrapped: + for inner in self.wrapped: + rv.extend(inner.handle_message(topic, cmd, *args)) + return rv diff --git a/testing/mozbase/mozlog/mozlog/handlers/statushandler.py b/testing/mozbase/mozlog/mozlog/handlers/statushandler.py new file mode 100644 index 0000000000..500f2e1d89 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/handlers/statushandler.py @@ -0,0 +1,87 @@ +# 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 collections import defaultdict, namedtuple + +RunSummary = namedtuple( + "RunSummary", + ( + "unexpected_statuses", + "expected_statuses", + "known_intermittent_statuses", + "log_level_counts", + "action_counts", + ), +) + + +class StatusHandler(object): + """A handler used to determine an overall status for a test run according + to a sequence of log messages.""" + + def __init__(self): + # The count of each type of unexpected result status (includes tests and subtests) + self.unexpected_statuses = defaultdict(int) + # The count of each type of expected result status (includes tests and subtests) + self.expected_statuses = defaultdict(int) + # The count of known intermittent result statuses (includes tests and subtests) + self.known_intermittent_statuses = defaultdict(int) + # The count of actions logged + self.action_counts = defaultdict(int) + # The count of messages logged at each log level + self.log_level_counts = defaultdict(int) + # The count of "No tests run" error messages seen + self.no_tests_run_count = 0 + + def __call__(self, data): + action = data["action"] + known_intermittent = data.get("known_intermittent", []) + self.action_counts[action] += 1 + + if action == "log": + if data["level"] == "ERROR" and data["message"] == "No tests ran": + self.no_tests_run_count += 1 + self.log_level_counts[data["level"]] += 1 + + if action in ("test_status", "test_end"): + status = data["status"] + # Don't count known_intermittent status as unexpected + if "expected" in data and status not in known_intermittent: + self.unexpected_statuses[status] += 1 + else: + self.expected_statuses[status] += 1 + # Count known_intermittent as expected and intermittent. + if status in known_intermittent: + self.known_intermittent_statuses[status] += 1 + + if action == "assertion_count": + if data["count"] < data["min_expected"]: + self.unexpected_statuses["PASS"] += 1 + elif data["count"] > data["max_expected"]: + self.unexpected_statuses["FAIL"] += 1 + elif data["count"]: + self.expected_statuses["FAIL"] += 1 + else: + self.expected_statuses["PASS"] += 1 + + if action == "lsan_leak": + if not data.get("allowed_match"): + self.unexpected_statuses["FAIL"] += 1 + + if action == "lsan_summary": + if not data.get("allowed", False): + self.unexpected_statuses["FAIL"] += 1 + + if action == "mozleak_total": + if data["bytes"] is not None and data["bytes"] > data.get("threshold", 0): + self.unexpected_statuses["FAIL"] += 1 + + def summarize(self): + return RunSummary( + dict(self.unexpected_statuses), + dict(self.expected_statuses), + dict(self.known_intermittent_statuses), + dict(self.log_level_counts), + dict(self.action_counts), + ) diff --git a/testing/mozbase/mozlog/mozlog/handlers/summaryhandler.py b/testing/mozbase/mozlog/mozlog/handlers/summaryhandler.py new file mode 100644 index 0000000000..347e52abdb --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/handlers/summaryhandler.py @@ -0,0 +1,193 @@ +# 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 collections import OrderedDict, defaultdict + +import six + +from ..reader import LogHandler + + +class SummaryHandler(LogHandler): + """Handler class for storing suite summary information. + + Can handle multiple suites in a single run. Summary + information is stored on the self.summary instance variable. + + Per suite summary information can be obtained by calling 'get' + or iterating over this class. + """ + + def __init__(self, **kwargs): + super(SummaryHandler, self).__init__(**kwargs) + + self.summary = OrderedDict() + self.current_suite = None + + @property + def current(self): + return self.summary.get(self.current_suite) + + def __getitem__(self, suite): + """Return summary information for the given suite. + + The summary is of the form: + + { + 'counts': { + '<check>': { + 'count': int, + 'expected': { + '<status>': int, + }, + 'unexpected': { + '<status>': int, + }, + 'known_intermittent': { + '<status>': int, + }, + }, + }, + 'unexpected_logs': { + '<test>': [<data>] + }, + 'intermittent_logs': { + '<test>': [<data>] + } + } + + Valid values for <check> are `test`, `subtest` and `assert`. Valid + <status> keys are defined in the :py:mod:`mozlog.logtypes` module. The + <test> key is the id as logged by `test_start`. Finally the <data> + field is the log data from any `test_end` or `test_status` log messages + that have an unexpected result. + + Mozlog's structuredlog has a `known_intermittent` field indicating if a + `test` and `subtest` <status> are expected to arise intermittently. + Known intermittent results are logged as both as `expected` and + `known_intermittent`. + """ + return self.summary[suite] + + def __iter__(self): + """Iterate over summaries. + + Yields a tuple of (suite, summary). The summary returned is + the same format as returned by 'get'. + """ + for suite, data in six.iteritems(self.summary): + yield suite, data + + @classmethod + def aggregate(cls, key, counts, include_skip=True): + """Helper method for aggregating count data by 'key' instead of by 'check'.""" + assert key in ("count", "expected", "unexpected", "known_intermittent") + + res = defaultdict(int) + for check, val in counts.items(): + if key == "count": + res[check] += val[key] + continue + + for status, num in val[key].items(): + if not include_skip and status == "skip": + continue + res[check] += num + return res + + def suite_start(self, data): + self.current_suite = data.get("name", "suite {}".format(len(self.summary) + 1)) + if self.current_suite not in self.summary: + self.summary[self.current_suite] = { + "counts": { + "test": { + "count": 0, + "expected": defaultdict(int), + "unexpected": defaultdict(int), + "known_intermittent": defaultdict(int), + }, + "subtest": { + "count": 0, + "expected": defaultdict(int), + "unexpected": defaultdict(int), + "known_intermittent": defaultdict(int), + }, + "assert": { + "count": 0, + "expected": defaultdict(int), + "unexpected": defaultdict(int), + "known_intermittent": defaultdict(int), + }, + }, + "unexpected_logs": OrderedDict(), + "intermittent_logs": OrderedDict(), + "harness_errors": [], + } + + def test_start(self, data): + self.current["counts"]["test"]["count"] += 1 + + def test_status(self, data): + logs = self.current["unexpected_logs"] + intermittent_logs = self.current["intermittent_logs"] + count = self.current["counts"] + count["subtest"]["count"] += 1 + + if "expected" in data: + if data["status"] not in data.get("known_intermittent", []): + count["subtest"]["unexpected"][data["status"].lower()] += 1 + if data["test"] not in logs: + logs[data["test"]] = [] + logs[data["test"]].append(data) + else: + count["subtest"]["expected"][data["status"].lower()] += 1 + count["subtest"]["known_intermittent"][data["status"].lower()] += 1 + if data["test"] not in intermittent_logs: + intermittent_logs[data["test"]] = [] + intermittent_logs[data["test"]].append(data) + else: + count["subtest"]["expected"][data["status"].lower()] += 1 + + def test_end(self, data): + logs = self.current["unexpected_logs"] + intermittent_logs = self.current["intermittent_logs"] + count = self.current["counts"] + if "expected" in data: + if data["status"] not in data.get("known_intermittent", []): + count["test"]["unexpected"][data["status"].lower()] += 1 + if data["test"] not in logs: + logs[data["test"]] = [] + logs[data["test"]].append(data) + else: + count["test"]["expected"][data["status"].lower()] += 1 + count["test"]["known_intermittent"][data["status"].lower()] += 1 + if data["test"] not in intermittent_logs: + intermittent_logs[data["test"]] = [] + intermittent_logs[data["test"]].append(data) + else: + count["test"]["expected"][data["status"].lower()] += 1 + + def assertion_count(self, data): + count = self.current["counts"] + count["assert"]["count"] += 1 + + if data["min_expected"] <= data["count"] <= data["max_expected"]: + if data["count"] > 0: + count["assert"]["expected"]["fail"] += 1 + else: + count["assert"]["expected"]["pass"] += 1 + elif data["max_expected"] < data["count"]: + count["assert"]["unexpected"]["fail"] += 1 + else: + count["assert"]["unexpected"]["pass"] += 1 + + def log(self, data): + if not self.current_suite: + return + + logs = self.current["harness_errors"] + level = data.get("level").upper() + + if level in ("CRITICAL", "ERROR"): + logs.append(data) diff --git a/testing/mozbase/mozlog/mozlog/handlers/valgrindhandler.py b/testing/mozbase/mozlog/mozlog/handlers/valgrindhandler.py new file mode 100644 index 0000000000..0f9b06e8f7 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/handlers/valgrindhandler.py @@ -0,0 +1,138 @@ +# 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/. + +import re + +from .base import BaseHandler + + +class ValgrindHandler(BaseHandler): + def __init__(self, inner): + BaseHandler.__init__(self, inner) + self.inner = inner + self.vFilter = ValgrindFilter() + + def __call__(self, data): + tmp = self.vFilter(data) + if tmp is not None: + self.inner(tmp) + + +class ValgrindFilter(object): + """ + A class for handling Valgrind output. + + Valgrind errors look like this: + + ==60741== 40 (24 direct, 16 indirect) bytes in 1 blocks are definitely lost in loss + record 2,746 of 5,235 + ==60741== at 0x4C26B43: calloc (vg_replace_malloc.c:593) + ==60741== by 0x63AEF65: PR_Calloc (prmem.c:443) + ==60741== by 0x69F236E: PORT_ZAlloc_Util (secport.c:117) + ==60741== by 0x69F1336: SECITEM_AllocItem_Util (secitem.c:28) + ==60741== by 0xA04280B: ffi_call_unix64 (in /builds/slave/m-in-l64-valgrind-000000000000/objdir/toolkit/library/libxul.so) # noqa + ==60741== by 0xA042443: ffi_call (ffi64.c:485) + + For each such error, this class extracts most or all of the first (error + kind) line, plus the function name in each of the first few stack entries. + With this data it constructs and prints a TEST-UNEXPECTED-FAIL message that + TBPL will highlight. + + It buffers these lines from which text is extracted so that the + TEST-UNEXPECTED-FAIL message can be printed before the full error. + + Parsing the Valgrind output isn't ideal, and it may break in the future if + Valgrind changes the format of the messages, or introduces new error kinds. + To protect against this, we also count how many lines containing + "<insert_a_suppression_name_here>" are seen. Thanks to the use of + --gen-suppressions=yes, exactly one of these lines is present per error. If + the count of these lines doesn't match the error count found during + parsing, then the parsing has missed one or more errors and we can fail + appropriately. + """ + + def __init__(self): + # The regexps in this list match all of Valgrind's errors. Note that + # Valgrind is English-only, so we don't have to worry about + # localization. + self.re_error = re.compile( + r"==\d+== (" + + r"(Use of uninitialised value of size \d+)|" + + r"(Conditional jump or move depends on uninitialised value\(s\))|" + + r"(Syscall param .* contains uninitialised byte\(s\))|" + + r"(Syscall param .* points to (unaddressable|uninitialised) byte\(s\))|" + + r"((Unaddressable|Uninitialised) byte\(s\) found during client check request)|" + + r"(Invalid free\(\) / delete / delete\[\] / realloc\(\))|" + + r"(Mismatched free\(\) / delete / delete \[\])|" + + r"(Invalid (read|write) of size \d+)|" + + r"(Jump to the invalid address stated on the next line)|" + + r"(Source and destination overlap in .*)|" + + r"(.* bytes in .* blocks are .* lost)" + + r")" + ) + # Match identifer chars, plus ':' for namespaces, and '\?' in order to + # match "???" which Valgrind sometimes produces. + self.re_stack_entry = re.compile(r"^==\d+==.*0x[A-Z0-9]+: ([A-Za-z0-9_:\?]+)") + self.re_suppression = re.compile(r" *<insert_a_suppression_name_here>") + self.error_count = 0 + self.suppression_count = 0 + self.number_of_stack_entries_to_get = 0 + self.curr_failure_msg = "" + self.buffered_lines = [] + + # Takes a message and returns a message + def __call__(self, msg): + # Pass through everything that isn't plain text + if msg["action"] != "log": + return msg + + line = msg["message"] + output_message = None + if self.number_of_stack_entries_to_get == 0: + # Look for the start of a Valgrind error. + m = re.search(self.re_error, line) + if m: + self.error_count += 1 + self.number_of_stack_entries_to_get = 4 + self.curr_failure_msg = m.group(1) + " at " + self.buffered_lines = [line] + else: + output_message = msg + + else: + # We've recently found a Valgrind error, and are now extracting + # details from the first few stack entries. + self.buffered_lines.append(line) + m = re.match(self.re_stack_entry, line) + if m: + self.curr_failure_msg += m.group(1) + else: + self.curr_failure_msg += "?!?" + + self.number_of_stack_entries_to_get -= 1 + if self.number_of_stack_entries_to_get != 0: + self.curr_failure_msg += " / " + else: + # We've finished getting the first few stack entries. Emit + # the failure action, comprising the primary message and the + # buffered lines, and then reset state. Copy the mandatory + # fields from the incoming message, since there's nowhere + # else to get them from. + output_message = { # Mandatory fields + "action": "valgrind_error", + "time": msg["time"], + "thread": msg["thread"], + "pid": msg["pid"], + "source": msg["source"], + # valgrind_error specific fields + "primary": self.curr_failure_msg, + "secondary": self.buffered_lines, + } + self.curr_failure_msg = "" + self.buffered_lines = [] + + if re.match(self.re_suppression, line): + self.suppression_count += 1 + + return output_message diff --git a/testing/mozbase/mozlog/mozlog/logtypes.py b/testing/mozbase/mozlog/mozlog/logtypes.py new file mode 100644 index 0000000000..00ff414d1c --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/logtypes.py @@ -0,0 +1,302 @@ +# 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/. + +import inspect + +import six +from six.moves import range, zip + +convertor_registry = {} +missing = object() +no_default = object() + + +class log_action(object): + def __init__(self, *args): + self.args = {} + + self.args_no_default = [] + self.args_with_default = [] + self.optional_args = set() + + # These are the required fields in a log message that usually aren't + # supplied by the caller, but can be in the case of log_raw + self.default_args = [ + Unicode("action"), + Int("time"), + Unicode("thread"), + Int("pid", default=None), + Unicode("source"), + Unicode("component"), + ] + + for arg in args: + if arg.default is no_default: + self.args_no_default.append(arg.name) + else: + self.args_with_default.append(arg.name) + + if arg.optional: + self.optional_args.add(arg.name) + + if arg.name in self.args: + raise ValueError("Repeated argument name %s" % arg.name) + + self.args[arg.name] = arg + + for extra in self.default_args: + self.args[extra.name] = extra + + def __call__(self, f): + convertor_registry[f.__name__] = self + converter = self + + def inner(self, *args, **kwargs): + data = converter.convert(*args, **kwargs) + return f(self, data) + + if hasattr(f, "__doc__"): + setattr(inner, "__doc__", f.__doc__) + + return inner + + def convert(self, *args, **kwargs): + data = {} + values = {} + values.update(kwargs) + + positional_no_default = [ + item for item in self.args_no_default if item not in values + ] + + num_no_default = len(positional_no_default) + + if len(args) < num_no_default: + raise TypeError("Too few arguments") + + if len(args) > num_no_default + len(self.args_with_default): + raise TypeError("Too many arguments") + + for i, name in enumerate(positional_no_default): + values[name] = args[i] + + positional_with_default = [ + self.args_with_default[i] for i in range(len(args) - num_no_default) + ] + + for i, name in enumerate(positional_with_default): + if name in values: + raise TypeError("Argument %s specified twice" % name) + values[name] = args[i + num_no_default] + + # Fill in missing arguments + for name in self.args_with_default: + if name not in values: + values[name] = self.args[name].default + + for key, value in six.iteritems(values): + if key in self.args: + out_value = self.args[key](value) + if out_value is not missing: + if key in self.optional_args and value == self.args[key].default: + pass + else: + data[key] = out_value + else: + raise TypeError("Unrecognised argument %s" % key) + + return data + + def convert_known(self, **kwargs): + known_kwargs = { + name: value for name, value in six.iteritems(kwargs) if name in self.args + } + return self.convert(**known_kwargs) + + +class DataType(object): + def __init__(self, name, default=no_default, optional=False): + self.name = name + self.default = default + + if default is no_default and optional is not False: + raise ValueError("optional arguments require a default value") + + self.optional = optional + + def __call__(self, value): + if value == self.default: + if self.optional: + return missing + return self.default + + try: + return self.convert(value) + except Exception: + raise ValueError( + "Failed to convert value %s of type %s for field %s to type %s" + % (value, type(value).__name__, self.name, self.__class__.__name__) + ) + + +class ContainerType(DataType): + """A DataType that contains other DataTypes. + + ContainerTypes must specify which other DataType they will contain. ContainerTypes + may contain other ContainerTypes. + + Some examples: + + List(Int, 'numbers') + Tuple((Unicode, Int, Any), 'things') + Dict(Unicode, 'config') + Dict({TestId: Status}, 'results') + Dict(List(Unicode), 'stuff') + """ + + def __init__(self, item_type, name=None, **kwargs): + DataType.__init__(self, name, **kwargs) + self.item_type = self._format_item_type(item_type) + + def _format_item_type(self, item_type): + if inspect.isclass(item_type): + return item_type(None) + return item_type + + +class Unicode(DataType): + def convert(self, data): + if isinstance(data, six.text_type): + return data + if isinstance(data, str): + return data.decode("utf8", "replace") + return six.text_type(data) + + +class TestId(DataType): + def convert(self, data): + if isinstance(data, six.text_type): + return data + elif isinstance(data, bytes): + return data.decode("utf-8", "replace") + elif isinstance(data, (tuple, list)): + # This is really a bit of a hack; should really split out convertors from the + # fields they operate on + func = Unicode(None).convert + return tuple(func(item) for item in data) + else: + raise ValueError + + +class Status(DataType): + allowed = [ + "PASS", + "FAIL", + "OK", + "ERROR", + "TIMEOUT", + "CRASH", + "ASSERT", + "PRECONDITION_FAILED", + "SKIP", + ] + + def convert(self, data): + value = data.upper() + if value not in self.allowed: + raise ValueError + return value + + +class SubStatus(Status): + allowed = [ + "PASS", + "FAIL", + "ERROR", + "TIMEOUT", + "ASSERT", + "PRECONDITION_FAILED", + "NOTRUN", + "SKIP", + ] + + +class Dict(ContainerType): + def _format_item_type(self, item_type): + superfmt = super(Dict, self)._format_item_type + + if isinstance(item_type, dict): + if len(item_type) != 1: + raise ValueError( + "Dict item type specifier must contain a single entry." + ) + key_type, value_type = list(item_type.items())[0] + return superfmt(key_type), superfmt(value_type) + return Any(None), superfmt(item_type) + + def convert(self, data): + key_type, value_type = self.item_type + return { + key_type.convert(k): value_type.convert(v) for k, v in dict(data).items() + } + + +class List(ContainerType): + def convert(self, data): + # while dicts and strings _can_ be cast to lists, + # doing so is likely not intentional behaviour + if isinstance(data, (six.string_types, dict)): + raise ValueError("Expected list but got %s" % type(data)) + return [self.item_type.convert(item) for item in data] + + +class TestList(DataType): + """A TestList is a list of tests that can be either keyed by a group name, + or specified as a flat list. + """ + + def convert(self, data): + if isinstance(data, (list, tuple)): + data = {"default": data} + return Dict({Unicode: List(Unicode)}).convert(data) + + +class Int(DataType): + def convert(self, data): + return int(data) + + +class Any(DataType): + def convert(self, data): + return data + + +class Boolean(DataType): + def convert(self, data): + return bool(data) + + +class Tuple(ContainerType): + def _format_item_type(self, item_type): + superfmt = super(Tuple, self)._format_item_type + + if isinstance(item_type, (tuple, list)): + return [superfmt(t) for t in item_type] + return (superfmt(item_type),) + + def convert(self, data): + if len(data) != len(self.item_type): + raise ValueError( + "Expected %i items got %i" % (len(self.item_type), len(data)) + ) + return tuple( + item_type.convert(value) for item_type, value in zip(self.item_type, data) + ) + + +class Nullable(ContainerType): + def convert(self, data): + if data is None: + return data + return self.item_type.convert(data) diff --git a/testing/mozbase/mozlog/mozlog/proxy.py b/testing/mozbase/mozlog/mozlog/proxy.py new file mode 100644 index 0000000000..1d5994406b --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/proxy.py @@ -0,0 +1,81 @@ +# 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 threading import Thread + +from .structuredlog import StructuredLogger, get_default_logger + + +class ProxyLogger(object): + """ + A ProxyLogger behaves like a + :class:`mozlog.structuredlog.StructuredLogger`. + + Each method and attribute access will be forwarded to the underlying + StructuredLogger. + + RuntimeError will be raised when the default logger is not yet initialized. + """ + + def __init__(self, component=None): + self.logger = None + self._component = component + + def __getattr__(self, name): + if self.logger is None: + self.logger = get_default_logger(component=self._component) + if self.logger is None: + raise RuntimeError("Default logger is not initialized!") + return getattr(self.logger, name) + + +def get_proxy_logger(component=None): + """ + Returns a :class:`ProxyLogger` for the given component. + """ + return ProxyLogger(component) + + +class QueuedProxyLogger(StructuredLogger): + """Logger that logs via a queue. + + This is intended for multiprocessing use cases where there are + some subprocesses which want to share a log handler with the main thread, + without the overhead of having a multiprocessing lock for all logger + access.""" + + threads = {} + + def __init__(self, logger, mp_context=None): + StructuredLogger.__init__(self, logger.name) + + if mp_context is None: + import multiprocessing as mp_context + + if logger.name not in self.threads: + self.threads[logger.name] = LogQueueThread(mp_context.Queue(), logger) + self.threads[logger.name].start() + self.queue = self.threads[logger.name].queue + + def _handle_log(self, data): + self.queue.put(data) + + +class LogQueueThread(Thread): + def __init__(self, queue, logger): + self.queue = queue + self.logger = logger + Thread.__init__(self, name="Thread-Log") + self.daemon = True + + def run(self): + while True: + try: + msg = self.queue.get() + except (EOFError, IOError): + break + if msg is None: + break + else: + self.logger._handle_log(msg) diff --git a/testing/mozbase/mozlog/mozlog/pytest_mozlog/__init__.py b/testing/mozbase/mozlog/mozlog/pytest_mozlog/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/pytest_mozlog/__init__.py diff --git a/testing/mozbase/mozlog/mozlog/pytest_mozlog/plugin.py b/testing/mozbase/mozlog/mozlog/pytest_mozlog/plugin.py new file mode 100644 index 0000000000..ce51dacdec --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/pytest_mozlog/plugin.py @@ -0,0 +1,127 @@ +# 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/. + +import time + +import pytest +import six + +import mozlog + + +def pytest_addoption(parser): + # We can't simply use mozlog.commandline.add_logging_group(parser) here because + # Pytest's parser doesn't have the add_argument_group method Mozlog expects. + group = parser.getgroup("mozlog") + + for name, (_class, _help) in six.iteritems(mozlog.commandline.log_formatters): + group.addoption("--log-{0}".format(name), action="append", help=_help) + + formatter_options = six.iteritems(mozlog.commandline.fmt_options) + for name, (_class, _help, formatters, action) in formatter_options: + for formatter in formatters: + if formatter in mozlog.commandline.log_formatters: + group.addoption( + "--log-{0}-{1}".format(formatter, name), action=action, help=_help + ) + + +def pytest_configure(config): + # If using pytest-xdist for parallelization, only register plugin on master process + if not hasattr(config, "slaveinput"): + config.pluginmanager.register(MozLog()) + + +class MozLog(object): + def __init__(self): + self._started = False + self.results = {} + self.start_time = int(time.time() * 1000) # in ms for Mozlog compatibility + + def _log_suite_start(self, tests): + if not self._started: + # As this is called for each node when using pytest-xdist, we want + # to avoid logging multiple suite_start messages. + self.logger.suite_start( + tests=tests, time=self.start_time, run_info=self.run_info + ) + self._started = True + + def pytest_configure(self, config): + mozlog.commandline.setup_logging( + "pytest", + config.known_args_namespace, + defaults={}, + allow_unused_options=True, + ) + self.logger = mozlog.get_default_logger(component="pytest") + + def pytest_sessionstart(self, session): + """Called before test collection; records suite start time to log later""" + self.start_time = int(time.time() * 1000) # in ms for Mozlog compatibility + self.run_info = getattr(session.config, "_metadata", None) + + def pytest_collection_finish(self, session): + """Called after test collection is completed, just before tests are run (suite start)""" + self._log_suite_start([item.nodeid for item in session.items]) + + @pytest.mark.optionalhook + def pytest_xdist_node_collection_finished(self, node, ids): + """Called after each pytest-xdist node collection is completed""" + self._log_suite_start(ids) + + def pytest_sessionfinish(self, session, exitstatus): + self.logger.suite_end() + + def pytest_runtest_logstart(self, nodeid, location): + self.logger.test_start(test=nodeid) + + def pytest_runtest_logreport(self, report): + """Called 3 times per test (setup, call, teardown), indicated by report.when""" + test = report.nodeid + status = expected = "PASS" + message = stack = None + if hasattr(report, "wasxfail"): + expected = "FAIL" + if report.failed or report.outcome == "rerun": + status = "FAIL" if report.when == "call" else "ERROR" + if report.skipped: + status = "SKIP" if not hasattr(report, "wasxfail") else "FAIL" + if report.longrepr is not None: + longrepr = report.longrepr + if isinstance(longrepr, six.string_types): + # When using pytest-xdist, longrepr is serialised as a str + message = stack = longrepr + if longrepr.startswith("[XPASS(strict)]"): + # Strict expected failures have an outcome of failed when + # they unexpectedly pass. + expected, status = ("FAIL", "PASS") + elif hasattr(longrepr, "reprcrash"): + # For failures, longrepr is a ReprExceptionInfo + crash = longrepr.reprcrash + message = "{0} (line {1})".format(crash.message, crash.lineno) + stack = longrepr.reprtraceback + elif hasattr(longrepr, "errorstring"): + message = longrepr.errorstring + stack = longrepr.errorstring + elif hasattr(longrepr, "__getitem__") and len(longrepr) == 3: + # For skips, longrepr is a tuple of (file, lineno, reason) + message = report.longrepr[-1] + else: + raise ValueError( + "Unable to convert longrepr to message:\ntype %s\nfields: %s" + % (longrepr.__class__, dir(longrepr)) + ) + if status != expected or expected != "PASS": + self.results[test] = (status, expected, message, stack) + if report.outcome == "rerun" or report.when == "teardown": + defaults = ("PASS", "PASS", None, None) + status, expected, message, stack = self.results.get(test, defaults) + self.logger.test_end( + test=test, + status=status, + expected=expected, + message=message, + stack=stack, + ) diff --git a/testing/mozbase/mozlog/mozlog/reader.py b/testing/mozbase/mozlog/mozlog/reader.py new file mode 100644 index 0000000000..af5470f351 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/reader.py @@ -0,0 +1,78 @@ +# 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/. + +import json + + +def read(log_f, raise_on_error=False): + """Return a generator that will return the entries in a structured log file. + Note that the caller must not close the file whilst the generator is still + in use. + + :param log_f: file-like object containing the raw log entries, one per line + :param raise_on_error: boolean indicating whether ValueError should be raised + for lines that cannot be decoded.""" + while True: + line = log_f.readline() + if not line: + # This allows log_f to be a stream like stdout + break + try: + yield json.loads(line) + except ValueError: + if raise_on_error: + raise + + +def imap_log(log_iter, action_map): + """Create an iterator that will invoke a callback per action for each item in a + iterable containing structured log entries + + :param log_iter: Iterator returning structured log entries + :param action_map: Dictionary mapping action name to callback function. Log items + with actions not in this dictionary will be skipped. + """ + for item in log_iter: + if item["action"] in action_map: + yield action_map[item["action"]](item) + + +def each_log(log_iter, action_map): + """Call a callback for each item in an iterable containing structured + log entries + + :param log_iter: Iterator returning structured log entries + :param action_map: Dictionary mapping action name to callback function. Log items + with actions not in this dictionary will be skipped. + """ + for item in log_iter: + if item["action"] in action_map: + action_map[item["action"]](item) + + +class LogHandler(object): + """Base class for objects that act as log handlers. A handler is a callable + that takes a log entry as the only argument. + + Subclasses are expected to provide a method for each action type they + wish to handle, each taking a single argument for the test data. + For example a trivial subclass that just produces the id of each test as + it starts might be:: + + class StartIdHandler(LogHandler): + def test_start(data): + #For simplicity in the example pretend the id is always a string + return data["test"] + """ + + def __call__(self, data): + if hasattr(self, data["action"]): + handler = getattr(self, data["action"]) + return handler(data) + + +def handle_log(log_iter, handler): + """Call a handler for each item in a log, discarding the return value""" + for item in log_iter: + handler(item) diff --git a/testing/mozbase/mozlog/mozlog/scripts/__init__.py b/testing/mozbase/mozlog/mozlog/scripts/__init__.py new file mode 100644 index 0000000000..f1392507f8 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/scripts/__init__.py @@ -0,0 +1,41 @@ +#!/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/. + + +import argparse + +import format as formatlog +import logmerge +import six +import unstable + + +def get_parser(): + parser = argparse.ArgumentParser( + "structlog", description="Tools for dealing with structured logs" + ) + + commands = { + "unstable": (unstable.get_parser, unstable.main), + "format": (formatlog.get_parser, formatlog.main), + "logmerge": (logmerge.get_parser, logmerge.main), + } + + sub_parser = parser.add_subparsers(title="Subcommands") + + for command, (parser_func, main_func) in six.iteritems(commands): + parent = parser_func(False) + command_parser = sub_parser.add_parser( + command, description=parent.description, parents=[parent] + ) + command_parser.set_defaults(func=main_func) + + return parser + + +def main(): + parser = get_parser() + args = parser.parse_args() + args.func(**vars(args)) diff --git a/testing/mozbase/mozlog/mozlog/scripts/format.py b/testing/mozbase/mozlog/mozlog/scripts/format.py new file mode 100644 index 0000000000..27a643068f --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/scripts/format.py @@ -0,0 +1,55 @@ +# 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/. + +import argparse +import sys + +from .. import commandline, handlers, reader + + +def get_parser(add_help=True): + parser = argparse.ArgumentParser( + "format", description="Format a structured log stream", add_help=add_help + ) + parser.add_argument( + "--input", + action="store", + default=None, + help="Filename to read from, defaults to stdin", + ) + parser.add_argument( + "--output", + action="store", + default=None, + help="Filename to write to, defaults to stdout", + ) + parser.add_argument( + "format", choices=list(commandline.log_formatters.keys()), help="Format to use" + ) + return parser + + +def main(**kwargs): + if kwargs["input"] is None: + input_file = sys.stdin + else: + input_file = open(kwargs["input"]) + if kwargs["output"] is None: + output_file = sys.stdout + else: + output_file = open(kwargs["output"], "w") + + formatter = commandline.log_formatters[kwargs["format"]][0]() + + handler = handlers.StreamHandler(stream=output_file, formatter=formatter) + + for data in reader.read(input_file): + handler(data) + + +if __name__ == "__main__": + parser = get_parser() + args = parser.parse_args() + kwargs = vars(args) + main(**kwargs) diff --git a/testing/mozbase/mozlog/mozlog/scripts/logmerge.py b/testing/mozbase/mozlog/mozlog/scripts/logmerge.py new file mode 100644 index 0000000000..1114795387 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/scripts/logmerge.py @@ -0,0 +1,90 @@ +# 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/. + +import argparse +import json +import os +import sys +import time +from threading import current_thread + +from mozlog.reader import read + + +def dump_entry(entry, output): + json.dump(entry, output) + output.write("\n") + + +def fill_process_info(event): + # pylint: disable=W1633 + event["time"] = int(round(time.time() * 1000)) + event["thread"] = current_thread().name + event["pid"] = os.getpid() + return event + + +def process_until(reader, output, action): + for entry in reader: + if entry["action"] == action: + return entry + dump_entry(entry, output) + + +def process_until_suite_start(reader, output): + return process_until(reader, output, "suite_start") + + +def process_until_suite_end(reader, output): + return process_until(reader, output, "suite_end") + + +def validate_start_events(events): + for start in events: + if not start["run_info"] == events[0]["run_info"]: + print("Error: different run_info entries", file=sys.stderr) + sys.exit(1) + + +def merge_start_events(events): + for start in events[1:]: + events[0]["tests"].extend(start["tests"]) + return events[0] + + +def get_parser(add_help=True): + parser = argparse.ArgumentParser( + "logmerge", description="Merge multiple log files.", add_help=add_help + ) + parser.add_argument("-o", dest="output", help="output file, defaults to stdout") + parser.add_argument( + "files", metavar="File", type=str, nargs="+", help="file to be merged" + ) + return parser + + +def main(**kwargs): + if kwargs["output"] is None: + output = sys.stdout + else: + output = open(kwargs["output"], "w") + readers = [read(open(filename, "r")) for filename in kwargs["files"]] + start_events = [process_until_suite_start(reader, output) for reader in readers] + validate_start_events(start_events) + merged_start_event = merge_start_events(start_events) + dump_entry(fill_process_info(merged_start_event), output) + + end_events = [process_until_suite_end(reader, output) for reader in readers] + dump_entry(fill_process_info(end_events[0]), output) + + for reader in readers: + for entry in reader: + dump_entry(entry, output) + + +if __name__ == "__main__": + parser = get_parser() + args = parser.parse_args() + kwargs = vars(args) + main(**kwargs) diff --git a/testing/mozbase/mozlog/mozlog/scripts/unstable.py b/testing/mozbase/mozlog/mozlog/scripts/unstable.py new file mode 100644 index 0000000000..4292aced2a --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/scripts/unstable.py @@ -0,0 +1,148 @@ +# 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/. + +import argparse +import json +from collections import defaultdict + +import six + +from mozlog import reader + + +class StatusHandler(reader.LogHandler): + def __init__(self): + self.run_info = None + self.statuses = defaultdict( + lambda: defaultdict(lambda: defaultdict(lambda: defaultdict(int))) + ) + + def test_id(self, test): + if type(test) in (str, six.text_type): + return test + else: + return tuple(test) + + def suite_start(self, item): + self.run_info = tuple(sorted(item.get("run_info", {}).items())) + + def test_status(self, item): + self.statuses[self.run_info][self.test_id(item["test"])][item["subtest"]][ + item["status"] + ] += 1 + + def test_end(self, item): + self.statuses[self.run_info][self.test_id(item["test"])][None][ + item["status"] + ] += 1 + + def suite_end(self, item): + self.run_info = None + + +def get_statuses(filenames): + handler = StatusHandler() + + for filename in filenames: + with open(filename) as f: + reader.handle_log(reader.read(f), handler) + + return handler.statuses + + +def _filter(results_cmp): + def inner(statuses): + rv = defaultdict(lambda: defaultdict(dict)) + + for run_info, tests in six.iteritems(statuses): + for test, subtests in six.iteritems(tests): + for name, results in six.iteritems(subtests): + if results_cmp(results): + rv[run_info][test][name] = results + + return rv + + return inner + + +filter_unstable = _filter(lambda x: len(x) > 1) +filter_stable = _filter(lambda x: len(x) == 1) + + +def group_results(data): + rv = defaultdict(lambda: defaultdict(lambda: defaultdict(int))) + + for run_info, tests in six.iteritems(data): + for test, subtests in six.iteritems(tests): + for name, results in six.iteritems(subtests): + for status, number in six.iteritems(results): + rv[test][name][status] += number + return rv + + +def print_results(data): + for run_info, tests in six.iteritems(data): + run_str = ( + " ".join("%s:%s" % (k, v) for k, v in run_info) + if run_info + else "No Run Info" + ) + print(run_str) + print("=" * len(run_str)) + print_run(tests) + + +def print_run(tests): + for test, subtests in sorted(tests.items()): + print("\n" + str(test)) + print("-" * len(test)) + for name, results in six.iteritems(subtests): + print( + "[%s]: %s" + % ( + name if name is not None else "", + " ".join("%s (%i)" % (k, v) for k, v in six.iteritems(results)), + ) + ) + + +def get_parser(add_help=True): + parser = argparse.ArgumentParser( + "unstable", + description="List tests that don't give consistent " + "results from one or more runs.", + add_help=add_help, + ) + parser.add_argument( + "--json", action="store_true", default=False, help="Output in JSON format" + ) + parser.add_argument( + "--group", + action="store_true", + default=False, + help="Group results from different run types", + ) + parser.add_argument("log_file", nargs="+", help="Log files to read") + return parser + + +def main(**kwargs): + unstable = filter_unstable(get_statuses(kwargs["log_file"])) + if kwargs["group"]: + unstable = group_results(unstable) + + if kwargs["json"]: + print(json.dumps(unstable)) + else: + if not kwargs["group"]: + print_results(unstable) + else: + print_run(unstable) + + +if __name__ == "__main__": + parser = get_parser() + args = parser.parse_args() + kwargs = vars(args) + main(**kwargs) diff --git a/testing/mozbase/mozlog/mozlog/stdadapter.py b/testing/mozbase/mozlog/mozlog/stdadapter.py new file mode 100644 index 0000000000..0c13d61e43 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/stdadapter.py @@ -0,0 +1,50 @@ +# 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/. + +import logging + +from .structuredlog import StructuredLogger, log_levels + + +class UnstructuredHandler(logging.Handler): + def __init__(self, name=None, level=logging.NOTSET): + self.structured = StructuredLogger(name) + logging.Handler.__init__(self, level=level) + + def emit(self, record): + if record.levelname in log_levels: + log_func = getattr(self.structured, record.levelname.lower()) + else: + log_func = self.logger.debug + log_func(record.msg) + + def handle(self, record): + self.emit(record) + + +class LoggingWrapper(object): + def __init__(self, wrapped): + self.wrapped = wrapped + self.wrapped.addHandler( + UnstructuredHandler( + self.wrapped.name, logging.getLevelName(self.wrapped.level) + ) + ) + + def add_handler(self, handler): + self.addHandler(handler) + + def remove_handler(self, handler): + self.removeHandler(handler) + + def __getattr__(self, name): + return getattr(self.wrapped, name) + + +def std_logging_adapter(logger): + """Adapter for stdlib logging so that it produces structured + messages rather than standard logging messages + + :param logger: logging.Logger to wrap""" + return LoggingWrapper(logger) diff --git a/testing/mozbase/mozlog/mozlog/structuredlog.py b/testing/mozbase/mozlog/mozlog/structuredlog.py new file mode 100644 index 0000000000..4a56087996 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/structuredlog.py @@ -0,0 +1,820 @@ +# 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/. + +import json +import sys +import time +import traceback +from multiprocessing import current_process +from threading import Lock, current_thread + +import six + +from .logtypes import ( + Any, + Boolean, + Dict, + Int, + List, + Nullable, + Status, + SubStatus, + TestId, + TestList, + Tuple, + Unicode, + convertor_registry, + log_action, +) + +"""Structured Logging for recording test results. + +Allowed actions, and subfields: + suite_start + tests - List of test names + name - Name for the suite + run_info - Dictionary of run properties + + add_subsuite + name - Name for the subsuite (must be unique) + run_info - Updates to the suite run_info (optional) + + group_start + name - Name for the test group + + group_end + name - Name for the test group + + suite_end + + test_start + test - ID for the test + path - Relative path to test (optional) + subsuite - Name of the subsuite to which test belongs (optional) + group - Name of the test group for the incoming test (optional) + + test_end + test - ID for the test + status [PASS | FAIL | OK | ERROR | TIMEOUT | CRASH | + ASSERT PRECONDITION_FAILED | SKIP] - test status + expected [As for status] - Status that the test was expected to get, + or absent if the test got the expected status + extra - Dictionary of harness-specific extra information e.g. debug info + known_intermittent - List of known intermittent statuses that should + not fail a test. eg. ['FAIL', 'TIMEOUT'] + subsuite - Name of the subsuite to which test belongs (optional) + group - Name of the test group for the incoming test (optional) + + test_status + test - ID for the test + subtest - Name of the subtest + status [PASS | FAIL | TIMEOUT | + PRECONDITION_FAILED | NOTRUN | SKIP] - test status + expected [As for status] - Status that the subtest was expected to get, + or absent if the subtest got the expected status + known_intermittent - List of known intermittent statuses that should + not fail a test. eg. ['FAIL', 'TIMEOUT'] + subsuite - Name of the subsuite to which test belongs (optional) + group - Name of the test group for the incoming test (optional) + + process_output + process - PID of the process + command - Command line of the process + data - Output data from the process + test - ID of the test that the process was running (optional) + subsuite - Name of the subsuite that the process was running (optional) + + assertion_count + count - Number of assertions produced + min_expected - Minimum expected number of assertions + max_expected - Maximum expected number of assertions + subsuite - Name of the subsuite for the tests that ran (optional) + + lsan_leak + frames - List of stack frames from the leak report + scope - An identifier for the set of tests run during the browser session + (e.g. a directory name) + allowed_match - A stack frame in the list that matched a rule meaning the + leak is expected + subsuite - Name of the subsuite for the tests that ran (optional) + + lsan_summary + bytes - Number of bytes leaked + allocations - Number of allocations + allowed - Boolean indicating whether all detected leaks matched allow rules + subsuite - Name of the subsuite for the tests that ran (optional) + + mozleak_object + process - Process that leaked + bytes - Number of bytes that leaked + name - Name of the object that leaked + scope - An identifier for the set of tests run during the browser session + (e.g. a directory name) + allowed - Boolean indicating whether the leak was permitted + subsuite - Name of the subsuite for the tests that ran (optional) + + log + level [CRITICAL | ERROR | WARNING | + INFO | DEBUG] - level of the logging message + message - Message to log + +Subfields for all messages: + action - the action type of the current message + time - the timestamp in ms since the epoch of the log message + thread - name for the thread emitting the message + pid - id of the python process in which the logger is running + source - name for the source emitting the message + component - name of the subcomponent emitting the message +""" + +_default_logger_name = None + + +def get_default_logger(component=None): + """Gets the default logger if available, optionally tagged with component + name. Will return None if not yet set + + :param component: The component name to tag log messages with + """ + global _default_logger_name + + if not _default_logger_name: + return None + + return StructuredLogger(_default_logger_name, component=component) + + +def set_default_logger(default_logger): + """Sets the default logger to logger. + + It can then be retrieved with :py:func:`get_default_logger` + + Note that :py:func:`~mozlog.commandline.setup_logging` will + set a default logger for you, so there should be no need to call this + function if you're using setting up logging that way (recommended). + + :param default_logger: The logger to set to default. + """ + global _default_logger_name + + _default_logger_name = default_logger.name + + +log_levels = dict( + (k.upper(), v) + for v, k in enumerate(["critical", "error", "warning", "info", "debug"]) +) + +lint_levels = ["ERROR", "WARNING"] + + +def log_actions(): + """Returns the set of actions implemented by mozlog.""" + return set(convertor_registry.keys()) + + +class LoggerShutdownError(Exception): + """Raised when attempting to log after logger.shutdown() has been called.""" + + +class LoggerState(object): + def __init__(self): + self.reset() + + def reset(self): + self.handlers = [] + self.subsuites = set() + self.running_tests = set() + self.suite_started = False + self.component_states = {} + self.has_shutdown = False + + +class ComponentState(object): + def __init__(self): + self.filter_ = None + + +class StructuredLogger(object): + _lock = Lock() + _logger_states = {} + """Create a structured logger with the given name + + :param name: The name of the logger. + :param component: A subcomponent that the logger belongs to (typically a library name) + """ + + def __init__(self, name, component=None): + self.name = name + self.component = component + + with self._lock: + if name not in self._logger_states: + self._logger_states[name] = LoggerState() + + if component not in self._logger_states[name].component_states: + self._logger_states[name].component_states[component] = ComponentState() + + self._state = self._logger_states[name] + self._component_state = self._state.component_states[component] + + def add_handler(self, handler): + """Add a handler to the current logger""" + self._state.handlers.append(handler) + + def remove_handler(self, handler): + """Remove a handler from the current logger""" + self._state.handlers.remove(handler) + + def reset_state(self): + """Resets the logger to a brand new state. This means all handlers + are removed, running tests are discarded and components are reset. + """ + self._state.reset() + self._component_state = self._state.component_states[ + self.component + ] = ComponentState() + + def send_message(self, topic, command, *args): + """Send a message to each handler configured for this logger. This + part of the api is useful to those users requiring dynamic control + of a handler's behavior. + + :param topic: The name used by handlers to subscribe to a message. + :param command: The name of the command to issue. + :param args: Any arguments known to the target for specialized + behavior. + """ + rv = [] + for handler in self._state.handlers: + if hasattr(handler, "message_handler"): + rv += handler.message_handler.handle_message(topic, command, *args) + return rv + + @property + def has_shutdown(self): + """Property indicating whether the logger has been shutdown""" + return self._state.has_shutdown + + @property + def handlers(self): + """A list of handlers that will be called when a + message is logged from this logger""" + return self._state.handlers + + @property + def component_filter(self): + return self._component_state.filter_ + + @component_filter.setter + def component_filter(self, value): + self._component_state.filter_ = value + + def log_raw(self, raw_data): + if "action" not in raw_data: + raise ValueError + + action = raw_data["action"] + converted_data = convertor_registry[action].convert_known(**raw_data) + for k, v in six.iteritems(raw_data): + if ( + k not in converted_data + and k not in convertor_registry[action].optional_args + ): + converted_data[k] = v + + data = self._make_log_data(action, converted_data) + + if action in ("test_status", "test_end"): + if ( + data["expected"] == data["status"] + or data["status"] == "SKIP" + or "expected" not in raw_data + ): + del data["expected"] + + if not self._ensure_suite_state(action, data): + return + + self._handle_log(data) + + def _log_data(self, action, data=None): + if data is None: + data = {} + + if data.get("subsuite") and data["subsuite"] not in self._state.subsuites: + self.error(f"Unrecognised subsuite {data['subsuite']}") + return + + log_data = self._make_log_data(action, data) + self._handle_log(log_data) + + def _handle_log(self, data): + if self._state.has_shutdown: + raise LoggerShutdownError( + "{} action received after shutdown.".format(data["action"]) + ) + + with self._lock: + if self.component_filter: + data = self.component_filter(data) + if data is None: + return + + for handler in self.handlers: + try: + handler(data) + except Exception: + # Write the exception details directly to stderr because + # log() would call this method again which is currently locked. + print( + "%s: Failure calling log handler:" % __name__, + file=sys.__stderr__, + ) + print(traceback.format_exc(), file=sys.__stderr__) + + def _make_log_data(self, action, data): + all_data = { + "action": action, + "time": int(time.time() * 1000), + "thread": current_thread().name, + "pid": current_process().pid, + "source": self.name, + } + if self.component: + all_data["component"] = self.component + all_data.update(data) + return all_data + + def _ensure_suite_state(self, action, data): + if action == "suite_start": + if self._state.suite_started: + # limit data to reduce unnecessary log bloat + self.error( + "Got second suite_start message before suite_end. " + + "Logged with data: {}".format(json.dumps(data)[:100]) + ) + return False + self._state.suite_started = True + elif action == "suite_end": + if not self._state.suite_started: + self.error( + "Got suite_end message before suite_start. " + + "Logged with data: {}".format(json.dumps(data)) + ) + return False + self._state.suite_started = False + return True + + @log_action( + TestList("tests"), + Unicode("name", default=None, optional=True), + Dict(Any, "run_info", default=None, optional=True), + Dict(Any, "version_info", default=None, optional=True), + Dict(Any, "device_info", default=None, optional=True), + Dict(Any, "extra", default=None, optional=True), + ) + def suite_start(self, data): + """Log a suite_start message + + :param dict tests: Test identifiers that will be run in the suite, keyed by group name. + :param str name: Optional name to identify the suite. + :param dict run_info: Optional information typically provided by mozinfo. + :param dict version_info: Optional target application version information provided + by mozversion. + :param dict device_info: Optional target device information provided by mozdevice. + """ + if not self._ensure_suite_state("suite_start", data): + return + + self._log_data("suite_start", data) + + @log_action( + Unicode("name"), + Dict(Any, "run_info", default=None, optional=True), + ) + def add_subsuite(self, data): + """Log a add_subsuite message + + :param str name: Name to identify the subsuite. + :param dict run_info: Optional information about the subsuite. This updates the suite run_info. + """ + if data["name"] in self._state.subsuites: + return + run_info = data.get("run_info", {"subsuite": data["name"]}) + if "subsuite" not in run_info: + run_info = run_info.copy() + run_info["subsuite"] = data["name"] + data["run_info"] = run_info + self._state.subsuites.add(data["name"]) + self._log_data("add_subsuite", data) + + @log_action( + Unicode("name"), + ) + def group_start(self, data): + """Log a group_start message + + :param str name: Name to identify the test group. + """ + self._log_data("group_start", data) + + @log_action( + Unicode("name"), + ) + def group_end(self, data): + """Log a group_end message + + :param str name: Name to identify the test group. + """ + self._log_data("group_end", data) + + @log_action(Dict(Any, "extra", default=None, optional=True)) + def suite_end(self, data): + """Log a suite_end message""" + if not self._ensure_suite_state("suite_end", data): + return + + self._state.subsuites.clear() + + self._log_data("suite_end", data) + + @log_action( + TestId("test"), + Unicode("path", default=None, optional=True), + Unicode("subsuite", default=None, optional=True), + Unicode("group", default=None, optional=True), + ) + def test_start(self, data): + """Log a test_start message + + :param test: Identifier of the test that will run. + :param path: Path to test relative to some base (typically the root of + the source tree). + :param subsuite: Optional name of the subsuite to which the test belongs. + :param group: Optional name of the test group or manifest name (useful + when running in paralle) + """ + if not self._state.suite_started: + self.error( + "Got test_start message before suite_start for test %s" % data["test"] + ) + return + test_key = (data.get("subsuite"), data["test"]) + if test_key in self._state.running_tests: + self.error("test_start for %s logged while in progress." % data["test"]) + return + self._state.running_tests.add(test_key) + self._log_data("test_start", data) + + @log_action( + TestId("test"), + Unicode("subtest"), + SubStatus("status"), + SubStatus("expected", default="PASS"), + Unicode("message", default=None, optional=True), + Unicode("stack", default=None, optional=True), + Dict(Any, "extra", default=None, optional=True), + List(SubStatus, "known_intermittent", default=None, optional=True), + Unicode("subsuite", default=None, optional=True), + Unicode("group", default=None, optional=True), + ) + def test_status(self, data): + """ + Log a test_status message indicating a subtest result. Tests that + do not have subtests are not expected to produce test_status messages. + + :param test: Identifier of the test that produced the result. + :param subtest: Name of the subtest. + :param status: Status string indicating the subtest result + :param expected: Status string indicating the expected subtest result. + :param message: Optional string containing a message associated with the result. + :param stack: Optional stack trace encountered during test execution. + :param extra: Optional suite-specific data associated with the test result. + :param known_intermittent: Optional list of string expected intermittent statuses + :param subsuite: Optional name of the subsuite to which the test belongs. + :param group: Optional name of the test group or manifest name (useful + when running in paralle) + """ + + if data["expected"] == data["status"] or data["status"] == "SKIP": + del data["expected"] + + test_key = (data.get("subsuite"), data["test"]) + if test_key not in self._state.running_tests: + self.error( + "test_status for %s logged while not in progress. " + "Logged with data: %s" % (data["test"], json.dumps(data)) + ) + return + + self._log_data("test_status", data) + + @log_action( + TestId("test"), + Status("status"), + Status("expected", default="OK"), + Unicode("message", default=None, optional=True), + Unicode("stack", default=None, optional=True), + Dict(Any, "extra", default=None, optional=True), + List(Status, "known_intermittent", default=None, optional=True), + Unicode("subsuite", default=None, optional=True), + Unicode("group", default=None, optional=True), + ) + def test_end(self, data): + """ + Log a test_end message indicating that a test completed. For tests + with subtests this indicates whether the overall test completed without + errors. For tests without subtests this indicates the test result + directly. + + :param test: Identifier of the test that produced the result. + :param status: Status string indicating the test result + :param expected: Status string indicating the expected test result. + :param message: Optonal string containing a message associated with the result. + :param stack: Optional stack trace encountered during test execution. + :param extra: Optional suite-specific data associated with the test result. + :param subsuite: Optional name of the subsuite to which the test belongs. + :param group: Optional name of the test group or manifest name (useful + when running in paralle) + """ + + if data["expected"] == data["status"] or data["status"] == "SKIP": + del data["expected"] + + test_key = (data.get("subsuite"), data["test"]) + if test_key not in self._state.running_tests: + self.error( + "test_end for %s logged while not in progress. " + "Logged with data: %s" % (data["test"], json.dumps(data)) + ) + else: + self._state.running_tests.remove(test_key) + self._log_data("test_end", data) + + @log_action( + Unicode("process"), + Unicode("data"), + Unicode("command", default=None, optional=True), + TestId("test", default=None, optional=True), + Unicode("subsuite", default=None, optional=True), + ) + def process_output(self, data): + """Log output from a managed process. + + :param process: A unique identifier for the process producing the output + (typically the pid) + :param data: The output to log + :param command: Optional string representing the full command line used to start + the process. + :param test: Optional ID of the test which the process was running. + :param subsuite: Optional name of the subsuite which the process was running. + """ + self._log_data("process_output", data) + + @log_action( + Unicode("process", default=None), + Unicode("signature", default="[Unknown]"), + TestId("test", default=None, optional=True), + Unicode("minidump_path", default=None, optional=True), + Unicode("minidump_extra", default=None, optional=True), + Int("stackwalk_retcode", default=None, optional=True), + Unicode("stackwalk_stdout", default=None, optional=True), + Unicode("stackwalk_stderr", default=None, optional=True), + Unicode("reason", default=None, optional=True), + Unicode("java_stack", default=None, optional=True), + Unicode("process_type", default=None, optional=True), + List(Unicode, "stackwalk_errors", default=None), + Unicode("subsuite", default=None, optional=True), + ) + def crash(self, data): + if data["stackwalk_errors"] is None: + data["stackwalk_errors"] = [] + + self._log_data("crash", data) + + @log_action(Unicode("group", default=None), Unicode("message", default=None)) + def shutdown_failure(self, data): + self._log_data("shutdown_failure", data) + + @log_action( + Unicode("primary", default=None), List(Unicode, "secondary", default=None) + ) + def valgrind_error(self, data): + self._log_data("valgrind_error", data) + + @log_action( + Unicode("process"), + Unicode("command", default=None, optional=True), + Unicode("subsuite", default=None, optional=True), + ) + def process_start(self, data): + """Log start event of a process. + + :param process: A unique identifier for the process producing the + output (typically the pid) + :param command: Optional string representing the full command line used to + start the process. + :param subsuite: Optional name of the subsuite using the process. + """ + self._log_data("process_start", data) + + @log_action( + Unicode("process"), + Int("exitcode"), + Unicode("command", default=None, optional=True), + Unicode("subsuite", default=None, optional=True), + ) + def process_exit(self, data): + """Log exit event of a process. + + :param process: A unique identifier for the process producing the + output (typically the pid) + :param exitcode: the exit code + :param command: Optional string representing the full command line used to + start the process. + :param subsuite: Optional name of the subsuite using the process. + """ + self._log_data("process_exit", data) + + @log_action( + TestId("test"), + Int("count"), + Int("min_expected"), + Int("max_expected"), + Unicode("subsuite", default=None, optional=True), + ) + def assertion_count(self, data): + """Log count of assertions produced when running a test. + + :param count: Number of assertions produced + :param min_expected: Minimum expected number of assertions + :param max_expected: Maximum expected number of assertions + :param subsuite: Optional name of the subsuite for the tests that ran + """ + self._log_data("assertion_count", data) + + @log_action( + List(Unicode, "frames"), + Unicode("scope", optional=True, default=None), + Unicode("allowed_match", optional=True, default=None), + Unicode("subsuite", default=None, optional=True), + ) + def lsan_leak(self, data): + self._log_data("lsan_leak", data) + + @log_action( + Int("bytes"), + Int("allocations"), + Boolean("allowed", optional=True, default=False), + Unicode("subsuite", default=None, optional=True), + ) + def lsan_summary(self, data): + self._log_data("lsan_summary", data) + + @log_action( + Unicode("process"), + Int("bytes"), + Unicode("name"), + Unicode("scope", optional=True, default=None), + Boolean("allowed", optional=True, default=False), + Unicode("subsuite", default=None, optional=True), + ) + def mozleak_object(self, data): + self._log_data("mozleak_object", data) + + @log_action( + Unicode("process"), + Nullable(Int, "bytes"), + Int("threshold"), + List(Unicode, "objects"), + Unicode("scope", optional=True, default=None), + Boolean("induced_crash", optional=True, default=False), + Boolean("ignore_missing", optional=True, default=False), + Unicode("subsuite", default=None, optional=True), + ) + def mozleak_total(self, data): + self._log_data("mozleak_total", data) + + @log_action() + def shutdown(self, data): + """Shutdown the logger. + + This logs a 'shutdown' action after which any further attempts to use + the logger will raise a :exc:`LoggerShutdownError`. + + This is also called implicitly from the destructor or + when exiting the context manager. + """ + self._log_data("shutdown", data) + self._state.has_shutdown = True + + def __enter__(self): + return self + + def __exit__(self, exc, val, tb): + self.shutdown() + + +def _log_func(level_name): + @log_action(Unicode("message"), Any("exc_info", default=False)) + def log(self, data): + exc_info = data.pop("exc_info", None) + if exc_info: + if not isinstance(exc_info, tuple): + exc_info = sys.exc_info() + if exc_info != (None, None, None): + bt = traceback.format_exception(*exc_info) + data["stack"] = "\n".join(bt) + + data["level"] = level_name + self._log_data("log", data) + + log.__doc__ = ( + """Log a message with level %s + +:param message: The string message to log +:param exc_info: Either a boolean indicating whether to include a traceback + derived from sys.exc_info() or a three-item tuple in the + same format as sys.exc_info() containing exception information + to log. +""" + % level_name + ) + log.__name__ = str(level_name).lower() + return log + + +def _lint_func(level_name): + @log_action( + Unicode("path"), + Unicode("message", default=""), + Int("lineno", default=0), + Int("column", default=None, optional=True), + Unicode("hint", default=None, optional=True), + Unicode("source", default=None, optional=True), + Unicode("rule", default=None, optional=True), + Tuple((Int, Int), "lineoffset", default=None, optional=True), + Unicode("linter", default=None, optional=True), + ) + def lint(self, data): + data["level"] = level_name + self._log_data("lint", data) + + lint.__doc__ = """Log an error resulting from a failed lint check + + :param linter: name of the linter that flagged this error + :param path: path to the file containing the error + :param message: text describing the error + :param lineno: line number that contains the error + :param column: column containing the error + :param hint: suggestion for fixing the error (optional) + :param source: source code context of the error (optional) + :param rule: name of the rule that was violated (optional) + :param lineoffset: denotes an error spans multiple lines, of the form + (<lineno offset>, <num lines>) (optional) + """ + lint.__name__ = str("lint_%s" % level_name) + return lint + + +# Create all the methods on StructuredLog for log/lint levels +for level_name in log_levels: + setattr(StructuredLogger, level_name.lower(), _log_func(level_name)) + +for level_name in lint_levels: + level_name = level_name.lower() + name = "lint_%s" % level_name + setattr(StructuredLogger, name, _lint_func(level_name)) + + +class StructuredLogFileLike(object): + """Wrapper for file-like objects to redirect writes to logger + instead. Each call to `write` becomes a single log entry of type `log`. + + When using this it is important that the callees i.e. the logging + handlers do not themselves try to write to the wrapped file as this + will cause infinite recursion. + + :param logger: `StructuredLogger` to which to redirect the file write operations. + :param level: log level to use for each write. + :param prefix: String prefix to prepend to each log entry. + """ + + def __init__(self, logger, level="info", prefix=None): + self.logger = logger + self.log_func = getattr(self.logger, level) + self.prefix = prefix + + def write(self, data): + if data.endswith("\n"): + data = data[:-1] + if data.endswith("\r"): + data = data[:-1] + if self.prefix is not None: + data = "%s: %s" % (self.prefix, data) + self.log_func(data) + + def flush(self): + pass diff --git a/testing/mozbase/mozlog/mozlog/unstructured/__init__.py b/testing/mozbase/mozlog/mozlog/unstructured/__init__.py new file mode 100644 index 0000000000..24b8f5b405 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/unstructured/__init__.py @@ -0,0 +1,8 @@ +# flake8: noqa +# 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 .logger import * +from .loggingmixin import LoggingMixin +from .loglistener import LogMessageServer diff --git a/testing/mozbase/mozlog/mozlog/unstructured/logger.py b/testing/mozbase/mozlog/mozlog/unstructured/logger.py new file mode 100644 index 0000000000..db436c9d11 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/unstructured/logger.py @@ -0,0 +1,191 @@ +# flake8: noqa +# 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/. + +import json + +# Some of the build slave environments don't see the following when doing +# 'from logging import *' +# see https://bugzilla.mozilla.org/show_bug.cgi?id=700415#c35 +from logging import * +from logging import addLevelName, basicConfig, debug +from logging import getLogger as getSysLogger +from logging import getLoggerClass, info, setLoggerClass, shutdown + +_default_level = INFO +_LoggerClass = getLoggerClass() + +# Define mozlog specific log levels +START = _default_level + 1 +END = _default_level + 2 +PASS = _default_level + 3 +KNOWN_FAIL = _default_level + 4 +FAIL = _default_level + 5 +CRASH = _default_level + 6 +# Define associated text of log levels +addLevelName(START, "TEST-START") +addLevelName(END, "TEST-END") +addLevelName(PASS, "TEST-PASS") +addLevelName(KNOWN_FAIL, "TEST-KNOWN-FAIL") +addLevelName(FAIL, "TEST-UNEXPECTED-FAIL") +addLevelName(CRASH, "PROCESS-CRASH") + + +class MozLogger(_LoggerClass): + """ + MozLogger class which adds some convenience log levels + related to automated testing in Mozilla and ability to + output structured log messages. + """ + + def testStart(self, message, *args, **kwargs): + """Logs a test start message""" + self.log(START, message, *args, **kwargs) + + def testEnd(self, message, *args, **kwargs): + """Logs a test end message""" + self.log(END, message, *args, **kwargs) + + def testPass(self, message, *args, **kwargs): + """Logs a test pass message""" + self.log(PASS, message, *args, **kwargs) + + def testFail(self, message, *args, **kwargs): + """Logs a test fail message""" + self.log(FAIL, message, *args, **kwargs) + + def testKnownFail(self, message, *args, **kwargs): + """Logs a test known fail message""" + self.log(KNOWN_FAIL, message, *args, **kwargs) + + def processCrash(self, message, *args, **kwargs): + """Logs a process crash message""" + self.log(CRASH, message, *args, **kwargs) + + def log_structured(self, action, params=None): + """Logs a structured message object.""" + if params is None: + params = {} + + level = params.get("_level", _default_level) + if isinstance(level, int): + params["_level"] = getLevelName(level) + else: + params["_level"] = level + level = getLevelName(level.upper()) + + # If the logger is fed a level number unknown to the logging + # module, getLevelName will return a string. Unfortunately, + # the logging module will raise a type error elsewhere if + # the level is not an integer. + if not isinstance(level, int): + level = _default_level + + params["action"] = action + + # The can message be None. This is expected, and shouldn't cause + # unstructured formatters to fail. + message = params.get("_message") + + self.log(level, message, extra={"params": params}) + + +class JSONFormatter(Formatter): + """Log formatter for emitting structured JSON entries.""" + + def format(self, record): + # Default values determined by logger metadata + # pylint: disable=W1633 + output = { + "_time": int(round(record.created * 1000, 0)), + "_namespace": record.name, + "_level": getLevelName(record.levelno), + } + + # If this message was created by a call to log_structured, + # anything specified by the caller's params should act as + # an override. + output.update(getattr(record, "params", {})) + + if record.msg and output.get("_message") is None: + # For compatibility with callers using the printf like + # API exposed by python logging, call the default formatter. + output["_message"] = Formatter.format(self, record) + + return json.dumps(output, indent=output.get("indent")) + + +class MozFormatter(Formatter): + """ + MozFormatter class used to standardize formatting + If a different format is desired, this can be explicitly + overriden with the log handler's setFormatter() method + """ + + level_length = 0 + max_level_length = len("TEST-START") + + def __init__(self, include_timestamp=False): + """ + Formatter.__init__ has fmt and datefmt parameters that won't have + any affect on a MozFormatter instance. + + :param include_timestamp: if True, include formatted time at the + beginning of the message + """ + self.include_timestamp = include_timestamp + Formatter.__init__(self) + + def format(self, record): + # Handles padding so record levels align nicely + if len(record.levelname) > self.level_length: + pad = 0 + if len(record.levelname) <= self.max_level_length: + self.level_length = len(record.levelname) + else: + pad = self.level_length - len(record.levelname) + 1 + sep = "|".rjust(pad) + fmt = "%(name)s %(levelname)s " + sep + " %(message)s" + if self.include_timestamp: + fmt = "%(asctime)s " + fmt + # this protected member is used to define the format + # used by the base Formatter's method + self._fmt = fmt + return Formatter(fmt=fmt).format(record) + + +def getLogger(name, handler=None): + """ + Returns the logger with the specified name. + If the logger doesn't exist, it is created. + If handler is specified, adds it to the logger. Otherwise a default handler + that logs to standard output will be used. + + :param name: The name of the logger to retrieve + :param handler: A handler to add to the logger. If the logger already exists, + and a handler is specified, an exception will be raised. To + add a handler to an existing logger, call that logger's + addHandler method. + """ + setLoggerClass(MozLogger) + + if name in Logger.manager.loggerDict: + if handler: + raise ValueError( + "The handler parameter requires " + + "that a logger by this name does " + + "not already exist" + ) + return Logger.manager.loggerDict[name] + + logger = getSysLogger(name) + logger.setLevel(_default_level) + + if handler is None: + handler = StreamHandler() + handler.setFormatter(MozFormatter()) + + logger.addHandler(handler) + logger.propagate = False + return logger diff --git a/testing/mozbase/mozlog/mozlog/unstructured/loggingmixin.py b/testing/mozbase/mozlog/mozlog/unstructured/loggingmixin.py new file mode 100644 index 0000000000..727e8f1a7d --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/unstructured/loggingmixin.py @@ -0,0 +1,42 @@ +# 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 .logger import Logger, getLogger + + +class LoggingMixin(object): + """Expose a subset of logging functions to an inheriting class.""" + + def set_logger(self, logger_instance=None, name=None): + """Method for setting the underlying logger instance to be used.""" + + if logger_instance and not isinstance(logger_instance, Logger): + raise ValueError("logger_instance must be an instance of Logger") + + if name is None: + name = ".".join([self.__module__, self.__class__.__name__]) + + self._logger = logger_instance or getLogger(name) + + def _log_msg(self, cmd, *args, **kwargs): + if not hasattr(self, "_logger"): + self._logger = getLogger( + ".".join([self.__module__, self.__class__.__name__]) + ) + getattr(self._logger, cmd)(*args, **kwargs) + + def log(self, *args, **kwargs): + self._log_msg("log", *args, **kwargs) + + def info(self, *args, **kwargs): + self._log_msg("info", *args, **kwargs) + + def error(self, *args, **kwargs): + self._log_msg("error", *args, **kwargs) + + def warn(self, *args, **kwargs): + self._log_msg("warn", *args, **kwargs) + + def log_structured(self, *args, **kwargs): + self._log_msg("log_structured", *args, **kwargs) diff --git a/testing/mozbase/mozlog/mozlog/unstructured/loglistener.py b/testing/mozbase/mozlog/mozlog/unstructured/loglistener.py new file mode 100644 index 0000000000..0d57061236 --- /dev/null +++ b/testing/mozbase/mozlog/mozlog/unstructured/loglistener.py @@ -0,0 +1,50 @@ +# 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/. + +import json +import socket + +from six.moves import socketserver + + +class LogMessageServer(socketserver.TCPServer): + def __init__(self, server_address, logger, message_callback=None, timeout=3): + socketserver.TCPServer.__init__(self, server_address, LogMessageHandler) + self._logger = logger + self._message_callback = message_callback + self.timeout = timeout + + +class LogMessageHandler(socketserver.BaseRequestHandler): + """Processes output from a connected log source, logging to an + existing logger upon receipt of a well-formed log messsage.""" + + def handle(self): + """Continually listens for log messages.""" + self._partial_message = "" + self.request.settimeout(self.server.timeout) + + while True: + try: + data = self.request.recv(1024) + if not data: + return + self.process_message(data.decode()) + except socket.timeout: + return + + def process_message(self, data): + """Processes data from a connected log source. Messages are assumed + to be newline delimited, and generally well-formed JSON.""" + for part in data.split("\n"): + msg_string = self._partial_message + part + try: + msg = json.loads(msg_string) + self._partial_message = "" + self.server._logger.log_structured(msg.get("action", "UNKNOWN"), msg) + if self.server._message_callback: + self.server._message_callback() + + except ValueError: + self._partial_message = msg_string diff --git a/testing/mozbase/mozlog/setup.cfg b/testing/mozbase/mozlog/setup.cfg new file mode 100644 index 0000000000..3c6e79cf31 --- /dev/null +++ b/testing/mozbase/mozlog/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 diff --git a/testing/mozbase/mozlog/setup.py b/testing/mozbase/mozlog/setup.py new file mode 100644 index 0000000000..3283374363 --- /dev/null +++ b/testing/mozbase/mozlog/setup.py @@ -0,0 +1,43 @@ +# 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 setuptools import find_packages, setup + +PACKAGE_NAME = "mozlog" +PACKAGE_VERSION = "8.0.0" +DEPS = [ + "blessed>=1.19.1", + "mozterm", + "mozfile", + "six >= 1.13.0", +] + + +setup( + name=PACKAGE_NAME, + version=PACKAGE_VERSION, + description="Robust log handling specialized for logging in the Mozilla universe", + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + author="Mozilla Automation and Testing Team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + license="Mozilla Public License 2.0 (MPL 2.0)", + packages=find_packages(), + zip_safe=False, + install_requires=DEPS, + tests_require=["mozfile"], + platforms=["Any"], + classifiers=[ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", + "Operating System :: OS Independent", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.5", + "Topic :: Software Development :: Libraries :: Python Modules", + ], + package_data={"mozlog": ["formatters/html/main.js", "formatters/html/style.css"]}, + entry_points={"console_scripts": ["structlog = mozlog.scripts:main"]}, +) diff --git a/testing/mozbase/mozlog/tests/conftest.py b/testing/mozbase/mozlog/tests/conftest.py new file mode 100644 index 0000000000..eaf79897e4 --- /dev/null +++ b/testing/mozbase/mozlog/tests/conftest.py @@ -0,0 +1,24 @@ +import pytest +from mozlog.formatters import ErrorSummaryFormatter, MachFormatter +from mozlog.handlers import StreamHandler +from mozlog.structuredlog import StructuredLogger +from six import StringIO + + +@pytest.fixture +def get_logger(): + # Ensure a new state instance is created for each test function. + StructuredLogger._logger_states = {} + formatters = { + "mach": MachFormatter, + "errorsummary": ErrorSummaryFormatter, + } + + def inner(name, **fmt_args): + buf = StringIO() + fmt = formatters[name](**fmt_args) + logger = StructuredLogger("test_logger") + logger.add_handler(StreamHandler(buf, fmt)) + return logger + + return inner diff --git a/testing/mozbase/mozlog/tests/manifest.toml b/testing/mozbase/mozlog/tests/manifest.toml new file mode 100644 index 0000000000..6154550965 --- /dev/null +++ b/testing/mozbase/mozlog/tests/manifest.toml @@ -0,0 +1,16 @@ +[DEFAULT] +subsuite = "mozbase" + +["test_capture.py"] + +["test_errorsummary.py"] + +["test_formatters.py"] + +["test_logger.py"] + +["test_logtypes.py"] + +["test_structured.py"] + +["test_terminal_colors.py"] diff --git a/testing/mozbase/mozlog/tests/test_capture.py b/testing/mozbase/mozlog/tests/test_capture.py new file mode 100644 index 0000000000..6e14e155aa --- /dev/null +++ b/testing/mozbase/mozlog/tests/test_capture.py @@ -0,0 +1,37 @@ +import sys +import unittest + +import mozunit +from mozlog import capture, structuredlog +from test_structured import TestHandler + + +class TestCaptureIO(unittest.TestCase): + """Tests expected logging output of CaptureIO""" + + def setUp(self): + self.logger = structuredlog.StructuredLogger("test") + self.handler = TestHandler() + self.logger.add_handler(self.handler) + + def test_captureio_log(self): + """ + CaptureIO takes in two arguments. The second argument must + be truthy in order for the code to run. Hence, the string + "capture_stdio" has been used in this test case. + """ + with capture.CaptureIO(self.logger, "capture_stdio"): + print("message 1") + sys.stdout.write("message 2") + sys.stderr.write("message 3") + sys.stdout.write("\xff") + log = self.handler.items + messages = [item["message"] for item in log] + self.assertIn("STDOUT: message 1", messages) + self.assertIn("STDOUT: message 2", messages) + self.assertIn("STDERR: message 3", messages) + self.assertIn("STDOUT: \xff", messages) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozlog/tests/test_errorsummary.py b/testing/mozbase/mozlog/tests/test_errorsummary.py new file mode 100644 index 0000000000..30a5a304b2 --- /dev/null +++ b/testing/mozbase/mozlog/tests/test_errorsummary.py @@ -0,0 +1,262 @@ +# -*- coding: utf-8 -*- + +import json +import time + +import mozunit +import pytest + +# flake8: noqa + + +@pytest.mark.parametrize( + "logs,expected", + ( + pytest.param( + [ + ( + "suite_start", + { + "manifestA": ["test_foo", "test_bar", "test_baz"], + "manifestB": ["test_something"], + }, + ), + ("test_start", "test_foo"), + ("test_end", "test_foo", "SKIP"), + ("test_start", "test_bar"), + ("test_end", "test_bar", "OK"), + ("test_start", "test_something"), + ("test_end", "test_something", "OK"), + ("test_start", "test_baz"), + ("test_end", "test_baz", "PASS", "FAIL"), + ("suite_end",), + ], + """ + {"groups": ["manifestA", "manifestB"], "action": "test_groups", "line": 0} + {"test": "test_baz", "subtest": null, "group": "manifestA", "status": "PASS", "expected": "FAIL", "message": null, "stack": null, "known_intermittent": [], "action": "test_result", "line": 8} + {"group": "manifestA", "status": "ERROR", "duration": 20, "action": "group_result", "line": 9} + {"group": "manifestB", "status": "OK", "duration": 10, "action": "group_result", "line": 9} + """.strip(), + id="basic", + ), + pytest.param( + [ + ("suite_start", {"manifest": ["test_foo"]}), + ("test_start", "test_foo"), + ("suite_end",), + ], + """ + {"groups": ["manifest"], "action": "test_groups", "line": 0} + {"group": "manifest", "status": null, "duration": 0, "action": "group_result", "line": 2} + """.strip(), + id="missing_test_end", + ), + pytest.param( + [ + ("suite_start", {"manifest": ["test_foo"]}), + ("test_start", "test_foo"), + ("test_status", "test_foo", "subtest", "PASS"), + ("suite_end",), + ], + """ + {"groups": ["manifest"], "action": "test_groups", "line": 0} + {"group": "manifest", "status": "ERROR", "duration": null, "action": "group_result", "line": 3} + """.strip(), + id="missing_test_end_with_test_status_ok", + marks=pytest.mark.xfail, # status is OK but should be ERROR + ), + pytest.param( + [ + ( + "suite_start", + { + "manifestA": ["test_foo", "test_bar", "test_baz"], + "manifestB": ["test_something"], + }, + ), + ("test_start", "test_foo"), + ("test_end", "test_foo", "SKIP"), + ("test_start", "test_bar"), + ("test_end", "test_bar", "CRASH"), + ("test_start", "test_something"), + ("test_end", "test_something", "OK"), + ("test_start", "test_baz"), + ("test_end", "test_baz", "FAIL", "FAIL"), + ("suite_end",), + ], + """ + {"groups": ["manifestA", "manifestB"], "action": "test_groups", "line": 0} + {"test": "test_bar", "subtest": null, "group": "manifestA", "status": "CRASH", "expected": "OK", "message": null, "stack": null, "known_intermittent": [], "action": "test_result", "line": 4} + {"group": "manifestA", "status": "ERROR", "duration": 20, "action": "group_result", "line": 9} + {"group": "manifestB", "status": "OK", "duration": 10, "action": "group_result", "line": 9} + """.strip(), + id="crash_and_group_status", + ), + pytest.param( + [ + ( + "suite_start", + { + "manifestA": ["test_foo", "test_bar", "test_baz"], + "manifestB": ["test_something"], + }, + ), + ("test_start", "test_foo"), + ("test_end", "test_foo", "SKIP"), + ("test_start", "test_bar"), + ("test_end", "test_bar", "OK"), + ("test_start", "test_something"), + ("test_end", "test_something", "OK"), + ("test_start", "test_baz"), + ("test_status", "test_baz", "subtest", "FAIL", "FAIL"), + ("test_end", "test_baz", "OK"), + ("suite_end",), + ], + """ + {"groups": ["manifestA", "manifestB"], "action": "test_groups", "line": 0} + {"group": "manifestA", "status": "OK", "duration": 29, "action": "group_result", "line": 10} + {"group": "manifestB", "status": "OK", "duration": 10, "action": "group_result", "line": 10} + """.strip(), + id="fail_expected_fail", + ), + pytest.param( + [ + ( + "suite_start", + { + "manifestA": ["test_foo", "test_bar", "test_baz"], + "manifestB": ["test_something"], + }, + ), + ("test_start", "test_foo"), + ("test_end", "test_foo", "SKIP"), + ("test_start", "test_bar"), + ("test_end", "test_bar", "OK"), + ("test_start", "test_something"), + ("test_end", "test_something", "OK"), + ("test_start", "test_baz"), + ("test_status", "test_baz", "Test timed out", "FAIL", "PASS"), + ("test_status", "test_baz", "", "TIMEOUT", "PASS"), + ("crash", "", "signature", "manifestA"), + ("test_end", "test_baz", "OK"), + ("suite_end",), + ], + """ + {"groups": ["manifestA", "manifestB"], "action": "test_groups", "line": 0} + {"test": "test_baz", "subtest": "Test timed out", "group": "manifestA", "status": "FAIL", "expected": "PASS", "message": null, "stack": null, "known_intermittent": [], "action": "test_result", "line": 8} + {"test": "test_baz", "subtest": "", "group": "manifestA", "status": "TIMEOUT", "expected": "PASS", "message": null, "stack": null, "known_intermittent": [], "action": "test_result", "line": 9} + {"test": "manifestA", "group": "manifestA", "signature": "signature", "stackwalk_stderr": null, "stackwalk_stdout": null, "action": "crash", "line": 10} + {"group": "manifestA", "status": "ERROR", "duration": 49, "action": "group_result", "line": 12} + {"group": "manifestB", "status": "OK", "duration": 10, "action": "group_result", "line": 12} + """.strip(), + id="timeout_and_crash", + ), + pytest.param( + [ + ( + "suite_start", + { + "manifestA": ["test_foo", "test_bar", "test_baz"], + "manifestB": ["test_something"], + }, + ), + ("test_start", "test_foo"), + ("test_end", "test_foo", "SKIP"), + ("test_start", "test_bar"), + ("test_end", "test_bar", "CRASH", "CRASH"), + ("test_start", "test_something"), + ("test_end", "test_something", "OK"), + ("test_start", "test_baz"), + ("test_end", "test_baz", "FAIL", "FAIL"), + ("suite_end",), + ], + """ + {"groups": ["manifestA", "manifestB"], "action": "test_groups", "line": 0} + {"group": "manifestA", "status": "OK", "duration": 20, "action": "group_result", "line": 9} + {"group": "manifestB", "status": "OK", "duration": 10, "action": "group_result", "line": 9} + """.strip(), + id="crash_expected_crash", + ), + pytest.param( + [ + ( + "suite_start", + { + "manifestA": ["test_foo", "test_bar", "test_baz"], + "manifestB": ["test_something"], + }, + ), + ("test_start", "test_foo"), + ("test_end", "test_foo", "SKIP"), + ("test_start", "test_bar"), + ("test_end", "test_bar", "OK"), + ("test_start", "test_something"), + ("test_end", "test_something", "OK"), + ("test_start", "test_baz"), + ("test_end", "test_baz", "FAIL", "FAIL"), + ("crash", "", "", "manifestA"), + ("suite_end",), + ], + """ + {"groups": ["manifestA", "manifestB"], "action": "test_groups", "line": 0} + {"test": "manifestA", "group": "manifestA", "signature": "", "stackwalk_stderr": null, "stackwalk_stdout": null, "action": "crash", "line": 9} + {"group": "manifestA", "status": "ERROR", "duration": 20, "action": "group_result", "line": 10} + {"group": "manifestB", "status": "OK", "duration": 10, "action": "group_result", "line": 10} + """.strip(), + id="assertion_crash_on_shutdown", + ), + pytest.param( + [ + ( + "suite_start", + { + "manifestA": ["test_foo", "test_bar", "test_baz"], + "manifestB": ["test_something"], + }, + ), + ("test_start", "test_foo"), + ("test_end", "test_foo", "SKIP"), + ("test_start", "test_bar"), + ("test_end", "test_bar", "OK"), + ("test_start", "test_something"), + ("test_end", "test_something", "OK"), + ("test_start", "test_baz"), + ("test_end", "test_baz", "FAIL"), + ], + """ + {"groups": ["manifestA", "manifestB"], "action": "test_groups", "line": 0} + {"test": "test_baz", "group": "manifestA", "status": "FAIL", "expected": "OK", "subtest": null, "message": null, "stack": null, "known_intermittent": [], "action": "test_result", "line": 8} + """.strip(), + id="timeout_no_group_status", + ), + ), +) +def test_errorsummary(monkeypatch, get_logger, logs, expected): + ts = {"ts": 0.0} # need to use dict since 'nonlocal' doesn't exist on PY2 + + def fake_time(): + ts["ts"] += 0.01 + return ts["ts"] + + monkeypatch.setattr(time, "time", fake_time) + logger = get_logger("errorsummary") + + for log in logs: + getattr(logger, log[0])(*log[1:]) + + buf = logger.handlers[0].stream + result = buf.getvalue() + print("Dumping result for copy/paste:") + print(result) + + expected = expected.split("\n") + for i, line in enumerate(result.split("\n")): + if not line: + continue + + data = json.loads(line) + assert data == json.loads(expected[i]) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozlog/tests/test_formatters.py b/testing/mozbase/mozlog/tests/test_formatters.py new file mode 100644 index 0000000000..e0e3a51d97 --- /dev/null +++ b/testing/mozbase/mozlog/tests/test_formatters.py @@ -0,0 +1,767 @@ +# encoding: utf-8 + +# 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/. + +import os +import signal +import unittest +import xml.etree.ElementTree as ET +from textwrap import dedent + +import mozunit +import pytest +from mozlog.formatters import ( + GroupingFormatter, + HTMLFormatter, + MachFormatter, + TbplFormatter, + XUnitFormatter, +) +from mozlog.handlers import StreamHandler +from mozlog.structuredlog import StructuredLogger +from six import StringIO, ensure_text, unichr + +FORMATS = { + # A list of tuples consisting of (name, options, expected string). + "PASS": [ + ( + "mach", + {}, + dedent( + """ + 0:00.00 SUITE_START: running 3 tests + 0:00.00 TEST_START: test_foo + 0:00.00 TEST_END: OK + 0:00.00 TEST_START: test_bar + 0:00.00 TEST_END: Test OK. Subtests passed 1/1. Unexpected 0 + 0:00.00 TEST_START: test_baz + 0:00.00 TEST_END: FAIL + 0:00.00 SUITE_END + + suite 1 + ~~~~~~~ + Ran 4 checks (1 subtests, 3 tests) + Expected results: 4 + Unexpected results: 0 + OK + """ + ).lstrip("\n"), + ), + ( + "mach", + {"verbose": True}, + dedent( + """ + 0:00.00 SUITE_START: running 3 tests + 0:00.00 TEST_START: test_foo + 0:00.00 TEST_END: OK + 0:00.00 TEST_START: test_bar + 0:00.00 PASS a subtest + 0:00.00 TEST_END: Test OK. Subtests passed 1/1. Unexpected 0 + 0:00.00 TEST_START: test_baz + 0:00.00 TEST_END: FAIL + 0:00.00 SUITE_END + + suite 1 + ~~~~~~~ + Ran 4 checks (1 subtests, 3 tests) + Expected results: 4 + Unexpected results: 0 + OK + """ + ).lstrip("\n"), + ), + ], + "FAIL": [ + ( + "mach", + {}, + dedent( + """ + 0:00.00 SUITE_START: running 3 tests + 0:00.00 TEST_START: test_foo + 0:00.00 TEST_END: FAIL, expected PASS - expected 0 got 1 + 0:00.00 TEST_START: test_bar + 0:00.00 TEST_END: Test OK. Subtests passed 0/2. Unexpected 2 + FAIL a subtest - expected 0 got 1 + SimpleTest.is@SimpleTest/SimpleTest.js:312:5 + @caps/tests/mochitest/test_bug246699.html:53:1 + TIMEOUT another subtest + 0:00.00 TEST_START: test_baz + 0:00.00 TEST_END: PASS, expected FAIL + 0:00.00 SUITE_END + + suite 1 + ~~~~~~~ + Ran 5 checks (2 subtests, 3 tests) + Expected results: 1 + Unexpected results: 4 + test: 2 (1 fail, 1 pass) + subtest: 2 (1 fail, 1 timeout) + + Unexpected Results + ------------------ + test_foo + FAIL test_foo - expected 0 got 1 + test_bar + FAIL a subtest - expected 0 got 1 + SimpleTest.is@SimpleTest/SimpleTest.js:312:5 + @caps/tests/mochitest/test_bug246699.html:53:1 + TIMEOUT another subtest + test_baz + UNEXPECTED-PASS test_baz + """ + ).lstrip("\n"), + ), + ( + "mach", + {"verbose": True}, + dedent( + """ + 0:00.00 SUITE_START: running 3 tests + 0:00.00 TEST_START: test_foo + 0:00.00 TEST_END: FAIL, expected PASS - expected 0 got 1 + 0:00.00 TEST_START: test_bar + 0:00.00 FAIL a subtest - expected 0 got 1 + SimpleTest.is@SimpleTest/SimpleTest.js:312:5 + @caps/tests/mochitest/test_bug246699.html:53:1 + 0:00.00 TIMEOUT another subtest + 0:00.00 TEST_END: Test OK. Subtests passed 0/2. Unexpected 2 + 0:00.00 TEST_START: test_baz + 0:00.00 TEST_END: PASS, expected FAIL + 0:00.00 SUITE_END + + suite 1 + ~~~~~~~ + Ran 5 checks (2 subtests, 3 tests) + Expected results: 1 + Unexpected results: 4 + test: 2 (1 fail, 1 pass) + subtest: 2 (1 fail, 1 timeout) + + Unexpected Results + ------------------ + test_foo + FAIL test_foo - expected 0 got 1 + test_bar + FAIL a subtest - expected 0 got 1 + SimpleTest.is@SimpleTest/SimpleTest.js:312:5 + @caps/tests/mochitest/test_bug246699.html:53:1 + TIMEOUT another subtest + test_baz + UNEXPECTED-PASS test_baz + """ + ).lstrip("\n"), + ), + ], + "PRECONDITION_FAILED": [ + ( + "mach", + {}, + dedent( + """ + 0:00.00 SUITE_START: running 2 tests + 0:00.00 TEST_START: test_foo + 0:00.00 TEST_END: PRECONDITION_FAILED, expected OK + 0:00.00 TEST_START: test_bar + 0:00.00 TEST_END: Test OK. Subtests passed 1/2. Unexpected 1 + PRECONDITION_FAILED another subtest + 0:00.00 SUITE_END + + suite 1 + ~~~~~~~ + Ran 4 checks (2 subtests, 2 tests) + Expected results: 2 + Unexpected results: 2 + test: 1 (1 precondition_failed) + subtest: 1 (1 precondition_failed) + + Unexpected Results + ------------------ + test_foo + PRECONDITION_FAILED test_foo + test_bar + PRECONDITION_FAILED another subtest + """ + ).lstrip("\n"), + ), + ( + "mach", + {"verbose": True}, + dedent( + """ + 0:00.00 SUITE_START: running 2 tests + 0:00.00 TEST_START: test_foo + 0:00.00 TEST_END: PRECONDITION_FAILED, expected OK + 0:00.00 TEST_START: test_bar + 0:00.00 PASS a subtest + 0:00.00 PRECONDITION_FAILED another subtest + 0:00.00 TEST_END: Test OK. Subtests passed 1/2. Unexpected 1 + 0:00.00 SUITE_END + + suite 1 + ~~~~~~~ + Ran 4 checks (2 subtests, 2 tests) + Expected results: 2 + Unexpected results: 2 + test: 1 (1 precondition_failed) + subtest: 1 (1 precondition_failed) + + Unexpected Results + ------------------ + test_foo + PRECONDITION_FAILED test_foo + test_bar + PRECONDITION_FAILED another subtest + """ + ).lstrip("\n"), + ), + ], + "KNOWN-INTERMITTENT": [ + ( + "mach", + {}, + dedent( + """ + 0:00.00 SUITE_START: running 3 tests + 0:00.00 TEST_START: test_foo + 0:00.00 TEST_END: FAIL + KNOWN-INTERMITTENT-FAIL test_foo + 0:00.00 TEST_START: test_bar + 0:00.00 TEST_END: Test OK. Subtests passed 1/1. Unexpected 0 + KNOWN-INTERMITTENT-PASS a subtest + 0:00.00 TEST_START: test_baz + 0:00.00 TEST_END: FAIL + 0:00.00 SUITE_END + + suite 1 + ~~~~~~~ + Ran 4 checks (1 subtests, 3 tests) + Expected results: 4 (2 known intermittents) + Unexpected results: 0 + + Known Intermittent Results + -------------------------- + test_foo + KNOWN-INTERMITTENT-FAIL test_foo + test_bar + KNOWN-INTERMITTENT-PASS a subtest + OK + """ + ).lstrip("\n"), + ), + ( + "mach", + {"verbose": True}, + dedent( + """ + 0:00.00 SUITE_START: running 3 tests + 0:00.00 TEST_START: test_foo + 0:00.00 TEST_END: FAIL + KNOWN-INTERMITTENT-FAIL test_foo + 0:00.00 TEST_START: test_bar + 0:00.00 KNOWN-INTERMITTENT-PASS a subtest + 0:00.00 TEST_END: Test OK. Subtests passed 1/1. Unexpected 0 + KNOWN-INTERMITTENT-PASS a subtest + 0:00.00 TEST_START: test_baz + 0:00.00 TEST_END: FAIL + 0:00.00 SUITE_END + + suite 1 + ~~~~~~~ + Ran 4 checks (1 subtests, 3 tests) + Expected results: 4 (2 known intermittents) + Unexpected results: 0 + + Known Intermittent Results + -------------------------- + test_foo + KNOWN-INTERMITTENT-FAIL test_foo + test_bar + KNOWN-INTERMITTENT-PASS a subtest + OK + """ + ).lstrip("\n"), + ), + ], +} + + +def ids(test): + ids = [] + for value in FORMATS[test]: + args = ", ".join(["{}={}".format(k, v) for k, v in value[1].items()]) + if args: + args = "-{}".format(args) + ids.append("{}{}".format(value[0], args)) + return ids + + +@pytest.fixture(autouse=True) +def timestamp(monkeypatch): + def fake_time(*args, **kwargs): + return 0 + + monkeypatch.setattr(MachFormatter, "_time", fake_time) + + +@pytest.mark.parametrize("name,opts,expected", FORMATS["PASS"], ids=ids("PASS")) +def test_pass(get_logger, name, opts, expected): + logger = get_logger(name, **opts) + + logger.suite_start(["test_foo", "test_bar", "test_baz"]) + logger.test_start("test_foo") + logger.test_end("test_foo", "OK") + logger.test_start("test_bar") + logger.test_status("test_bar", "a subtest", "PASS") + logger.test_end("test_bar", "OK") + logger.test_start("test_baz") + logger.test_end("test_baz", "FAIL", "FAIL", "expected 0 got 1") + logger.suite_end() + + buf = logger.handlers[0].stream + result = buf.getvalue() + print("Dumping result for copy/paste:") + print(result) + assert result == expected + + +@pytest.mark.parametrize("name,opts,expected", FORMATS["FAIL"], ids=ids("FAIL")) +def test_fail(get_logger, name, opts, expected): + stack = """ + SimpleTest.is@SimpleTest/SimpleTest.js:312:5 + @caps/tests/mochitest/test_bug246699.html:53:1 +""".strip( + "\n" + ) + + logger = get_logger(name, **opts) + + logger.suite_start(["test_foo", "test_bar", "test_baz"]) + logger.test_start("test_foo") + logger.test_end("test_foo", "FAIL", "PASS", "expected 0 got 1") + logger.test_start("test_bar") + logger.test_status( + "test_bar", "a subtest", "FAIL", "PASS", "expected 0 got 1", stack + ) + logger.test_status("test_bar", "another subtest", "TIMEOUT") + logger.test_end("test_bar", "OK") + logger.test_start("test_baz") + logger.test_end("test_baz", "PASS", "FAIL") + logger.suite_end() + + buf = logger.handlers[0].stream + result = buf.getvalue() + print("Dumping result for copy/paste:") + print(result) + assert result == expected + + +@pytest.mark.parametrize( + "name,opts,expected", FORMATS["PRECONDITION_FAILED"], ids=ids("PRECONDITION_FAILED") +) +def test_precondition_failed(get_logger, name, opts, expected): + logger = get_logger(name, **opts) + + logger.suite_start(["test_foo", "test_bar"]) + logger.test_start("test_foo") + logger.test_end("test_foo", "PRECONDITION_FAILED") + logger.test_start("test_bar") + logger.test_status("test_bar", "a subtest", "PASS") + logger.test_status("test_bar", "another subtest", "PRECONDITION_FAILED") + logger.test_end("test_bar", "OK") + logger.suite_end() + + buf = logger.handlers[0].stream + result = buf.getvalue() + print("Dumping result for copy/paste:") + print(result) + assert result == expected + + +@pytest.mark.parametrize( + "name,opts,expected", FORMATS["KNOWN-INTERMITTENT"], ids=ids("KNOWN-INTERMITTENT") +) +def test_known_intermittent(get_logger, name, opts, expected): + logger = get_logger(name, **opts) + + logger.suite_start(["test_foo", "test_bar", "test_baz"]) + logger.test_start("test_foo") + logger.test_end("test_foo", "FAIL", "PASS", known_intermittent=["FAIL"]) + logger.test_start("test_bar") + logger.test_status( + "test_bar", "a subtest", "PASS", "FAIL", known_intermittent=["PASS"] + ) + logger.test_end("test_bar", "OK") + logger.test_start("test_baz") + logger.test_end( + "test_baz", "FAIL", "FAIL", "expected 0 got 1", known_intermittent=["PASS"] + ) + logger.suite_end() + + buf = logger.handlers[0].stream + result = buf.getvalue() + print("Dumping result for copy/paste:") + print(result) + assert result == expected + + +class FormatterTest(unittest.TestCase): + def setUp(self): + self.position = 0 + self.logger = StructuredLogger("test_%s" % type(self).__name__) + self.output_file = StringIO() + self.handler = StreamHandler(self.output_file, self.get_formatter()) + self.logger.add_handler(self.handler) + + def set_position(self, pos=None): + if pos is None: + pos = self.output_file.tell() + self.position = pos + + def get_formatter(self): + raise NotImplementedError( + "FormatterTest subclasses must implement get_formatter" + ) + + @property + def loglines(self): + self.output_file.seek(self.position) + return [ensure_text(line.rstrip()) for line in self.output_file.readlines()] + + +class TestHTMLFormatter(FormatterTest): + def get_formatter(self): + return HTMLFormatter() + + def test_base64_string(self): + self.logger.suite_start([]) + self.logger.test_start("string_test") + self.logger.test_end("string_test", "FAIL", extra={"data": "foobar"}) + self.logger.suite_end() + self.assertIn("data:text/html;charset=utf-8;base64,Zm9vYmFy", self.loglines[-3]) + + def test_base64_unicode(self): + self.logger.suite_start([]) + self.logger.test_start("unicode_test") + self.logger.test_end("unicode_test", "FAIL", extra={"data": unichr(0x02A9)}) + self.logger.suite_end() + self.assertIn("data:text/html;charset=utf-8;base64,yqk=", self.loglines[-3]) + + def test_base64_other(self): + self.logger.suite_start([]) + self.logger.test_start("int_test") + self.logger.test_end("int_test", "FAIL", extra={"data": {"foo": "bar"}}) + self.logger.suite_end() + self.assertIn( + "data:text/html;charset=utf-8;base64,eyJmb28iOiAiYmFyIn0=", + self.loglines[-3], + ) + + +class TestTBPLFormatter(FormatterTest): + def get_formatter(self): + return TbplFormatter() + + def test_unexpected_message(self): + self.logger.suite_start([]) + self.logger.test_start("timeout_test") + self.logger.test_end("timeout_test", "TIMEOUT", message="timed out") + self.assertIn( + "TEST-UNEXPECTED-TIMEOUT | timeout_test | timed out", self.loglines + ) + self.logger.suite_end() + + def test_default_unexpected_end_message(self): + self.logger.suite_start([]) + self.logger.test_start("timeout_test") + self.logger.test_end("timeout_test", "TIMEOUT") + self.assertIn( + "TEST-UNEXPECTED-TIMEOUT | timeout_test | expected OK", self.loglines + ) + self.logger.suite_end() + + def test_default_unexpected_status_message(self): + self.logger.suite_start([]) + self.logger.test_start("timeout_test") + self.logger.test_status("timeout_test", "subtest", status="TIMEOUT") + self.assertIn( + "TEST-UNEXPECTED-TIMEOUT | timeout_test | subtest - expected PASS", + self.loglines, + ) + self.logger.test_end("timeout_test", "OK") + self.logger.suite_end() + + def test_known_intermittent_end(self): + self.logger.suite_start([]) + self.logger.test_start("intermittent_test") + self.logger.test_end( + "intermittent_test", + status="FAIL", + expected="PASS", + known_intermittent=["FAIL"], + ) + # test_end log format: + # "TEST-KNOWN-INTERMITTENT-<STATUS> | <test> | took <duration>ms" + # where duration may be different each time + self.assertIn( + "TEST-KNOWN-INTERMITTENT-FAIL | intermittent_test | took ", self.loglines[2] + ) + self.assertIn("ms", self.loglines[2]) + self.logger.suite_end() + + def test_known_intermittent_status(self): + self.logger.suite_start([]) + self.logger.test_start("intermittent_test") + self.logger.test_status( + "intermittent_test", + "subtest", + status="FAIL", + expected="PASS", + known_intermittent=["FAIL"], + ) + self.assertIn( + "TEST-KNOWN-INTERMITTENT-FAIL | intermittent_test | subtest", self.loglines + ) + self.logger.test_end("intermittent_test", "OK") + self.logger.suite_end() + + def test_single_newline(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.set_position() + self.logger.test_status("test1", "subtest", status="PASS", expected="FAIL") + self.logger.test_end("test1", "OK") + self.logger.suite_end() + + # This sequence should not produce blanklines + for line in self.loglines: + self.assertNotEqual("", line) + + def test_process_exit(self): + self.logger.process_exit(1234, 0) + self.assertIn("TEST-INFO | 1234: exit 0", self.loglines) + + @unittest.skipUnless(os.name == "posix", "posix only") + def test_process_exit_with_sig(self): + # subprocess return code is negative when process + # has been killed by signal on posix. + self.logger.process_exit(1234, -signal.SIGTERM) + self.assertIn("TEST-INFO | 1234: killed by SIGTERM", self.loglines) + + +class TestTBPLFormatterWithShutdown(FormatterTest): + def get_formatter(self): + return TbplFormatter(summary_on_shutdown=True) + + def test_suite_summary_on_shutdown(self): + self.logger.suite_start([]) + self.logger.test_start("summary_test") + self.logger.test_status( + "summary_test", "subtest", "FAIL", "PASS", known_intermittent=["FAIL"] + ) + self.logger.test_end("summary_test", "FAIL", "OK", known_intermittent=["FAIL"]) + self.logger.suite_end() + self.logger.shutdown() + + self.assertIn("suite 1: 2/2 (2 known intermittent tests)", self.loglines) + self.assertIn("Known Intermittent tests:", self.loglines) + self.assertIn( + "TEST-KNOWN-INTERMITTENT-FAIL | summary_test | subtest", self.loglines + ) + + +class TestMachFormatter(FormatterTest): + def get_formatter(self): + return MachFormatter(disable_colors=True) + + def test_summary(self): + self.logger.suite_start([]) + + # Some tests that pass + self.logger.test_start("test1") + self.logger.test_end("test1", status="PASS", expected="PASS") + + self.logger.test_start("test2") + self.logger.test_end("test2", status="PASS", expected="TIMEOUT") + + self.logger.test_start("test3") + self.logger.test_end("test3", status="FAIL", expected="PASS") + + self.set_position() + self.logger.suite_end() + + self.assertIn("Ran 3 checks (3 tests)", self.loglines) + self.assertIn("Expected results: 1", self.loglines) + self.assertIn( + """ +Unexpected results: 2 + test: 2 (1 fail, 1 pass) +""".strip(), + "\n".join(self.loglines), + ) + self.assertNotIn("test1", self.loglines) + self.assertIn("UNEXPECTED-PASS test2", self.loglines) + self.assertIn("FAIL test3", self.loglines) + + def test_summary_subtests(self): + self.logger.suite_start([]) + + self.logger.test_start("test1") + self.logger.test_status("test1", "subtest1", status="PASS") + self.logger.test_status("test1", "subtest2", status="FAIL") + self.logger.test_end("test1", status="OK", expected="OK") + + self.logger.test_start("test2") + self.logger.test_status("test2", "subtest1", status="TIMEOUT", expected="PASS") + self.logger.test_end("test2", status="TIMEOUT", expected="OK") + + self.set_position() + self.logger.suite_end() + + self.assertIn("Ran 5 checks (3 subtests, 2 tests)", self.loglines) + self.assertIn("Expected results: 2", self.loglines) + self.assertIn( + """ +Unexpected results: 3 + test: 1 (1 timeout) + subtest: 2 (1 fail, 1 timeout) +""".strip(), + "\n".join(self.loglines), + ) + + def test_summary_ok(self): + self.logger.suite_start([]) + + self.logger.test_start("test1") + self.logger.test_status("test1", "subtest1", status="PASS") + self.logger.test_status("test1", "subtest2", status="PASS") + self.logger.test_end("test1", status="OK", expected="OK") + + self.logger.test_start("test2") + self.logger.test_status("test2", "subtest1", status="PASS", expected="PASS") + self.logger.test_end("test2", status="OK", expected="OK") + + self.set_position() + self.logger.suite_end() + + self.assertIn("OK", self.loglines) + self.assertIn("Expected results: 5", self.loglines) + self.assertIn("Unexpected results: 0", self.loglines) + + def test_process_start(self): + self.logger.process_start(1234) + self.assertIn("Started process `1234`", self.loglines[0]) + + def test_process_start_with_command(self): + self.logger.process_start(1234, command="test cmd") + self.assertIn("Started process `1234` (test cmd)", self.loglines[0]) + + def test_process_exit(self): + self.logger.process_exit(1234, 0) + self.assertIn("1234: exit 0", self.loglines[0]) + + @unittest.skipUnless(os.name == "posix", "posix only") + def test_process_exit_with_sig(self): + # subprocess return code is negative when process + # has been killed by signal on posix. + self.logger.process_exit(1234, -signal.SIGTERM) + self.assertIn("1234: killed by SIGTERM", self.loglines[0]) + + +class TestGroupingFormatter(FormatterTest): + def get_formatter(self): + return GroupingFormatter() + + def test_results_total(self): + self.logger.suite_start([]) + + self.logger.test_start("test1") + self.logger.test_status("test1", "subtest1", status="PASS") + self.logger.test_status("test1", "subtest1", status="PASS") + self.logger.test_end("test1", status="OK") + + self.logger.test_start("test2") + self.logger.test_status( + "test2", + "subtest2", + status="FAIL", + expected="PASS", + known_intermittent=["FAIL"], + ) + self.logger.test_end("test2", status="FAIL", expected="OK") + + self.set_position() + self.logger.suite_end() + + self.assertIn("Ran 2 tests finished in 0.0 seconds.", self.loglines) + self.assertIn(" \u2022 1 ran as expected. 0 tests skipped.", self.loglines) + self.assertIn(" \u2022 1 known intermittent results.", self.loglines) + self.assertIn(" \u2022 1 tests failed unexpectedly", self.loglines) + self.assertIn(" \u25B6 FAIL [expected OK] test2", self.loglines) + self.assertIn( + " \u25B6 FAIL [expected PASS, known intermittent [FAIL] test2, subtest2", + self.loglines, + ) + + +class TestXUnitFormatter(FormatterTest): + def get_formatter(self): + return XUnitFormatter() + + def log_as_xml(self): + return ET.fromstring("\n".join(self.loglines)) + + def test_stacktrace_is_present(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.logger.test_end( + "test1", "fail", message="Test message", stack="this\nis\na\nstack" + ) + self.logger.suite_end() + + root = self.log_as_xml() + self.assertIn("this\nis\na\nstack", root.find("testcase/failure").text) + + def test_failure_message(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.logger.test_end("test1", "fail", message="Test message") + self.logger.suite_end() + + root = self.log_as_xml() + self.assertEqual( + "Expected OK, got FAIL", root.find("testcase/failure").get("message") + ) + + def test_suite_attrs(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.logger.test_end("test1", "ok", message="Test message") + self.logger.suite_end() + + root = self.log_as_xml() + self.assertEqual(root.get("skips"), "0") + self.assertEqual(root.get("failures"), "0") + self.assertEqual(root.get("errors"), "0") + self.assertEqual(root.get("tests"), "1") + + def test_time_is_not_rounded(self): + # call formatter directly, it is easier here + formatter = self.get_formatter() + formatter.suite_start(dict(time=55000)) + formatter.test_start(dict(time=55100)) + formatter.test_end( + dict(time=55558, test="id", message="message", status="PASS") + ) + xml_string = formatter.suite_end(dict(time=55559)) + + root = ET.fromstring(xml_string) + self.assertEqual(root.get("time"), "0.56") + self.assertEqual(root.find("testcase").get("time"), "0.46") + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozlog/tests/test_logger.py b/testing/mozbase/mozlog/tests/test_logger.py new file mode 100644 index 0000000000..0776d87000 --- /dev/null +++ b/testing/mozbase/mozlog/tests/test_logger.py @@ -0,0 +1,303 @@ +# 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/. + +import datetime +import json +import socket +import threading +import time +import unittest + +import mozfile +import mozlog.unstructured as mozlog +import mozunit +import six + + +class ListHandler(mozlog.Handler): + """Mock handler appends messages to a list for later inspection.""" + + def __init__(self): + mozlog.Handler.__init__(self) + self.messages = [] + + def emit(self, record): + self.messages.append(self.format(record)) + + +class TestLogging(unittest.TestCase): + """Tests behavior of basic mozlog api.""" + + def test_logger_defaults(self): + """Tests the default logging format and behavior.""" + + default_logger = mozlog.getLogger("default.logger") + self.assertEqual(default_logger.name, "default.logger") + self.assertEqual(len(default_logger.handlers), 1) + self.assertTrue(isinstance(default_logger.handlers[0], mozlog.StreamHandler)) + + f = mozfile.NamedTemporaryFile() + list_logger = mozlog.getLogger( + "file.logger", handler=mozlog.FileHandler(f.name) + ) + self.assertEqual(len(list_logger.handlers), 1) + self.assertTrue(isinstance(list_logger.handlers[0], mozlog.FileHandler)) + f.close() + + self.assertRaises( + ValueError, mozlog.getLogger, "file.logger", handler=ListHandler() + ) + + def test_timestamps(self): + """Verifies that timestamps are included when asked for.""" + log_name = "test" + handler = ListHandler() + handler.setFormatter(mozlog.MozFormatter()) + log = mozlog.getLogger(log_name, handler=handler) + log.info("no timestamp") + self.assertTrue(handler.messages[-1].startswith("%s " % log_name)) + handler.setFormatter(mozlog.MozFormatter(include_timestamp=True)) + log.info("timestamp") + # Just verify that this raises no exceptions. + datetime.datetime.strptime(handler.messages[-1][:23], "%Y-%m-%d %H:%M:%S,%f") + + +class TestStructuredLogging(unittest.TestCase): + """Tests structured output in mozlog.""" + + def setUp(self): + self.handler = ListHandler() + self.handler.setFormatter(mozlog.JSONFormatter()) + self.logger = mozlog.MozLogger("test.Logger") + self.logger.addHandler(self.handler) + self.logger.setLevel(mozlog.DEBUG) + + def check_messages(self, expected, actual): + """Checks actual for equality with corresponding fields in actual. + The actual message should contain all fields in expected, and + should be identical, with the exception of the timestamp field. + The actual message should contain no fields other than the timestamp + field and those present in expected.""" + + self.assertTrue(isinstance(actual["_time"], six.integer_types)) + + for k, v in expected.items(): + self.assertEqual(v, actual[k]) + + for k in actual.keys(): + if k != "_time": + self.assertTrue(expected.get(k) is not None) + + def test_structured_output(self): + self.logger.log_structured( + "test_message", {"_level": mozlog.INFO, "_message": "message one"} + ) + self.logger.log_structured( + "test_message", {"_level": mozlog.INFO, "_message": "message two"} + ) + self.logger.log_structured( + "error_message", {"_level": mozlog.ERROR, "diagnostic": "unexpected error"} + ) + + message_one_expected = { + "_namespace": "test.Logger", + "_level": "INFO", + "_message": "message one", + "action": "test_message", + } + message_two_expected = { + "_namespace": "test.Logger", + "_level": "INFO", + "_message": "message two", + "action": "test_message", + } + message_three_expected = { + "_namespace": "test.Logger", + "_level": "ERROR", + "diagnostic": "unexpected error", + "action": "error_message", + } + + message_one_actual = json.loads(self.handler.messages[0]) + message_two_actual = json.loads(self.handler.messages[1]) + message_three_actual = json.loads(self.handler.messages[2]) + + self.check_messages(message_one_expected, message_one_actual) + self.check_messages(message_two_expected, message_two_actual) + self.check_messages(message_three_expected, message_three_actual) + + def test_unstructured_conversion(self): + """Tests that logging to a logger with a structured formatter + via the traditional logging interface works as expected.""" + self.logger.info("%s %s %d", "Message", "number", 1) + self.logger.error("Message number 2") + self.logger.debug( + "Message with %s", + "some extras", + extra={"params": {"action": "mozlog_test_output", "is_failure": False}}, + ) + message_one_expected = { + "_namespace": "test.Logger", + "_level": "INFO", + "_message": "Message number 1", + } + message_two_expected = { + "_namespace": "test.Logger", + "_level": "ERROR", + "_message": "Message number 2", + } + message_three_expected = { + "_namespace": "test.Logger", + "_level": "DEBUG", + "_message": "Message with some extras", + "action": "mozlog_test_output", + "is_failure": False, + } + + message_one_actual = json.loads(self.handler.messages[0]) + message_two_actual = json.loads(self.handler.messages[1]) + message_three_actual = json.loads(self.handler.messages[2]) + + self.check_messages(message_one_expected, message_one_actual) + self.check_messages(message_two_expected, message_two_actual) + self.check_messages(message_three_expected, message_three_actual) + + def message_callback(self): + if len(self.handler.messages) == 3: + message_one_expected = { + "_namespace": "test.Logger", + "_level": "DEBUG", + "_message": "socket message one", + "action": "test_message", + } + message_two_expected = { + "_namespace": "test.Logger", + "_level": "DEBUG", + "_message": "socket message two", + "action": "test_message", + } + message_three_expected = { + "_namespace": "test.Logger", + "_level": "DEBUG", + "_message": "socket message three", + "action": "test_message", + } + + message_one_actual = json.loads(self.handler.messages[0]) + + message_two_actual = json.loads(self.handler.messages[1]) + + message_three_actual = json.loads(self.handler.messages[2]) + + self.check_messages(message_one_expected, message_one_actual) + self.check_messages(message_two_expected, message_two_actual) + self.check_messages(message_three_expected, message_three_actual) + + def test_log_listener(self): + connection = "127.0.0.1", 0 + self.log_server = mozlog.LogMessageServer( + connection, self.logger, message_callback=self.message_callback, timeout=0.5 + ) + + message_string_one = json.dumps( + { + "_message": "socket message one", + "action": "test_message", + "_level": "DEBUG", + } + ) + message_string_two = json.dumps( + { + "_message": "socket message two", + "action": "test_message", + "_level": "DEBUG", + } + ) + + message_string_three = json.dumps( + { + "_message": "socket message three", + "action": "test_message", + "_level": "DEBUG", + } + ) + + message_string = ( + message_string_one + + "\n" + + message_string_two + + "\n" + + message_string_three + + "\n" + ) + + server_thread = threading.Thread(target=self.log_server.handle_request) + server_thread.start() + + host, port = self.log_server.server_address + + sock = socket.socket() + sock.connect((host, port)) + + # Sleeps prevent listener from receiving entire message in a single call + # to recv in order to test reconstruction of partial messages. + sock.sendall(message_string[:8].encode()) + time.sleep(0.01) + sock.sendall(message_string[8:32].encode()) + time.sleep(0.01) + sock.sendall(message_string[32:64].encode()) + time.sleep(0.01) + sock.sendall(message_string[64:128].encode()) + time.sleep(0.01) + sock.sendall(message_string[128:].encode()) + + server_thread.join() + + +class Loggable(mozlog.LoggingMixin): + """Trivial class inheriting from LoggingMixin""" + + pass + + +class TestLoggingMixin(unittest.TestCase): + """Tests basic use of LoggingMixin""" + + def test_mixin(self): + loggable = Loggable() + self.assertTrue(not hasattr(loggable, "_logger")) + loggable.log(mozlog.INFO, "This will instantiate the logger") + self.assertTrue(hasattr(loggable, "_logger")) + self.assertEqual(loggable._logger.name, "test_logger.Loggable") + + self.assertRaises(ValueError, loggable.set_logger, "not a logger") + + logger = mozlog.MozLogger("test.mixin") + handler = ListHandler() + logger.addHandler(handler) + loggable.set_logger(logger) + self.assertTrue(isinstance(loggable._logger.handlers[0], ListHandler)) + self.assertEqual(loggable._logger.name, "test.mixin") + + loggable.log(mozlog.WARN, 'message for "log" method') + loggable.info('message for "info" method') + loggable.error('message for "error" method') + loggable.log_structured( + "test_message", + params={"_message": "message for " + '"log_structured" method'}, + ) + + expected_messages = [ + 'message for "log" method', + 'message for "info" method', + 'message for "error" method', + 'message for "log_structured" method', + ] + + actual_messages = loggable._logger.handlers[0].messages + self.assertEqual(expected_messages, actual_messages) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozlog/tests/test_logtypes.py b/testing/mozbase/mozlog/tests/test_logtypes.py new file mode 100644 index 0000000000..177d25cdb0 --- /dev/null +++ b/testing/mozbase/mozlog/tests/test_logtypes.py @@ -0,0 +1,106 @@ +# 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/. + +import unittest + +import mozunit +from mozlog.logtypes import Any, Dict, Int, List, TestList, Tuple, Unicode + + +class TestContainerTypes(unittest.TestCase): + def test_dict_type_basic(self): + d = Dict("name") + with self.assertRaises(ValueError): + d({"foo": "bar"}) + + d = Dict(Any, "name") + d({"foo": "bar"}) # doesn't raise + + def test_dict_type_with_dictionary_item_type(self): + d = Dict({Int: Int}, "name") + with self.assertRaises(ValueError): + d({"foo": 1}) + + with self.assertRaises(ValueError): + d({1: "foo"}) + + d({1: 2}) # doesn't raise + + def test_dict_type_with_recursive_item_types(self): + d = Dict(Dict({Unicode: List(Int)}), "name") + with self.assertRaises(ValueError): + d({"foo": "bar"}) + + with self.assertRaises(ValueError): + d({"foo": {"bar": "baz"}}) + + with self.assertRaises(ValueError): + d({"foo": {"bar": ["baz"]}}) + + d({"foo": {"bar": [1]}}) # doesn't raise + + def test_list_type_basic(self): + l = List("name") + with self.assertRaises(ValueError): + l(["foo"]) + + l = List(Any, "name") + l(["foo", 1]) # doesn't raise + + def test_list_type_with_recursive_item_types(self): + l = List(Dict(List(Tuple((Unicode, Int)))), "name") + with self.assertRaises(ValueError): + l(["foo"]) + + with self.assertRaises(ValueError): + l([{"foo": "bar"}]) + + with self.assertRaises(ValueError): + l([{"foo": ["bar"]}]) + + l([{"foo": [("bar", 1)]}]) # doesn't raise + + def test_tuple_type_basic(self): + t = Tuple("name") + with self.assertRaises(ValueError): + t((1,)) + + t = Tuple(Any, "name") + t((1,)) # doesn't raise + + def test_tuple_type_with_tuple_item_type(self): + t = Tuple((Unicode, Int)) + with self.assertRaises(ValueError): + t(("foo", "bar")) + + t(("foo", 1)) # doesn't raise + + def test_tuple_type_with_recursive_item_types(self): + t = Tuple((Dict(List(Any)), List(Dict(Any)), Unicode), "name") + with self.assertRaises(ValueError): + t(({"foo": "bar"}, [{"foo": "bar"}], "foo")) + + with self.assertRaises(ValueError): + t(({"foo": ["bar"]}, ["foo"], "foo")) + + t(({"foo": ["bar"]}, [{"foo": "bar"}], "foo")) # doesn't raise + + +class TestDataTypes(unittest.TestCase): + def test_test_list(self): + t = TestList("name") + with self.assertRaises(ValueError): + t("foo") + + with self.assertRaises(ValueError): + t({"foo": 1}) + + d1 = t({"default": ["bar"]}) # doesn't raise + d2 = t(["bar"]) # doesn't raise + + self.assertDictContainsSubset(d1, d2) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozlog/tests/test_structured.py b/testing/mozbase/mozlog/tests/test_structured.py new file mode 100644 index 0000000000..4dc0993263 --- /dev/null +++ b/testing/mozbase/mozlog/tests/test_structured.py @@ -0,0 +1,1162 @@ +# -*- coding: utf-8 -*- + +import argparse +import json +import optparse +import os +import sys +import unittest + +import mozfile +import mozunit +import six +from mozlog import commandline, formatters, handlers, reader, stdadapter, structuredlog +from six import StringIO + + +class TestHandler(object): + def __init__(self): + self.items = [] + + def __call__(self, data): + self.items.append(data) + + @property + def last_item(self): + return self.items[-1] + + @property + def empty(self): + return not self.items + + +class BaseStructuredTest(unittest.TestCase): + def setUp(self): + self.logger = structuredlog.StructuredLogger("test") + self.handler = TestHandler() + self.logger.add_handler(self.handler) + + def pop_last_item(self): + return self.handler.items.pop() + + def assert_log_equals(self, expected, actual=None): + if actual is None: + actual = self.pop_last_item() + + all_expected = {"pid": os.getpid(), "thread": "MainThread", "source": "test"} + specials = set(["time"]) + + all_expected.update(expected) + for key, value in six.iteritems(all_expected): + self.assertEqual(actual[key], value) + + self.assertEqual(set(all_expected.keys()) | specials, set(actual.keys())) + + +class TestStatusHandler(BaseStructuredTest): + def setUp(self): + super(TestStatusHandler, self).setUp() + self.handler = handlers.StatusHandler() + self.logger.add_handler(self.handler) + + def test_failure_run(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.logger.test_status("test1", "sub1", status="PASS") + self.logger.test_status("test1", "sub2", status="TIMEOUT") + self.logger.test_status( + "test1", "sub3", status="FAIL", expected="PASS", known_intermittent=["FAIL"] + ) + self.logger.test_end("test1", status="OK") + self.logger.suite_end() + summary = self.handler.summarize() + self.assertIn("TIMEOUT", summary.unexpected_statuses) + self.assertEqual(1, summary.unexpected_statuses["TIMEOUT"]) + self.assertIn("PASS", summary.expected_statuses) + self.assertEqual(1, summary.expected_statuses["PASS"]) + self.assertIn("OK", summary.expected_statuses) + self.assertEqual(1, summary.expected_statuses["OK"]) + self.assertIn("FAIL", summary.expected_statuses) + self.assertEqual(1, summary.expected_statuses["FAIL"]) + self.assertIn("FAIL", summary.known_intermittent_statuses) + self.assertEqual(1, summary.known_intermittent_statuses["FAIL"]) + self.assertEqual(3, summary.action_counts["test_status"]) + self.assertEqual(1, summary.action_counts["test_end"]) + + def test_precondition_failed_run(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.logger.test_end("test1", status="PRECONDITION_FAILED") + self.logger.test_start("test2") + self.logger.test_status("test2", "sub1", status="PRECONDITION_FAILED") + self.logger.test_end("test2", status="OK") + self.logger.suite_end() + summary = self.handler.summarize() + self.assertEqual(1, summary.expected_statuses["OK"]) + self.assertEqual(2, summary.unexpected_statuses["PRECONDITION_FAILED"]) + + def test_error_run(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.logger.error("ERRR!") + self.logger.test_end("test1", status="OK") + self.logger.test_start("test2") + self.logger.test_end("test2", status="OK") + self.logger.suite_end() + summary = self.handler.summarize() + self.assertIn("ERROR", summary.log_level_counts) + self.assertEqual(1, summary.log_level_counts["ERROR"]) + self.assertIn("OK", summary.expected_statuses) + self.assertEqual(2, summary.expected_statuses["OK"]) + + +class TestSummaryHandler(BaseStructuredTest): + def setUp(self): + super(TestSummaryHandler, self).setUp() + self.handler = handlers.SummaryHandler() + self.logger.add_handler(self.handler) + + def test_failure_run(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.logger.test_status("test1", "sub1", status="PASS") + self.logger.test_status("test1", "sub2", status="TIMEOUT") + self.logger.assertion_count("test1", 5, 1, 10) + self.logger.assertion_count("test1", 5, 10, 15) + self.logger.test_end("test1", status="OK") + self.logger.suite_end() + + counts = self.handler.current["counts"] + self.assertIn("timeout", counts["subtest"]["unexpected"]) + self.assertEqual(1, counts["subtest"]["unexpected"]["timeout"]) + self.assertIn("pass", counts["subtest"]["expected"]) + self.assertEqual(1, counts["subtest"]["expected"]["pass"]) + self.assertIn("ok", counts["test"]["expected"]) + self.assertEqual(1, counts["test"]["expected"]["ok"]) + self.assertIn("pass", counts["assert"]["unexpected"]) + self.assertEqual(1, counts["assert"]["unexpected"]["pass"]) + self.assertIn("fail", counts["assert"]["expected"]) + self.assertEqual(1, counts["assert"]["expected"]["fail"]) + + logs = self.handler.current["unexpected_logs"] + self.assertEqual(1, len(logs)) + self.assertIn("test1", logs) + self.assertEqual(1, len(logs["test1"])) + self.assertEqual("sub2", logs["test1"][0]["subtest"]) + + def test_precondition_failed_run(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.logger.test_status("test1", "sub1", status="PASS") + self.logger.test_end("test1", status="PRECONDITION_FAILED") + self.logger.test_start("test2") + self.logger.test_status("test2", "sub1", status="PRECONDITION_FAILED") + self.logger.test_status("test2", "sub2", status="PRECONDITION_FAILED") + self.logger.test_end("test2", status="OK") + self.logger.suite_end() + + counts = self.handler.current["counts"] + self.assertIn("precondition_failed", counts["test"]["unexpected"]) + self.assertEqual(1, counts["test"]["unexpected"]["precondition_failed"]) + self.assertIn("pass", counts["subtest"]["expected"]) + self.assertEqual(1, counts["subtest"]["expected"]["pass"]) + self.assertIn("ok", counts["test"]["expected"]) + self.assertEqual(1, counts["test"]["expected"]["ok"]) + self.assertIn("precondition_failed", counts["subtest"]["unexpected"]) + self.assertEqual(2, counts["subtest"]["unexpected"]["precondition_failed"]) + + +class TestStructuredLog(BaseStructuredTest): + def test_suite_start(self): + self.logger.suite_start(["test"], "logtest") + self.assert_log_equals( + {"action": "suite_start", "name": "logtest", "tests": {"default": ["test"]}} + ) + self.logger.suite_end() + + def test_suite_end(self): + self.logger.suite_start([]) + self.logger.suite_end() + self.assert_log_equals({"action": "suite_end"}) + + def test_add_subsuite(self): + self.logger.suite_start([]) + self.logger.add_subsuite("other") + self.assert_log_equals( + { + "action": "add_subsuite", + "name": "other", + "run_info": {"subsuite": "other"}, + } + ) + self.logger.suite_end() + + def test_add_subsuite_duplicate(self): + self.logger.suite_start([]) + self.logger.add_subsuite("other") + # This should be a no-op + self.logger.add_subsuite("other") + self.assert_log_equals( + { + "action": "add_subsuite", + "name": "other", + "run_info": {"subsuite": "other"}, + } + ) + self.assert_log_equals({"action": "suite_start", "tests": {"default": []}}) + + self.logger.suite_end() + + def test_start(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.assert_log_equals({"action": "test_start", "test": "test1"}) + + self.logger.test_start(("test1", "==", "test1-ref"), path="path/to/test") + self.assert_log_equals( + { + "action": "test_start", + "test": ("test1", "==", "test1-ref"), + "path": "path/to/test", + } + ) + self.logger.suite_end() + + def test_start_inprogress(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.logger.test_start("test1") + self.assert_log_equals( + { + "action": "log", + "message": "test_start for test1 logged while in progress.", + "level": "ERROR", + } + ) + self.logger.suite_end() + + def test_start_inprogress_subsuite(self): + self.logger.suite_start([]) + self.logger.add_subsuite("other") + self.logger.test_start("test1") + self.logger.test_start("test1", subsuite="other") + self.assert_log_equals( + { + "action": "test_start", + "test": "test1", + "subsuite": "other", + } + ) + self.logger.suite_end() + + def test_status(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.logger.test_status( + "test1", "subtest name", "fail", expected="FAIL", message="Test message" + ) + self.assert_log_equals( + { + "action": "test_status", + "subtest": "subtest name", + "status": "FAIL", + "message": "Test message", + "test": "test1", + } + ) + self.logger.test_end("test1", "OK") + self.logger.suite_end() + + def test_status_1(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.logger.test_status("test1", "subtest name", "fail") + self.assert_log_equals( + { + "action": "test_status", + "subtest": "subtest name", + "status": "FAIL", + "expected": "PASS", + "test": "test1", + } + ) + self.logger.test_end("test1", "OK") + self.logger.suite_end() + + def test_status_2(self): + self.assertRaises( + ValueError, + self.logger.test_status, + "test1", + "subtest name", + "XXXUNKNOWNXXX", + ) + + def test_status_extra(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.logger.test_status( + "test1", "subtest name", "FAIL", expected="PASS", extra={"data": 42} + ) + self.assert_log_equals( + { + "action": "test_status", + "subtest": "subtest name", + "status": "FAIL", + "expected": "PASS", + "test": "test1", + "extra": {"data": 42}, + } + ) + self.logger.test_end("test1", "OK") + self.logger.suite_end() + + def test_status_stack(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.logger.test_status( + "test1", + "subtest name", + "FAIL", + expected="PASS", + stack="many\nlines\nof\nstack", + ) + self.assert_log_equals( + { + "action": "test_status", + "subtest": "subtest name", + "status": "FAIL", + "expected": "PASS", + "test": "test1", + "stack": "many\nlines\nof\nstack", + } + ) + self.logger.test_end("test1", "OK") + self.logger.suite_end() + + def test_status_known_intermittent(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.logger.test_status( + "test1", "subtest name", "fail", known_intermittent=["FAIL"] + ) + self.assert_log_equals( + { + "action": "test_status", + "subtest": "subtest name", + "status": "FAIL", + "expected": "PASS", + "known_intermittent": ["FAIL"], + "test": "test1", + } + ) + self.logger.test_end("test1", "OK") + self.logger.suite_end() + + def test_status_not_started(self): + self.logger.test_status("test_UNKNOWN", "subtest", "PASS") + self.assertTrue( + self.pop_last_item()["message"].startswith( + "test_status for test_UNKNOWN logged while not in progress. Logged with data: {" + ) + ) + + def test_remove_optional_defaults(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.logger.test_status( + "test1", "subtest name", "fail", message=None, stack=None + ) + self.assert_log_equals( + { + "action": "test_status", + "subtest": "subtest name", + "status": "FAIL", + "expected": "PASS", + "test": "test1", + } + ) + self.logger.test_end("test1", "OK") + self.logger.suite_end() + + def test_remove_optional_defaults_raw_log(self): + self.logger.log_raw({"action": "suite_start", "tests": [1], "name": None}) + self.assert_log_equals({"action": "suite_start", "tests": {"default": ["1"]}}) + self.logger.suite_end() + + def test_end(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.logger.test_end("test1", "fail", message="Test message") + self.assert_log_equals( + { + "action": "test_end", + "status": "FAIL", + "expected": "OK", + "message": "Test message", + "test": "test1", + } + ) + self.logger.suite_end() + + def test_end_1(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.logger.test_end("test1", "PASS", expected="PASS", extra={"data": 123}) + self.assert_log_equals( + { + "action": "test_end", + "status": "PASS", + "extra": {"data": 123}, + "test": "test1", + } + ) + self.logger.suite_end() + + def test_end_2(self): + self.assertRaises(ValueError, self.logger.test_end, "test1", "XXXUNKNOWNXXX") + + def test_end_stack(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.logger.test_end( + "test1", "PASS", expected="PASS", stack="many\nlines\nof\nstack" + ) + self.assert_log_equals( + { + "action": "test_end", + "status": "PASS", + "test": "test1", + "stack": "many\nlines\nof\nstack", + } + ) + self.logger.suite_end() + + def test_end_no_start(self): + self.logger.test_end("test1", "PASS", expected="PASS") + self.assertTrue( + self.pop_last_item()["message"].startswith( + "test_end for test1 logged while not in progress. Logged with data: {" + ) + ) + self.logger.suite_end() + + def test_end_no_start_subsuite(self): + self.logger.suite_start([]) + self.logger.add_subsuite("other") + self.logger.test_start("test1", subsuite="other") + self.logger.test_end("test1", "PASS", expected="PASS") + self.assertTrue( + self.pop_last_item()["message"].startswith( + "test_end for test1 logged while not in progress. Logged with data: {" + ) + ) + self.logger.test_end("test1", "OK", subsuite="other") + self.assert_log_equals( + { + "action": "test_end", + "status": "OK", + "test": "test1", + "subsuite": "other", + } + ) + self.logger.suite_end() + + def test_end_twice(self): + self.logger.suite_start([]) + self.logger.test_start("test2") + self.logger.test_end("test2", "PASS", expected="PASS") + self.assert_log_equals( + {"action": "test_end", "status": "PASS", "test": "test2"} + ) + self.logger.test_end("test2", "PASS", expected="PASS") + last_item = self.pop_last_item() + self.assertEqual(last_item["action"], "log") + self.assertEqual(last_item["level"], "ERROR") + self.assertTrue( + last_item["message"].startswith( + "test_end for test2 logged while not in progress. Logged with data: {" + ) + ) + self.logger.suite_end() + + def test_suite_start_twice(self): + self.logger.suite_start([]) + self.assert_log_equals({"action": "suite_start", "tests": {"default": []}}) + self.logger.suite_start([]) + last_item = self.pop_last_item() + self.assertEqual(last_item["action"], "log") + self.assertEqual(last_item["level"], "ERROR") + self.logger.suite_end() + + def test_suite_end_no_start(self): + self.logger.suite_start([]) + self.assert_log_equals({"action": "suite_start", "tests": {"default": []}}) + self.logger.suite_end() + self.assert_log_equals({"action": "suite_end"}) + self.logger.suite_end() + last_item = self.pop_last_item() + self.assertEqual(last_item["action"], "log") + self.assertEqual(last_item["level"], "ERROR") + + def test_multiple_loggers_suite_start(self): + logger1 = structuredlog.StructuredLogger("test") + self.logger.suite_start([]) + logger1.suite_start([]) + last_item = self.pop_last_item() + self.assertEqual(last_item["action"], "log") + self.assertEqual(last_item["level"], "ERROR") + + def test_multiple_loggers_test_start(self): + logger1 = structuredlog.StructuredLogger("test") + self.logger.suite_start([]) + self.logger.test_start("test") + logger1.test_start("test") + last_item = self.pop_last_item() + self.assertEqual(last_item["action"], "log") + self.assertEqual(last_item["level"], "ERROR") + + def test_process(self): + self.logger.process_output(1234, "test output") + self.assert_log_equals( + {"action": "process_output", "process": "1234", "data": "test output"} + ) + + def test_process_start(self): + self.logger.process_start(1234) + self.assert_log_equals({"action": "process_start", "process": "1234"}) + + def test_process_exit(self): + self.logger.process_exit(1234, 0) + self.assert_log_equals( + {"action": "process_exit", "process": "1234", "exitcode": 0} + ) + + def test_log(self): + for level in ["critical", "error", "warning", "info", "debug"]: + getattr(self.logger, level)("message") + self.assert_log_equals( + {"action": "log", "level": level.upper(), "message": "message"} + ) + + def test_logging_adapter(self): + import logging + + logging.basicConfig(level="DEBUG") + old_level = logging.root.getEffectiveLevel() + logging.root.setLevel("DEBUG") + + std_logger = logging.getLogger("test") + std_logger.setLevel("DEBUG") + + logger = stdadapter.std_logging_adapter(std_logger) + + try: + for level in ["critical", "error", "warning", "info", "debug"]: + getattr(logger, level)("message") + self.assert_log_equals( + {"action": "log", "level": level.upper(), "message": "message"} + ) + finally: + logging.root.setLevel(old_level) + + def test_add_remove_handlers(self): + handler = TestHandler() + self.logger.add_handler(handler) + self.logger.info("test1") + + self.assert_log_equals({"action": "log", "level": "INFO", "message": "test1"}) + + self.assert_log_equals( + {"action": "log", "level": "INFO", "message": "test1"}, + actual=handler.last_item, + ) + + self.logger.remove_handler(handler) + self.logger.info("test2") + + self.assert_log_equals({"action": "log", "level": "INFO", "message": "test2"}) + + self.assert_log_equals( + {"action": "log", "level": "INFO", "message": "test1"}, + actual=handler.last_item, + ) + + def test_wrapper(self): + file_like = structuredlog.StructuredLogFileLike(self.logger) + + file_like.write("line 1") + + self.assert_log_equals({"action": "log", "level": "INFO", "message": "line 1"}) + + file_like.write("line 2\n") + + self.assert_log_equals({"action": "log", "level": "INFO", "message": "line 2"}) + + file_like.write("line 3\r") + + self.assert_log_equals({"action": "log", "level": "INFO", "message": "line 3"}) + + file_like.write("line 4\r\n") + + self.assert_log_equals({"action": "log", "level": "INFO", "message": "line 4"}) + + def test_shutdown(self): + # explicit shutdown + log = structuredlog.StructuredLogger("test 1") + log.add_handler(self.handler) + log.info("line 1") + self.assert_log_equals( + {"action": "log", "level": "INFO", "message": "line 1", "source": "test 1"} + ) + log.shutdown() + self.assert_log_equals({"action": "shutdown", "source": "test 1"}) + with self.assertRaises(structuredlog.LoggerShutdownError): + log.info("bad log") + with self.assertRaises(structuredlog.LoggerShutdownError): + log.log_raw({"action": "log", "level": "info", "message": "bad log"}) + + # shutdown still applies to new instances + del log + log = structuredlog.StructuredLogger("test 1") + with self.assertRaises(structuredlog.LoggerShutdownError): + log.info("bad log") + + # context manager shutdown + with structuredlog.StructuredLogger("test 2") as log: + log.add_handler(self.handler) + log.info("line 2") + self.assert_log_equals( + { + "action": "log", + "level": "INFO", + "message": "line 2", + "source": "test 2", + } + ) + self.assert_log_equals({"action": "shutdown", "source": "test 2"}) + + # shutdown prevents logging across instances + log1 = structuredlog.StructuredLogger("test 3") + log2 = structuredlog.StructuredLogger("test 3", component="bar") + log1.shutdown() + with self.assertRaises(structuredlog.LoggerShutdownError): + log2.info("line 3") + + +class TestTypeConversions(BaseStructuredTest): + def test_raw(self): + self.logger.log_raw({"action": "suite_start", "tests": [1], "time": "1234"}) + self.assert_log_equals( + {"action": "suite_start", "tests": {"default": ["1"]}, "time": 1234} + ) + self.logger.suite_end() + + def test_tuple(self): + self.logger.suite_start([]) + if six.PY3: + self.logger.test_start( + ( + b"\xf0\x90\x8d\x84\xf0\x90\x8c\xb4\xf0\x90" + b"\x8d\x83\xf0\x90\x8d\x84".decode(), + 42, + "\u16a4", + ) + ) + else: + self.logger.test_start( + ( + "\xf0\x90\x8d\x84\xf0\x90\x8c\xb4\xf0\x90" + "\x8d\x83\xf0\x90\x8d\x84", + 42, + "\u16a4", + ) + ) + self.assert_log_equals( + { + "action": "test_start", + "test": ("\U00010344\U00010334\U00010343\U00010344", "42", "\u16a4"), + } + ) + self.logger.suite_end() + + def test_non_string_messages(self): + self.logger.suite_start([]) + self.logger.info(1) + self.assert_log_equals({"action": "log", "message": "1", "level": "INFO"}) + self.logger.info([1, (2, "3"), "s", "s" + chr(255)]) + if six.PY3: + self.assert_log_equals( + { + "action": "log", + "message": "[1, (2, '3'), 's', 's\xff']", + "level": "INFO", + } + ) + else: + self.assert_log_equals( + { + "action": "log", + "message": "[1, (2, '3'), 's', 's\\xff']", + "level": "INFO", + } + ) + + self.logger.suite_end() + + def test_utf8str_write(self): + with mozfile.NamedTemporaryFile() as logfile: + _fmt = formatters.TbplFormatter() + _handler = handlers.StreamHandler(logfile, _fmt) + self.logger.add_handler(_handler) + self.logger.suite_start([]) + self.logger.info("☺") + logfile.seek(0) + data = logfile.readlines()[-1].strip() + if six.PY3: + self.assertEqual(data.decode(), "☺") + else: + self.assertEqual(data, "☺") + self.logger.suite_end() + self.logger.remove_handler(_handler) + + def test_arguments(self): + self.logger.info(message="test") + self.assert_log_equals({"action": "log", "message": "test", "level": "INFO"}) + + self.logger.suite_start([], run_info={}) + self.assert_log_equals( + {"action": "suite_start", "tests": {"default": []}, "run_info": {}} + ) + self.logger.test_start(test="test1") + self.logger.test_status("subtest1", "FAIL", test="test1", status="PASS") + self.assert_log_equals( + { + "action": "test_status", + "test": "test1", + "subtest": "subtest1", + "status": "PASS", + "expected": "FAIL", + } + ) + self.logger.process_output(123, "data", "test") + self.assert_log_equals( + { + "action": "process_output", + "process": "123", + "command": "test", + "data": "data", + } + ) + self.assertRaises( + TypeError, + self.logger.test_status, + subtest="subtest2", + status="FAIL", + expected="PASS", + ) + self.assertRaises( + TypeError, + self.logger.test_status, + "test1", + "subtest1", + "group1", + "PASS", + "FAIL", + "message", + "stack", + {}, + [], + None, + "unexpected", + ) + self.assertRaises(TypeError, self.logger.test_status, "test1", test="test2") + self.logger.suite_end() + + +class TestComponentFilter(BaseStructuredTest): + def test_filter_component(self): + component_logger = structuredlog.StructuredLogger( + self.logger.name, "test_component" + ) + component_logger.component_filter = handlers.LogLevelFilter(lambda x: x, "info") + + self.logger.debug("Test") + self.assertFalse(self.handler.empty) + self.assert_log_equals({"action": "log", "level": "DEBUG", "message": "Test"}) + self.assertTrue(self.handler.empty) + + component_logger.info("Test 1") + self.assertFalse(self.handler.empty) + self.assert_log_equals( + { + "action": "log", + "level": "INFO", + "message": "Test 1", + "component": "test_component", + } + ) + + component_logger.debug("Test 2") + self.assertTrue(self.handler.empty) + + component_logger.component_filter = None + + component_logger.debug("Test 3") + self.assertFalse(self.handler.empty) + self.assert_log_equals( + { + "action": "log", + "level": "DEBUG", + "message": "Test 3", + "component": "test_component", + } + ) + + def test_filter_default_component(self): + component_logger = structuredlog.StructuredLogger( + self.logger.name, "test_component" + ) + + self.logger.debug("Test") + self.assertFalse(self.handler.empty) + self.assert_log_equals({"action": "log", "level": "DEBUG", "message": "Test"}) + + self.logger.component_filter = handlers.LogLevelFilter(lambda x: x, "info") + + self.logger.debug("Test 1") + self.assertTrue(self.handler.empty) + + component_logger.debug("Test 2") + self.assertFalse(self.handler.empty) + self.assert_log_equals( + { + "action": "log", + "level": "DEBUG", + "message": "Test 2", + "component": "test_component", + } + ) + + self.logger.component_filter = None + + self.logger.debug("Test 3") + self.assertFalse(self.handler.empty) + self.assert_log_equals({"action": "log", "level": "DEBUG", "message": "Test 3"}) + + def test_filter_message_mutuate(self): + def filter_mutate(msg): + if msg["action"] == "log": + msg["message"] = "FILTERED! %s" % msg["message"] + return msg + + self.logger.component_filter = filter_mutate + self.logger.debug("Test") + self.assert_log_equals( + {"action": "log", "level": "DEBUG", "message": "FILTERED! Test"} + ) + self.logger.component_filter = None + + +class TestCommandline(unittest.TestCase): + def setUp(self): + self.logfile = mozfile.NamedTemporaryFile() + + @property + def loglines(self): + self.logfile.seek(0) + return [line.rstrip() for line in self.logfile.readlines()] + + def test_setup_logging(self): + parser = argparse.ArgumentParser() + commandline.add_logging_group(parser) + args = parser.parse_args(["--log-raw=-"]) + logger = commandline.setup_logging("test_setup_logging", args, {}) + self.assertEqual(len(logger.handlers), 1) + + def test_setup_logging_optparse(self): + parser = optparse.OptionParser() + commandline.add_logging_group(parser) + args, _ = parser.parse_args(["--log-raw=-"]) + logger = commandline.setup_logging("test_optparse", args, {}) + self.assertEqual(len(logger.handlers), 1) + self.assertIsInstance(logger.handlers[0], handlers.StreamHandler) + + def test_limit_formatters(self): + parser = argparse.ArgumentParser() + commandline.add_logging_group(parser, include_formatters=["raw"]) + other_formatters = [fmt for fmt in commandline.log_formatters if fmt != "raw"] + # check that every formatter except raw is not present + for fmt in other_formatters: + with self.assertRaises(SystemExit): + parser.parse_args(["--log-%s=-" % fmt]) + with self.assertRaises(SystemExit): + parser.parse_args(["--log-%s-level=error" % fmt]) + # raw is still ok + args = parser.parse_args(["--log-raw=-"]) + logger = commandline.setup_logging("test_setup_logging2", args, {}) + self.assertEqual(len(logger.handlers), 1) + + def test_setup_logging_optparse_unicode(self): + parser = optparse.OptionParser() + commandline.add_logging_group(parser) + args, _ = parser.parse_args(["--log-raw=-"]) + logger = commandline.setup_logging("test_optparse_unicode", args, {}) + self.assertEqual(len(logger.handlers), 1) + self.assertEqual(logger.handlers[0].stream, sys.stdout) + self.assertIsInstance(logger.handlers[0], handlers.StreamHandler) + + def test_logging_defaultlevel(self): + parser = argparse.ArgumentParser() + commandline.add_logging_group(parser) + + args = parser.parse_args(["--log-tbpl=%s" % self.logfile.name]) + logger = commandline.setup_logging("test_fmtopts", args, {}) + logger.info("INFO message") + logger.debug("DEBUG message") + logger.error("ERROR message") + # The debug level is not logged by default. + self.assertEqual([b"INFO message", b"ERROR message"], self.loglines) + + def test_logging_errorlevel(self): + parser = argparse.ArgumentParser() + commandline.add_logging_group(parser) + args = parser.parse_args( + ["--log-tbpl=%s" % self.logfile.name, "--log-tbpl-level=error"] + ) + logger = commandline.setup_logging("test_fmtopts", args, {}) + logger.info("INFO message") + logger.debug("DEBUG message") + logger.error("ERROR message") + + # Only the error level and above were requested. + self.assertEqual([b"ERROR message"], self.loglines) + + def test_logging_debuglevel(self): + parser = argparse.ArgumentParser() + commandline.add_logging_group(parser) + args = parser.parse_args( + ["--log-tbpl=%s" % self.logfile.name, "--log-tbpl-level=debug"] + ) + logger = commandline.setup_logging("test_fmtopts", args, {}) + logger.info("INFO message") + logger.debug("DEBUG message") + logger.error("ERROR message") + # Requesting a lower log level than default works as expected. + self.assertEqual( + [b"INFO message", b"DEBUG message", b"ERROR message"], self.loglines + ) + + def test_unused_options(self): + parser = argparse.ArgumentParser() + commandline.add_logging_group(parser) + args = parser.parse_args(["--log-tbpl-level=error"]) + self.assertRaises( + ValueError, commandline.setup_logging, "test_fmtopts", args, {} + ) + + +class TestBuffer(BaseStructuredTest): + def assert_log_equals(self, expected, actual=None): + if actual is None: + actual = self.pop_last_item() + + all_expected = { + "pid": os.getpid(), + "thread": "MainThread", + "source": "testBuffer", + } + specials = set(["time"]) + + all_expected.update(expected) + for key, value in six.iteritems(all_expected): + self.assertEqual(actual[key], value) + + self.assertEqual(set(all_expected.keys()) | specials, set(actual.keys())) + + def setUp(self): + self.logger = structuredlog.StructuredLogger("testBuffer") + self.handler = handlers.BufferHandler(TestHandler(), message_limit=4) + self.logger.add_handler(self.handler) + + def tearDown(self): + self.logger.remove_handler(self.handler) + + def pop_last_item(self): + return self.handler.inner.items.pop() + + def test_buffer_messages(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.logger.send_message("buffer", "off") + self.logger.test_status("test1", "sub1", status="PASS") + # Even for buffered actions, the buffer does not interfere if + # buffering is turned off. + self.assert_log_equals( + { + "action": "test_status", + "test": "test1", + "status": "PASS", + "subtest": "sub1", + } + ) + self.logger.send_message("buffer", "on") + self.logger.test_status("test1", "sub2", status="PASS") + self.logger.test_status("test1", "sub3", status="PASS") + self.logger.test_status("test1", "sub4", status="PASS") + self.logger.test_status("test1", "sub5", status="PASS") + self.logger.test_status("test1", "sub6", status="PASS") + self.logger.test_status("test1", "sub7", status="PASS") + self.logger.test_end("test1", status="OK") + self.logger.send_message("buffer", "clear") + self.assert_log_equals({"action": "test_end", "test": "test1", "status": "OK"}) + self.logger.suite_end() + + def test_buffer_size(self): + self.logger.suite_start([]) + self.logger.test_start("test1") + self.logger.test_status("test1", "sub1", status="PASS") + self.logger.test_status("test1", "sub2", status="PASS") + self.logger.test_status("test1", "sub3", status="PASS") + self.logger.test_status("test1", "sub4", status="PASS") + self.logger.test_status("test1", "sub5", status="PASS") + self.logger.test_status("test1", "sub6", status="PASS") + self.logger.test_status("test1", "sub7", status="PASS") + + # No test status messages made it to the underlying handler. + self.assert_log_equals({"action": "test_start", "test": "test1"}) + + # The buffer's actual size never grows beyond the specified limit. + self.assertEqual(len(self.handler._buffer), 4) + + self.logger.test_status("test1", "sub8", status="FAIL") + # The number of messages deleted comes back in a list. + self.assertEqual([4], self.logger.send_message("buffer", "flush")) + + # When the buffer is dumped, the failure is the last thing logged + self.assert_log_equals( + { + "action": "test_status", + "test": "test1", + "subtest": "sub8", + "status": "FAIL", + "expected": "PASS", + } + ) + # Three additional messages should have been retained for context + self.assert_log_equals( + { + "action": "test_status", + "test": "test1", + "status": "PASS", + "subtest": "sub7", + } + ) + self.assert_log_equals( + { + "action": "test_status", + "test": "test1", + "status": "PASS", + "subtest": "sub6", + } + ) + self.assert_log_equals( + { + "action": "test_status", + "test": "test1", + "status": "PASS", + "subtest": "sub5", + } + ) + self.assert_log_equals({"action": "suite_start", "tests": {"default": []}}) + + +class TestReader(unittest.TestCase): + def to_file_like(self, obj): + data_str = "\n".join(json.dumps(item) for item in obj) + return StringIO(data_str) + + def test_read(self): + data = [ + {"action": "action_0", "data": "data_0"}, + {"action": "action_1", "data": "data_1"}, + ] + + f = self.to_file_like(data) + self.assertEqual(data, list(reader.read(f))) + + def test_imap_log(self): + data = [ + {"action": "action_0", "data": "data_0"}, + {"action": "action_1", "data": "data_1"}, + ] + + f = self.to_file_like(data) + + def f_action_0(item): + return ("action_0", item["data"]) + + def f_action_1(item): + return ("action_1", item["data"]) + + res_iter = reader.imap_log( + reader.read(f), {"action_0": f_action_0, "action_1": f_action_1} + ) + self.assertEqual( + [("action_0", "data_0"), ("action_1", "data_1")], list(res_iter) + ) + + def test_each_log(self): + data = [ + {"action": "action_0", "data": "data_0"}, + {"action": "action_1", "data": "data_1"}, + ] + + f = self.to_file_like(data) + + count = {"action_0": 0, "action_1": 0} + + def f_action_0(item): + count[item["action"]] += 1 + + def f_action_1(item): + count[item["action"]] += 2 + + reader.each_log( + reader.read(f), {"action_0": f_action_0, "action_1": f_action_1} + ) + + self.assertEqual({"action_0": 1, "action_1": 2}, count) + + def test_handler(self): + data = [ + {"action": "action_0", "data": "data_0"}, + {"action": "action_1", "data": "data_1"}, + ] + + f = self.to_file_like(data) + + test = self + + class ReaderTestHandler(reader.LogHandler): + def __init__(self): + self.action_0_count = 0 + self.action_1_count = 0 + + def action_0(self, item): + test.assertEqual(item["action"], "action_0") + self.action_0_count += 1 + + def action_1(self, item): + test.assertEqual(item["action"], "action_1") + self.action_1_count += 1 + + handler = ReaderTestHandler() + reader.handle_log(reader.read(f), handler) + + self.assertEqual(handler.action_0_count, 1) + self.assertEqual(handler.action_1_count, 1) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozlog/tests/test_terminal_colors.py b/testing/mozbase/mozlog/tests/test_terminal_colors.py new file mode 100644 index 0000000000..2dd72b7d53 --- /dev/null +++ b/testing/mozbase/mozlog/tests/test_terminal_colors.py @@ -0,0 +1,62 @@ +# encoding: utf-8 + +# 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/. + +import sys +from io import StringIO + +import mozunit +import pytest +from mozterm import Terminal + + +@pytest.fixture +def terminal(): + blessed = pytest.importorskip("blessed") + + kind = "xterm-256color" + try: + term = Terminal(stream=StringIO(), force_styling=True, kind=kind) + except blessed.curses.error: + pytest.skip("terminal '{}' not found".format(kind)) + + return term + + +EXPECTED_DICT = { + "log_test_status_fail": "\x1b[31mlog_test_status_fail\x1b(B\x1b[m", + "log_process_output": "\x1b[34mlog_process_output\x1b(B\x1b[m", + "log_test_status_pass": "\x1b[32mlog_test_status_pass\x1b(B\x1b[m", + "log_test_status_unexpected_fail": "\x1b[31mlog_test_status_unexpected_fail\x1b(B\x1b[m", + "log_test_status_known_intermittent": "\x1b[33mlog_test_status_known_intermittent\x1b(B\x1b[m", + "time": "\x1b[36mtime\x1b(B\x1b[m", + "action": "\x1b[33maction\x1b(B\x1b[m", + "pid": "\x1b[36mpid\x1b(B\x1b[m", + "heading": "\x1b[1m\x1b[33mheading\x1b(B\x1b[m", + "sub_heading": "\x1b[33msub_heading\x1b(B\x1b[m", + "error": "\x1b[31merror\x1b(B\x1b[m", + "warning": "\x1b[33mwarning\x1b(B\x1b[m", + "bold": "\x1b[1mbold\x1b(B\x1b[m", + "grey": "\x1b[38;2;190;190;190mgrey\x1b(B\x1b[m", + "normal": "\x1b[90mnormal\x1b(B\x1b[m", + "bright_black": "\x1b[90mbright_black\x1b(B\x1b[m", +} + + +@pytest.mark.skipif( + not sys.platform.startswith("win"), + reason="Only do ANSI Escape Sequence comparisons on Windows.", +) +def test_terminal_colors(terminal): + from mozlog.formatters.machformatter import TerminalColors, color_dict + + actual_dict = TerminalColors(terminal, color_dict) + + for key in color_dict: + assert getattr(actual_dict, key)(key) == EXPECTED_DICT[key] + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/moznetwork/moznetwork/__init__.py b/testing/mozbase/moznetwork/moznetwork/__init__.py new file mode 100644 index 0000000000..622305d8f4 --- /dev/null +++ b/testing/mozbase/moznetwork/moznetwork/__init__.py @@ -0,0 +1,26 @@ +# 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/. + +""" +moznetwork is a very simple module designed for one task: getting the +network address of the current machine. + +Example usage: + +:: + + import moznetwork + + try: + ip = moznetwork.get_ip() + print "The external IP of your machine is '%s'" % ip + except moznetwork.NetworkError: + print "Unable to determine IP address of machine" + raise + +""" + +from .moznetwork import NetworkError, get_ip + +__all__ = ["get_ip", "NetworkError"] diff --git a/testing/mozbase/moznetwork/moznetwork/moznetwork.py b/testing/mozbase/moznetwork/moznetwork/moznetwork.py new file mode 100644 index 0000000000..ecc04d563d --- /dev/null +++ b/testing/mozbase/moznetwork/moznetwork/moznetwork.py @@ -0,0 +1,215 @@ +# 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/. + +import argparse +import array +import re +import socket +import struct +import subprocess +import sys + +import mozinfo +import mozlog +import six + +if mozinfo.isLinux: + import fcntl +if mozinfo.isWin: + import os + + +class NetworkError(Exception): + """Exception thrown when unable to obtain interface or IP.""" + + +def _get_logger(): + logger = mozlog.get_default_logger(component="moznetwork") + if not logger: + logger = mozlog.unstructured.getLogger("moznetwork") + return logger + + +def _get_interface_list(): + """Provides a list of available network interfaces + as a list of tuples (name, ip)""" + logger = _get_logger() + logger.debug("Gathering interface list") + max_iface = 32 # Maximum number of interfaces(arbitrary) + bytes = max_iface * 32 + is_32bit = (8 * struct.calcsize("P")) == 32 # Set Architecture + struct_size = 32 if is_32bit else 40 + + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + names = array.array("B", b"\0" * bytes) + outbytes = struct.unpack( + "iL", + fcntl.ioctl( + s.fileno(), + 0x8912, # SIOCGIFCONF + struct.pack("iL", bytes, names.buffer_info()[0]), + ), + )[0] + if six.PY3: + namestr = names.tobytes() + else: + namestr = names.tostring() + return [ + ( + six.ensure_str(namestr[i : i + 32].split(b"\0", 1)[0]), + socket.inet_ntoa(namestr[i + 20 : i + 24]), + ) + for i in range(0, outbytes, struct_size) + ] + + except IOError: + raise NetworkError("Unable to call ioctl with SIOCGIFCONF") + + +def _proc_matches(args, regex): + """Helper returns the matches of regex in the output of a process created with + the given arguments""" + output = subprocess.Popen( + args=args, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True, + ).stdout.read() + return re.findall(regex, output) + + +def _parse_ifconfig(): + """Parse the output of running ifconfig on mac in cases other methods + have failed""" + logger = _get_logger() + logger.debug("Parsing ifconfig") + + # Attempt to determine the default interface in use. + default_iface = _proc_matches( + ["route", "-n", "get", "default"], r"interface: (\w+)" + ) + + if default_iface: + addr_list = _proc_matches( + ["ifconfig", default_iface[0]], r"inet (\d+.\d+.\d+.\d+)" + ) + if addr_list: + logger.debug( + "Default interface: [%s] %s" % (default_iface[0], addr_list[0]) + ) + if not addr_list[0].startswith("127."): + return addr_list[0] + + # Iterate over plausible interfaces if we didn't find a suitable default. + for iface in ["en%s" % i for i in range(10)]: + addr_list = _proc_matches(["ifconfig", iface], r"inet (\d+.\d+.\d+.\d+)") + if addr_list: + logger.debug("Interface: [%s] %s" % (iface, addr_list[0])) + if not addr_list[0].startswith("127."): + return addr_list[0] + + # Just return any that isn't localhost. If we can't find one, we have + # failed. + addrs = _proc_matches(["ifconfig"], r"inet (\d+.\d+.\d+.\d+)") + try: + return [addr for addr in addrs if not addr.startswith("127.")][0] + except IndexError: + return None + + +def _parse_powershell(): + logger = _get_logger() + logger.debug("Parsing Get-NetIPAdress output via PowerShell") + + try: + cmd = os.path.join( + os.environ.get("SystemRoot", "C:\\WINDOWS"), + "system32", + "windowspowershell", + "v1.0", + "powershell.exe", + ) + output = subprocess.check_output( + [ + cmd, + "(Get-NetIPAddress -AddressFamily IPv4 -AddressState Preferred | Format-List -Property IPAddress)", + ] + ).decode("ascii") + ips = re.findall(r"IPAddress : (\d+.\d+.\d+.\d+)", output) + for ip in ips: + logger.debug("IPAddress: %s" % ip) + if not ip.startswith("127."): + return ip + return None + except FileNotFoundError: + return None + + +def get_ip(): + """Provides an available network interface address, for example + "192.168.1.3". + + A `NetworkError` exception is raised in case of failure.""" + logger = _get_logger() + try: + hostname = socket.gethostname() + try: + logger.debug("Retrieving IP for %s" % hostname) + ips = socket.gethostbyname_ex(hostname)[2] + except socket.gaierror: # for Mac OS X + hostname += ".local" + logger.debug("Retrieving IP for %s" % hostname) + ips = socket.gethostbyname_ex(hostname)[2] + if len(ips) == 1: + ip = ips[0] + elif len(ips) > 1: + logger.debug("Multiple addresses found: %s" % ips) + ip = None + else: + ip = None + except socket.gaierror: + # sometimes the hostname doesn't resolve to an ip address, in which + # case this will always fail + ip = None + + if ip is None or ip.startswith("127."): + if mozinfo.isLinux: + interfaces = _get_interface_list() + for ifconfig in interfaces: + logger.debug("Interface: [%s] %s" % (ifconfig[0], ifconfig[1])) + if ifconfig[0] == "lo": + continue + else: + return ifconfig[1] + elif mozinfo.isMac: + ip = _parse_ifconfig() + elif mozinfo.isWin: + ip = _parse_powershell() + + if ip is None: + raise NetworkError("Unable to obtain network address") + + return ip + + +def get_lan_ip(): + """Deprecated. Please use get_ip() instead.""" + return get_ip() + + +def cli(args=sys.argv[1:]): + parser = argparse.ArgumentParser(description="Retrieve IP address") + mozlog.commandline.add_logging_group( + parser, include_formatters=mozlog.commandline.TEXT_FORMATTERS + ) + + args = parser.parse_args() + mozlog.commandline.setup_logging("mozversion", args, {"mach": sys.stdout}) + + _get_logger().info("IP address: %s" % get_ip()) + + +if __name__ == "__main__": + cli() diff --git a/testing/mozbase/moznetwork/setup.py b/testing/mozbase/moznetwork/setup.py new file mode 100644 index 0000000000..84940e3de0 --- /dev/null +++ b/testing/mozbase/moznetwork/setup.py @@ -0,0 +1,36 @@ +# 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 setuptools import setup + +PACKAGE_VERSION = "1.1.0" + +deps = [ + "mozinfo", + "mozlog >= 6.0", +] + +setup( + name="moznetwork", + version=PACKAGE_VERSION, + description="Library of network utilities for use in Mozilla testing", + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + classifiers=[ + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Development Status :: 5 - Production/Stable", + ], + # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + keywords="mozilla", + author="Mozilla Automation and Tools team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + license="MPL", + packages=["moznetwork"], + include_package_data=True, + zip_safe=False, + install_requires=deps, + entry_points={"console_scripts": ["moznetwork = moznetwork:cli"]}, +) diff --git a/testing/mozbase/moznetwork/tests/manifest.toml b/testing/mozbase/moznetwork/tests/manifest.toml new file mode 100644 index 0000000000..6a2d385c92 --- /dev/null +++ b/testing/mozbase/moznetwork/tests/manifest.toml @@ -0,0 +1,4 @@ +[DEFAULT] +subsuite = "mozbase" + +["test_moznetwork.py"] diff --git a/testing/mozbase/moznetwork/tests/test_moznetwork.py b/testing/mozbase/moznetwork/tests/test_moznetwork.py new file mode 100644 index 0000000000..8a1ee31a2e --- /dev/null +++ b/testing/mozbase/moznetwork/tests/test_moznetwork.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python +""" +Unit-Tests for moznetwork +""" + +import re +import subprocess +from distutils.spawn import find_executable +from unittest import mock + +import mozinfo +import moznetwork +import mozunit +import pytest + + +@pytest.fixture(scope="session") +def ip_addresses(): + """List of IP addresses associated with the host.""" + + # Regex to match IPv4 addresses. + # 0-255.0-255.0-255.0-255, note order is important here. + regexip = re.compile( + "((25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\.){3}" + "(25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)" + ) + + commands = ( + ["ip", "addr", "show"], + ["ifconfig"], + ["ipconfig"], + # Explicitly search '/sbin' because it doesn't always appear + # to be on the $PATH of all systems + ["/sbin/ip", "addr", "show"], + ["/sbin/ifconfig"], + ) + + cmd = None + for command in commands: + if find_executable(command[0]): + cmd = command + break + else: + raise OSError( + "No program for detecting ip address found! Ensure one of 'ip', " + "'ifconfig' or 'ipconfig' exists on your $PATH." + ) + + ps = subprocess.Popen(cmd, stdout=subprocess.PIPE) + standardoutput, _ = ps.communicate() + + # Generate a list of IPs by parsing the output of ip/ifconfig + return [x.group() for x in re.finditer(regexip, standardoutput.decode("UTF-8"))] + + +def test_get_ip(ip_addresses): + """Attempt to test the IP address returned by + moznetwork.get_ip() is valid""" + assert moznetwork.get_ip() in ip_addresses + + +@pytest.mark.skipif(mozinfo.isWin, reason="Test is not supported in Windows") +def test_get_ip_using_get_interface(ip_addresses): + """Test that the control flow path for get_ip() using + _get_interface_list() is works""" + with mock.patch("socket.gethostbyname") as byname: + # Force socket.gethostbyname to return None + byname.return_value = None + assert moznetwork.get_ip() in ip_addresses + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozpower/mozpower/__init__.py b/testing/mozbase/mozpower/mozpower/__init__.py new file mode 100644 index 0000000000..6ac10d076b --- /dev/null +++ b/testing/mozbase/mozpower/mozpower/__init__.py @@ -0,0 +1,24 @@ +# 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 .intel_power_gadget import ( + IPGEmptyFileError, + IPGMissingOutputFileError, + IPGTimeoutError, + IPGUnknownValueTypeError, +) +from .mozpower import MissingProcessorInfoError, MozPower, OsCpuComboMissingError +from .powerbase import IPGExecutableMissingError, PlatformUnsupportedError + +__all__ = [ + "IPGEmptyFileError", + "IPGExecutableMissingError", + "IPGMissingOutputFileError", + "IPGTimeoutError", + "IPGUnknownValueTypeError", + "MissingProcessorInfoError", + "MozPower", + "OsCpuComboMissingError", + "PlatformUnsupportedError", +] diff --git a/testing/mozbase/mozpower/mozpower/intel_power_gadget.py b/testing/mozbase/mozpower/mozpower/intel_power_gadget.py new file mode 100644 index 0000000000..0382084d8f --- /dev/null +++ b/testing/mozbase/mozpower/mozpower/intel_power_gadget.py @@ -0,0 +1,910 @@ +# 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/. +import csv +import os +import re +import subprocess +import threading +import time + +from .mozpowerutils import get_logger + + +class IPGTimeoutError(Exception): + """IPGTimeoutError is raised when we cannot stop Intel Power + Gadget from running. One possble cause of this is not calling + `stop_ipg` through a `finalize_power_measurements` call. The + other possiblity is that IPG failed to stop. + """ + + pass + + +class IPGMissingOutputFileError(Exception): + """IPGMissingOutputFile is raised when a file path is given + to _clean_ipg_file but it does not exist or cannot be found + at the expected location. + """ + + pass + + +class IPGEmptyFileError(Exception): + """IPGEmptyFileError is raised when a file path is given + to _clean_ipg_file and it exists but it is empty (contains + no results to clean). + """ + + pass + + +class IPGUnknownValueTypeError(Exception): + """IPGUnknownValueTypeError is raised when a value within a + given results file (that was cleaned) cannot be converted to + its column's expected data type. + """ + + pass + + +class IntelPowerGadget(object): + """IntelPowerGadget provides methods for using Intel Power Gadget + to measure power consumption. + + :: + + from mozpower.intel_power_gadget import IntelPowerGadget + + # On Mac, the ipg_exe_path should point to '/Applications/Intel Power Gadget/PowerLog' + ipg = IntelPowerGadget(ipg_exe_path, ipg_measure_duration=600, sampling_rate=500) + + ipg.start_ipg() + + # Run tests... + + ipg.stop_ipg() + + # Now process results with IPGResultsHandler by passing it + # ipg.output_files, ipg.output_file_ext, ipg.ipg_measure_duration, + # and ipg.sampling_rate. + """ + + def __init__( + self, + exe_file_path, + ipg_measure_duration=10, + sampling_rate=1000, + output_file_ext=".txt", + file_counter=1, + output_file_path="powerlog", + logger_name="mozpower", + ): + """Initializes the IntelPowerGadget object. + + :param str exe_file_path: path to Intel Power Gadget 'PowerLog' executable. + :param int ipg_measure_duration: duration to run IPG for in seconds. + This does not dictate when the tools shuts down. It only stops when stop_ipg + is called in case the test runs for a non-deterministic amount of time. The + IPG executable requires a duration to be supplied. The machinery in place is + to ensure that IPG keeps recording as long as the experiment runs, so + multiple files may result, which is handled in + IPGResultsHandler._combine_cumulative_rows. Defaults to 10s. + :param int sampling_rate: sampling rate of measurements in milliseconds. + Defaults to 1000ms. + :param output_file_ext: file extension of data being output. Defaults to '.txt'. + :param int file_counter: dictates the start of the file numbering (used + when test time exceeds duration value). Defaults to 0. + :param str output_file_path: path to the output location combined + with the output file prefix. Defaults to current working directory using the + prefix 'powerlog'. + :param str logger_name: logging logger name. Defaults to 'mozpower'. + """ + self._logger = get_logger(logger_name) + + # Output-specific settings + self._file_counter = file_counter + self._output_files = [] + self._output_file_path = output_file_path + self._output_file_ext = output_file_ext + self._output_dir_path, self._output_file_prefix = os.path.split( + self._output_file_path + ) + + # IPG-specific settings + self._ipg_measure_duration = ipg_measure_duration # in seconds + self._sampling_rate = sampling_rate # in milliseconds + self._exe_file_path = exe_file_path + + # Setup thread for measurement gathering + self._thread = threading.Thread( + target=self.run, args=(exe_file_path, ipg_measure_duration) + ) + self._thread.daemon = True + self._running = False + + @property + def output_files(self): + """Returns the list of files produced from running IPG. + + :returns: list + """ + return self._output_files + + @property + def output_file_ext(self): + """Returns the extension of the files produced by IPG. + + :returns: str + """ + return self._output_file_ext + + @property + def output_file_prefix(self): + """Returns the prefix of the files produces by IPG. + + :returns: str + """ + return self._output_file_prefix + + @property + def output_dir_path(self): + """Returns the output directory of the files produced by IPG. + + :returns: str + """ + return self._output_dir_path + + @property + def sampling_rate(self): + """Returns the specified sampling rate. + + :returns: int + """ + return self._sampling_rate + + @property + def ipg_measure_duration(self): + """Returns the IPG measurement duration (see __init__ for description + of what this value does). + + :returns: int + """ + return self._ipg_measure_duration + + def start_ipg(self): + """Starts the thread which runs IPG to start gathering measurements.""" + self._logger.info("Starting IPG thread") + self._stop = False + self._thread.start() + + def stop_ipg(self, wait_interval=10, timeout=200): + """Stops the thread which runs IPG and waits for it to finish it's work. + + :param int wait_interval: interval time (in seconds) at which to check if + IPG is finished. + :param int timeout: time to wait until _wait_for_ipg hits a time out, + in seconds. + """ + self._logger.info("Stopping IPG thread") + self._stop = True + self._wait_for_ipg(wait_interval=wait_interval, timeout=timeout) + + def _wait_for_ipg(self, wait_interval=10, timeout=200): + """Waits for IPG to finish it's final dataset. + + :param int wait_interval: interval time (in seconds) at which to check if + IPG is finished. + :param int timeout: time to wait until this method hits a time out, + in seconds. + :raises: * IPGTimeoutError + """ + timeout_stime = time.time() + while self._running and (time.time() - timeout_stime) < timeout: + self._logger.info( + "Waiting %s sec for Intel Power Gadget to stop" % wait_interval + ) + time.sleep(wait_interval) + if self._running: + raise IPGTimeoutError("Timed out waiting for IPG to stop") + + def _get_output_file_path(self): + """Returns the next output file name to be used. Starts at 1 and increases + at each successive call. Used when the test duration exceeds the specified + duration through ipg_meaure_duration. + + :returns: str + """ + self._output_file_path = os.path.join( + self._output_dir_path, + "%s_%s_%s" + % (self._output_file_prefix, self._file_counter, self._output_file_ext), + ) + self._file_counter += 1 + self._logger.info( + "Creating new file for IPG data measurements: %s" % self._output_file_path + ) + return self._output_file_path + + def run(self, exe_file_path, ipg_measure_duration): + """Runs the IPG measurement gatherer. While stop has not been set to True, + it continuously gathers measurements into separate files that are merged + by IPGResultsHandler once the output_files are passed to it. + + :param str exe_file_path: file path of where to find the IPG executable. + :param int ipg_measure_duration: time to gather measurements for. + """ + self._logger.info("Starting to gather IPG measurements in thread") + self._running = True + + while not self._stop: + outname = self._get_output_file_path() + self._output_files.append(outname) + + try: + subprocess.check_output( + [ + exe_file_path, + "-duration", + str(ipg_measure_duration), + "-resolution", + str(self._sampling_rate), + "-file", + outname, + ], + stderr=subprocess.STDOUT, + ) + except subprocess.CalledProcessError as e: + error_log = str(e) + if e.output: + error_log = e.output.decode() + self._logger.critical( + "Error while running Intel Power Gadget: %s" % error_log + ) + + self._running = False + + +class IPGResultsHandler(object): + """IPGResultsHandler provides methods for cleaning and formatting + the files that were created by IntelPowerGadget. + + :: + + from mozpower.intel_power_gadget import IntelPowerGadget, IPGResultsHandler + + ipg = IntelPowerGadget(ipg_exe_path, ipg_measure_duration=600, sampling_rate=500) + + # Run tests - call start_ipg and stop_ipg... + + ipg_rh = IPGResultsHandler( + ipg.output_files, + ipg.output_dir_path, + ipg_measure_duration=ipg.ipg_measure_duration, + sampling_rate=ipg.sampling_rate + ) + + cleaned_data = ipg_rh.clean_ipg_data() + # you can also get the data from results after calling clean_ipg_data + cleaned_data = ipg_rh.results + + perfherder_data = ipg_rh.format_ipg_data_to_partial_perfherder( + experiment_duration, + test_name + ) + # you can also get the perfherder data from summarized_results + # after calling format_ipg_data_to_partial_perfherder + perfherder_data = ipg_rh.summarized_results + """ + + def __init__( + self, + output_files, + output_dir_path, + ipg_measure_duration=10, + sampling_rate=1000, + logger_name="mozpower", + ): + """Initializes the IPGResultsHandler object. + + :param list output_files: files output by IntelPowerGadget containing + the IPG data. + :param str output_dir_path: location to store cleaned files and merged data. + :param int ipg_measure_duration: length of time that each IPG measurement lasted + in seconds (see IntelPowerGadget for more information on this argument). + Defaults to 10s. + :param int sampling_rate: sampling rate of the measurements in milliseconds. + Defaults to 1000ms. + :param str logger_name: logging logger name. Defaults to 'mozpower'. + """ + self._logger = get_logger(logger_name) + self._results = {} + self._summarized_results = {} + self._cleaned_files = [] + self._csv_header = None + self._merged_output_path = None + self._output_file_prefix = None + self._output_file_ext = None + + self._sampling_rate = sampling_rate + self._ipg_measure_duration = ipg_measure_duration + self._output_files = output_files + self._output_dir_path = output_dir_path + + if self._output_files: + # Gather the output file extension, and prefix + # for the cleaned files, and the merged file. + single_file = self._output_files[0] + _, file = os.path.split(single_file) + self._output_file_ext = "." + file.split(".")[-1] + + # This prefix detection depends on the path names created + # by _get_output_file_path. + integer_re = re.compile(r"""(.*)_[\d+]_%s""" % self._output_file_ext) + match = integer_re.match(file) + if match: + self._output_file_prefix = match.group(1) + else: + self._output_file_prefix = file.split("_")[0] + self._logger.warning( + "Cannot find output file prefix from output file name %s" + "using the following prefix: %s" % (file, self._output_file_prefix) + ) + + @property + def results(self): + """Returns the cleaned IPG data in the form of a dict. + Each key is a measurement name with the values being the list + of measurements. All value lists are sorted in increasing time. + + :returns: dict + """ + return self._results + + @property + def summarized_results(self): + """Returns IPG data in the form of a dict that is formatted + into a perfherder data blob. + + :returns: dict + """ + return self._summarized_results + + @property + def cleaned_files(self): + """Returns a list of cleaned IPG data files that were output + after running clean_ipg_data. + + :returns: list + """ + return self._cleaned_files + + @property + def merged_output_path(self): + """Returns the path to the cleaned, and merged output file. + + :returns: str + """ + return self._merged_output_path + + def _clean_ipg_file(self, file): + """Cleans an individual IPG file and writes out a cleaned file. + + An uncleaned file looks like this (this contains a partial + sample of the time series data): + ``` + "System Time","RDTSC","Elapsed Time (sec)" + "12:11:05:769","61075218263548"," 2.002" + "12:11:06:774","61077822279584"," 3.007" + "12:11:07:778","61080424421708"," 4.011" + "12:11:08:781","61083023535972"," 5.013" + "12:11:09:784","61085623402302"," 6.016" + + "Total Elapsed Time (sec) = 10.029232" + "Measured RDTSC Frequency (GHz) = 2.592" + + "Cumulative Processor Energy_0 (Joules) = 142.337524" + "Cumulative Processor Energy_0 (mWh) = 39.538201" + "Average Processor Power_0 (Watt) = 14.192265" + + "Cumulative IA Energy_0 (Joules) = 121.888000" + "Cumulative IA Energy_0 (mWh) = 33.857778" + "Average IA Power_0 (Watt) = 12.153273" + + "Cumulative DRAM Energy_0 (Joules) = 7.453308" + "Cumulative DRAM Energy_0 (mWh) = 2.070363" + "Average DRAM Power_0 (Watt) = 0.743158" + + "Cumulative GT Energy_0 (Joules) = 0.079834" + "Cumulative GT Energy_0 (mWh) = 0.022176" + "Average GT Power_0 (Watt) = 0.007960" + ``` + + While a cleaned file looks like: + ``` + System Time,RDTSC,Elapsed Time (sec) + 12:11:05:769,61075218263548, 2.002 + 12:11:06:774,61077822279584, 3.007 + 12:11:07:778,61080424421708, 4.011 + 12:11:08:781,61083023535972, 5.013 + 12:11:09:784,61085623402302, 6.016 + ``` + + The first portion of the file before the '"Total Elapsed Time' entry is + considered as the time series which is captured as a dict in the returned + results. + + All the text starting from '"Total Elapsed Time' is removed from the file and + is considered as a summary of the experiment (which is returned). This + portion is ignored in any other processing done by IPGResultsHandler, + and is not saved, unlike the returned results value. + + Note that the new lines are removed from the summary that is returned + (as well as the results). + + :param str file: file to clean. + :returns: a tuple of (dict, list, str) for + (results, summary, clean_output_path) + see method comments above for information on what + this output contains. + :raises: * IPGMissingOutputFileError + * IPGEmptyFileError + """ + self._logger.info("Cleaning IPG data file %s" % file) + + txt = "" + if os.path.exists(file): + with open(file, "r") as f: + txt = f.read() + else: + # This should never happen, so prevent IPGResultsHandler + # from continuing to clean files if it does occur. + raise IPGMissingOutputFileError( + "The following file does not exist so it cannot be cleaned: %s " % file + ) + + if txt == "": + raise IPGEmptyFileError( + "The following file is empty so it cannot be cleaned: %s" % file + ) + + # Split the time series data from the summary + tseries, summary = re.split('"Total Elapsed Time', txt) + + # Clean the summary + summary = '"Total Elapsed Time' + summary + summary = [ + line for line in re.split(r"""\n""", summary.replace("\r", "")) if line + ] + + # Clean the time series data, store the clean rows to write out later, + # and format the rows into a dict entry for each measure. + results = {} + clean_rows = [] + csv_header = None + for c, row in enumerate( + csv.reader(tseries.split("\n"), quotechar=str('"'), delimiter=str(",")) + ): + if not row: + continue + + # Make sure we don't have any bad line endings + # contaminating the cleaned rows. + fmt_row = [ + val.replace("\n", "") + .replace("\t", "") + .replace("\r", "") + .replace("\\n", "") + .strip() + for val in row + ] + + if not fmt_row or not any(fmt_row): + continue + + clean_rows.append(fmt_row) + if c == 0: + csv_header = fmt_row + for col in fmt_row: + results[col] = [] + continue + for i, col in enumerate(fmt_row): + results[csv_header[i]].append(col) + + # Write out the cleaned data and check to make sure + # the csv header hasn't changed mid-experiment + _, fname = os.path.split(file) + clean_output_path = os.path.join( + self._output_dir_path, + fname.replace( + self._output_file_ext, + "_clean.%s" % self._output_file_ext.replace(".", ""), + ), + ) + self._logger.info("Writing cleaned IPG results to %s" % clean_output_path) + try: + with open(clean_output_path, "w") as csvfile: + writer = csv.writer(csvfile) + for count, row in enumerate(clean_rows): + if count == 0: + if self._csv_header is None: + self._csv_header = row + elif self._csv_header != row: + self._logger.warning( + "CSV Headers from IPG data have changed during the experiment " + "expected: %s; got: %s" + % (str(self._csv_header), str(row)) + ) + writer.writerow(row) + except Exception as e: + self._logger.warning( + "Could not write out cleaned results of %s to %s due to the following error" + ", skipping this step: %s" % (file, clean_output_path, str(e)) + ) + + # Check to make sure the expected number of samples + # exist in the file and that the columns have the correct + # data type. + column_datatypes = {"System Time": str, "RDTSC": int, "default": float} + # pylint --py3k W1619 + expected_samples = int( + self._ipg_measure_duration / (float(self._sampling_rate) / 1000) + ) + for key in results: + if len(results[key]) != expected_samples: + self._logger.warning( + "Unexpected number of samples in %s for column %s - " + "expected: %s, got: %s" + % (clean_output_path, key, expected_samples, len(results[key])) + ) + + dtype = column_datatypes["default"] + if key in column_datatypes: + dtype = column_datatypes[key] + + for val in results[key]: + try: + # Check if converting from a string to its expected data + # type works, if not, it's not the expected data type. + dtype(val) + except ValueError as e: + raise IPGUnknownValueTypeError( + "Cleaned file %s entry %s in column %s has unknown type " + "instead of the expected type %s - data corrupted, " + "cannot continue: %s" + % (clean_output_path, str(val), key, dtype.__name__, str(e)) + ) + + # Check to make sure that IPG measured for the expected + # amount of time. + etime = "Elapsed Time (sec)" + if etime in results: + total_time = int(float(results[etime][-1])) + if total_time != self._ipg_measure_duration: + self._logger.warning( + "Elapsed time found in file %s is different from expected length - " + "expected: %s, got: %s" + % (clean_output_path, self._ipg_measure_duration, total_time) + ) + else: + self._logger.warning( + "Missing 'Elapsed Time (sec)' in file %s" % clean_output_path + ) + + return results, summary, clean_output_path + + def _combine_cumulative_rows(self, cumulatives): + """Combine cumulative rows from multiple IPG files into + a single time series. + + :param list cumulatives: list of cumulative time series collected + over time, on for each file. Must be ordered in increasing time. + :returns: list + """ + combined_cumulatives = [] + + val_mod = 0 + for count, cumulative in enumerate(cumulatives): + # Add the previous file's maximum cumulative value + # to the current one + mod_cumulative = [val_mod + float(val) for val in cumulative] + val_mod = mod_cumulative[-1] + combined_cumulatives.extend(mod_cumulative) + + return combined_cumulatives + + def clean_ipg_data(self): + """Cleans all IPG files, or data, that was produced by an IntelPowerGadget object. + Returns a dict containing the merged data from all files. + + :returns: dict + """ + self._logger.info("Cleaning IPG data...") + + if not self._output_files: + self._logger.warning("No IPG files to clean.") + return + # If this is called a second time for the same set of files, + # then prevent it from duplicating the cleaned file entries. + if self._cleaned_files: + self._cleaned_files = [] + + # Clean each file individually and gather the results + all_results = [] + for file in self._output_files: + results, summary, output_file_path = self._clean_ipg_file(file) + self._cleaned_files.append(output_file_path) + all_results.append(results) + + # Merge all the results into a single result + combined_results = {} + for measure in all_results[0]: + lmeasure = measure.lower() + if "cumulative" not in lmeasure and "elapsed time" not in lmeasure: + # For measures which are not cumulative, or elapsed time, + # combine them without changing the data. + for count, result in enumerate(all_results): + if "system time" in lmeasure or "rdtsc" in lmeasure: + new_results = result[measure] + else: + new_results = [float(val) for val in result[measure]] + + if count == 0: + combined_results[measure] = new_results + else: + combined_results[measure].extend(new_results) + else: + # For cumulative, and elapsed time measures, we need to + # modify all values - see _combine_cumulative_rows for + # more information on this procedure. + cumulatives = [result[measure] for result in all_results] + self._logger.info( + "Combining cumulative rows for '%s' measure" % measure + ) + combined_results[measure] = self._combine_cumulative_rows(cumulatives) + + # Write merged results to a new file + merged_output_path = os.path.join( + self._output_dir_path, + "%s_merged.%s" + % (self._output_file_prefix, self._output_file_ext.replace(".", "")), + ) + + self._merged_output_path = merged_output_path + self._logger.info("Writing merged IPG results to %s" % merged_output_path) + try: + with open(merged_output_path, "w") as csvfile: + writer = csv.writer(csvfile) + writer.writerow(self._csv_header) + + # Use _csv_header list to keep the header ordering + # the same as the cleaned and raw files. + first_key = list(combined_results.keys())[0] + for row_count, _ in enumerate(combined_results[first_key]): + row = [] + for measure in self._csv_header: + row.append(combined_results[measure][row_count]) + writer.writerow(row) + except Exception as e: + self.merged_output_path = None + self._logger.warning( + "Could not write out merged results to %s due to the following error" + ", skipping this step: %s" % (merged_output_path, str(e)) + ) + + # Check that the combined results have the expected number of samples. + # pylint W16919 + expected_samples = int( + self._ipg_measure_duration / (float(self._sampling_rate) / 1000) + ) + combined_expected_samples = len(self._cleaned_files) * expected_samples + for key in combined_results: + if len(combined_results[key]) != combined_expected_samples: + self._logger.warning( + "Unexpected number of merged samples in %s for column %s - " + "expected: %s, got: %s" + % ( + merged_output_path, + key, + combined_expected_samples, + len(results[key]), + ) + ) + + self._results = combined_results + return self._results + + def format_ipg_data_to_partial_perfherder(self, duration, test_name): + """Format the merged IPG data produced by clean_ipg_data into a + partial/incomplete perfherder data blob. Returns the perfherder + data and stores it in _summarized_results. + + This perfherder data still needs more information added to it before it + can be validated against the perfherder schema. The entries returned + through here are missing many required fields. Furthermore, the 'values' + entries need to be formatted into 'subtests'. + + Here is a sample of a complete perfherder data which uses + the 'utilization' results within the subtests: + ``` + { + "framework": {"name": "raptor"}, + "type": "power", + "unit": "mWh" + "suites": [ + { + "name": "raptor-tp6-amazon-firefox-power", + "lowerIsBetter": true, + "alertThreshold": 2.0, + "subtests": [ + { + "lowerIsBetter": true, + "unit": "%", + "name": "raptor-tp6-youtube-firefox-power-cpu", + "value": 14.409090909090908, + "alertThreshold": 2.0 + }, + { + "lowerIsBetter": true, + "unit": "%", + "name": "raptor-tp6-youtube-firefox-power-gpu", + "value": 20.1, + "alertThreshold": 2.0 + }, + ] + } + ] + } + ``` + + To obtain data that is formatted to a complete perfherder data blob, + see get_complete_perfherder_data in MozPower. + + :param float duration: the actual duration of the test in case some + data needs to be cut off from the end. + :param str test_name: the name of the test. + :returns: dict + """ + self._logger.info("Formatting cleaned IPG data into partial perfherder data") + + if not self._results: + self._logger.warning( + "No merged results found - cannot format data to perfherder format." + ) + return + + def replace_measure_name(name): + """Replaces the long IPG names with shorter versions. + Returns the given name if no conversions exist. + + :param str name: name of the entry to replace. + :returns: str + """ + lname = name.lower() + if "ia " in lname: + return "processor-cores" + elif "processor " in lname: + return "processor-package" + elif "gt " in lname: + return "gpu" + elif "dram " in lname: + return "dram" + else: + return name + + # Cut out entries which surpass the test duration. + # Occurs when IPG continues past the call to stop it. + cut_results = self._results + if duration: + cutoff_index = 0 + for count, etime in enumerate(self._results["Elapsed Time (sec)"]): + if etime > duration: + cutoff_index = count + break + if cutoff_index > 0: + for measure in self._results: + cut_results[measure] = self._results[measure][:cutoff_index] + + # Get the cumulative power used in mWh + cumulative_mwh = {} + for measure in cut_results: + if "cumulative" in measure.lower() and "mwh" in measure.lower(): + cumulative_mwh[replace_measure_name(measure)] = float( + cut_results[measure][-1] + ) + + # Get the power usage rate in Watts + watt_usage = {} + for measure in cut_results: + if "watt" in measure.lower() and "limit" not in measure.lower(): + # pylint --py3k W1619 + watt_usage[replace_measure_name(measure) + "-avg"] = sum( + [float(val) for val in cut_results[measure]] + ) / len(cut_results[measure]) + watt_usage[replace_measure_name(measure) + "-max"] = max( + [float(val) for val in cut_results[measure]] + ) + + # Get average CPU and GPU utilization + average_utilization = {} + for utilization in ("CPU Utilization(%)", "GT Utilization(%)"): + if utilization not in cut_results: + self._logger.warning( + "Could not find measurements for: %s" % utilization + ) + continue + + utilized_name = utilization.lower() + if "cpu " in utilized_name: + utilized_name = "cpu" + elif "gt " in utilized_name: + utilized_name = "gpu" + + # pylint --py3k W1619 + average_utilization[utilized_name] = sum( + [float(val) for val in cut_results[utilization]] + ) / len(cut_results[utilization]) + + # Get average and maximum CPU and GPU frequency + frequency_info = {"cpu": {}, "gpu": {}} + for frequency_measure in ("CPU Frequency_0(MHz)", "GT Frequency(MHz)"): + if frequency_measure not in cut_results: + self._logger.warning( + "Could not find measurements for: %s" % frequency_measure + ) + continue + + fmeasure_name = frequency_measure.lower() + if "cpu " in fmeasure_name: + fmeasure_name = "cpu" + elif "gt " in fmeasure_name: + fmeasure_name = "gpu" + # pylint --py3k W1619 + + frequency_info[fmeasure_name]["favg"] = sum( + [float(val) for val in cut_results[frequency_measure]] + ) / len(cut_results[frequency_measure]) + + frequency_info[fmeasure_name]["fmax"] = max( + [float(val) for val in cut_results[frequency_measure]] + ) + + frequency_info[fmeasure_name]["fmin"] = min( + [float(val) for val in cut_results[frequency_measure]] + ) + + summarized_results = { + "utilization": { + "type": "power", + "test": str(test_name) + "-utilization", + "unit": "%", + "values": average_utilization, + }, + "power-usage": { + "type": "power", + "test": str(test_name) + "-cumulative", + "unit": "mWh", + "values": cumulative_mwh, + }, + "power-watts": { + "type": "power", + "test": str(test_name) + "-watts", + "unit": "W", + "values": watt_usage, + }, + "frequency-cpu": { + "type": "power", + "test": str(test_name) + "-frequency-cpu", + "unit": "MHz", + "values": frequency_info["cpu"], + }, + "frequency-gpu": { + "type": "power", + "test": str(test_name) + "-frequency-gpu", + "unit": "MHz", + "values": frequency_info["gpu"], + }, + } + + self._summarized_results = summarized_results + return self._summarized_results diff --git a/testing/mozbase/mozpower/mozpower/macintelpower.py b/testing/mozbase/mozpower/mozpower/macintelpower.py new file mode 100644 index 0000000000..18629b4a9a --- /dev/null +++ b/testing/mozbase/mozpower/mozpower/macintelpower.py @@ -0,0 +1,92 @@ +# 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/. +import time + +from .intel_power_gadget import IntelPowerGadget, IPGResultsHandler +from .powerbase import PowerBase + + +class MacIntelPower(PowerBase): + """MacIntelPower is the OS and CPU dependent class for + power measurement gathering on Mac Intel-based hardware. + + :: + + from mozpower.macintelpower import MacIntelPower + + # duration and output_file_path are used in IntelPowerGadget + mip = MacIntelPower(ipg_measure_duration=600, output_file_path='power-testing') + + mip.initialize_power_measurements() + # Run test... + mip.finalize_power_measurements(test_name='raptor-test-name') + + perfherder_data = mip.get_perfherder_data() + """ + + def __init__(self, logger_name="mozpower", **kwargs): + """Initializes the MacIntelPower object. + + :param str logger_name: logging logger name. Defaults to 'mozpower'. + :param dict kwargs: optional keyword arguments passed to IntelPowerGadget. + """ + PowerBase.__init__(self, logger_name=logger_name, os="darwin", cpu="intel") + self.ipg = IntelPowerGadget(self.ipg_path, **kwargs) + self.ipg_results_handler = None + self.start_time = None + self.end_time = None + self.perfherder_data = {} + + def initialize_power_measurements(self): + """Starts power measurement gathering through IntelPowerGadget.""" + self._logger.info("Initializing power measurements...") + + # Start measuring + self.ipg.start_ipg() + + # Record start time to get an approximation of run time + self.start_time = time.time() + + def finalize_power_measurements( + self, test_name="power-testing", output_dir_path="", **kwargs + ): + """Stops power measurement gathering through IntelPowerGadget, cleans the data, + and produces partial perfherder formatted data that is stored in perfherder_data. + + :param str test_name: name of the test that was run. + :param str output_dir_path: directory to store output files. + :param dict kwargs: contains optional arguments to stop_ipg. + """ + self._logger.info("Finalizing power measurements...") + self.end_time = time.time() + + # Wait until Intel Power Gadget is done, then clean the data + # and then format it + self.ipg.stop_ipg(**kwargs) + + # Handle the results and format them to a partial perfherder format + if not output_dir_path: + output_dir_path = self.ipg.output_dir_path + + self.ipg_results_handler = IPGResultsHandler( + self.ipg.output_files, + output_dir_path, + ipg_measure_duration=self.ipg.ipg_measure_duration, + sampling_rate=self.ipg.sampling_rate, + logger_name=self.logger_name, + ) + + self.ipg_results_handler.clean_ipg_data() + self.perfherder_data = ( + self.ipg_results_handler.format_ipg_data_to_partial_perfherder( + self.end_time - self.start_time, test_name + ) + ) + + def get_perfherder_data(self): + """Returns the perfherder data output that was produced. + + :returns: dict + """ + return self.perfherder_data diff --git a/testing/mozbase/mozpower/mozpower/mozpower.py b/testing/mozbase/mozpower/mozpower/mozpower.py new file mode 100644 index 0000000000..2333fc84ee --- /dev/null +++ b/testing/mozbase/mozpower/mozpower/mozpower.py @@ -0,0 +1,376 @@ +# 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/. +import os +import platform +import re +import subprocess + +import six + +from .macintelpower import MacIntelPower +from .mozpowerutils import average_summary, frequency_summary, get_logger, sum_summary + +OSCPU_COMBOS = { + "darwin-intel": MacIntelPower, +} + +SUMMARY_METHODS = { + "utilization": average_summary, + "power-usage": sum_summary, + "power-watts": frequency_summary, + "frequency-cpu": frequency_summary, + "frequency-gpu": frequency_summary, + "default": sum_summary, +} + + +class OsCpuComboMissingError(Exception): + """OsCpuComboMissingError is raised when we cannot find + a class responsible for the OS, and CPU combination that + was detected. + """ + + pass + + +class MissingProcessorInfoError(Exception): + """MissingProcessorInfoError is raised when we cannot find + the processor information on the machine. This is raised when + the file is missing (mentioned in the error message) or if + an exception occurs when we try to gather the information + from the file. + """ + + pass + + +class MozPower(object): + """MozPower provides an OS and CPU independent interface + for initializing, finalizing, and gathering power measurement + data from OS+CPU combo-dependent measurement classes. The combo + is detected automatically, and the correct class is instantiated + based on the OSCPU_COMBOS list. If it cannot be found, an + OsCpuComboMissingError will be raised. + + If a newly added power measurer does not have the required functions + `initialize_power_measurements`, `finalize_power_measurements`, + or `get_perfherder_data`, then a NotImplementedError will be + raised. + + Android power measurements are currently not supported by this + module. + + :: + + from mozpower import MozPower + + mp = MozPower(output_file_path='dir/power-testing') + + mp.initialize_power_measurements() + # Run test... + mp.finalize_power_measurements(test_name='raptor-test-name') + + perfherder_data = mp.get_perfherder_data() + """ + + def __init__( + self, + android=False, + logger_name="mozpower", + output_file_path="power-testing", + **kwargs + ): + """Initializes the MozPower object, detects OS and CPU (if not android), + and instatiates the appropriate combo-dependent class for measurements. + + :param bool android: run android power measurer. + :param str logger_name: logging logger name. Defaults to 'mozpower'. + :param str output_file_path: path to where output files will be stored. + Can include a prefix for the output files, i.e. 'dir/raptor-test' + would output data to 'dir' with the prefix 'raptor-test'. + Defaults to 'power-testing', the current directory using + the prefix 'power-testing'. + :param dict kwargs: optional keyword arguments passed to power measurer. + :raises: * OsCpuComboMissingError + * NotImplementedError + """ + self.measurer = None + self._os = None + self._cpu = None + self._logger = get_logger(logger_name) + + if android: + self._logger.error("Android power usage measurer has not been implemented") + raise NotImplementedError + else: + self._os = self._get_os().lower() + cpu = six.text_type(self._get_processor_info().lower()) + + if "intel" in cpu: + self._cpu = "intel" + else: + self._cpu = "arm64" + + # OS+CPU combos are specified through strings such as 'darwin-intel' + # for mac power measurement on intel-based machines. If none exist in + # OSCPU_COMBOS, OsCpuComboMissingError will be raised. + measurer = None + oscpu_combo = "%s-%s" % (self._os, self._cpu) + if oscpu_combo in OSCPU_COMBOS: + measurer = OSCPU_COMBOS[oscpu_combo] + else: + raise OsCpuComboMissingError( + "Cannot find OS+CPU combo for %s" % oscpu_combo + ) + + if measurer: + self._logger.info( + "Intializing measurer %s for %s power measurements, see below for errors..." + % (measurer.__name__, oscpu_combo) + ) + self.measurer = measurer( + logger_name=logger_name, output_file_path=output_file_path, **kwargs + ) + + def _get_os(self): + """Returns the operating system of the machine being tested. platform.system() + returns 'darwin' on MacOS, 'windows' on Windows, and 'linux' on Linux systems. + + :returns: str + """ + return platform.system() + + def _get_processor_info(self): + """Returns the processor model type of the machine being tested. + Each OS has it's own way of storing this information. Raises + MissingProcessorInfoError if we cannot get the processor info + from the expected locations. + + :returns: str + :raises: * MissingProcessorInfoError + """ + model = "" + + if self._get_os() == "Windows": + model = platform.processor() + + elif self._get_os() == "Darwin": + proc_info_path = "/usr/sbin/sysctl" + command = [proc_info_path, "-n", "machdep.cpu.brand_string"] + + if not os.path.exists(proc_info_path): + raise MissingProcessorInfoError( + "Missing processor info file for darwin platform, " + "expecting it here %s" % proc_info_path + ) + + try: + model = subprocess.check_output(command).strip() + except subprocess.CalledProcessError as e: + error_log = str(e) + if e.output: + error_log = e.output.decode() + raise MissingProcessorInfoError( + "Error while attempting to get darwin processor information " + "from %s (exists) with the command %s: %s" + % (proc_info_path, str(command), error_log) + ) + + elif self._get_os() == "Linux": + proc_info_path = "/proc/cpuinfo" + model_re = re.compile(r""".*model name\s+[:]\s+(.*)\s+""") + + if not os.path.exists(proc_info_path): + raise MissingProcessorInfoError( + "Missing processor info file for linux platform, " + "expecting it here %s" % proc_info_path + ) + + try: + with open(proc_info_path) as cpuinfo: + for line in cpuinfo: + if not line.strip(): + continue + match = model_re.match(line) + if match: + model = match.group(1) + if not model: + raise Exception( + "No 'model name' entries found in the processor info file" + ) + except Exception as e: + raise MissingProcessorInfoError( + "Error while attempting to get linux processor information " + "from %s (exists): %s" % (proc_info_path, str(e)) + ) + + return model + + def initialize_power_measurements(self, **kwargs): + """Starts the power measurements by calling the power measurer's + `initialize_power_measurements` function. + + :param dict kwargs: keyword arguments for power measurer initialization + function if they are needed. + """ + if self.measurer is None: + return + self.measurer.initialize_power_measurements(**kwargs) + + def finalize_power_measurements(self, **kwargs): + """Stops the power measurements by calling the power measurer's + `finalize_power_measurements` function. + + :param dict kwargs: keyword arguments for power measurer finalization + function if they are needed. + """ + if self.measurer is None: + return + self.measurer.finalize_power_measurements(**kwargs) + + def get_perfherder_data(self): + """Returns the partial perfherder data output produced by the measurer. + For a complete perfherder data blob, see get_full_perfherder_data. + + :returns: dict + """ + if self.measurer is None: + return + return self.measurer.get_perfherder_data() + + def _summarize_values(self, datatype, values): + """Summarizes the given values based on the type of the + data. See SUMMARY_METHODS for the methods used for each + known data type. Defaults to using the sum of the values + when a data type cannot be found. + + Data type entries in SUMMARY_METHODS are case-sensitive. + + :param str datastype: the measurement type being summarized. + :param list values: the values to summarize. + :returns: float + """ + if datatype not in SUMMARY_METHODS: + self._logger.warning( + "Missing summary method for data type %s, defaulting to sum" % datatype + ) + datatype = "default" + + summary_func = SUMMARY_METHODS[datatype] + return summary_func(values) + + def get_full_perfherder_data( + self, framework, lowerisbetter=True, alertthreshold=2.0 + ): + """Returns a list of complete perfherder data blobs compiled from the + partial perfherder data blob returned from the measurer. Each key entry + (measurement type) in the partial perfherder data is parsed into its + own suite within a single perfherder data blob. + + For example, a partial perfherder data blob such as: + + :: + + { + 'utilization': {<perfherder_data>}, + 'power-usage': {<perfherder_data>} + } + + would produce two suites within a single perfherder data blobs - + one for utilization, and one for power-usage. + + Note that the 'values' entry must exist, otherwise the measurement + type is skipped. Furthermore, if 'name', 'unit', or 'type' is missing + we default to: + + :: + + { + 'name': 'mozpower', + 'unit': 'mWh', + 'type': 'power' + } + + Subtests produced for each sub-suite (measurement type), have the naming + pattern: <measurement_type>-<measured_name> + + Utilization of cpu would have the following name: 'utilization-cpu' + Power-usage for cpu has the following name: 'power-usage-cpu' + + :param str framework: name of the framework being tested, i.e. 'raptor'. + :param bool lowerisbetter: if set to true, low values are better than high ones. + :param float alertthreshold: determines the crossing threshold at + which an alert is generated. + :returns: dict + """ + if self.measurer is None: + return + + # Get the partial data, and the measurers name for + # logging purposes. + partial_perfherder_data = self.get_perfherder_data() + measurer_name = self.measurer.__class__.__name__ + + suites = [] + perfherder_data = {"framework": {"name": framework}, "suites": suites} + + for measurement_type in partial_perfherder_data: + self._logger.info("Summarizing %s data" % measurement_type) + dataset = partial_perfherder_data[measurement_type] + + # Skip this measurement type if the 'values' entry + # doesn't exist, and output a warning. + if "values" not in dataset: + self._logger.warning( + "Missing 'values' entry in partial perfherder data for measurement type %s " + "obtained from %s. This measurement type will not be processed." + % (measurement_type, measurer_name) + ) + continue + + # Get the settings, if they exist, otherwise output + # a warning and use a default entry. + settings = {"test": "mozpower", "unit": "mWh", "type": "power"} + + for setting in settings: + if setting in dataset: + settings[setting] = dataset[setting] + else: + self._logger.warning( + "Missing '%s' entry in partial perfherder data for measurement type %s " + "obtained from %s, using %s as the default" + % (setting, measurement_type, measurer_name, settings[setting]) + ) + + subtests = [] + suite = { + "name": "%s-%s" % (settings["test"], measurement_type), + "type": settings["type"], + "value": 0, + "subtests": subtests, + "lowerIsBetter": lowerisbetter, + "unit": settings["unit"], + "alertThreshold": alertthreshold, + } + + # Parse the 'values' entries into subtests + values = [] + for measure in dataset["values"]: + value = dataset["values"][measure] + subtest = { + "name": "%s-%s" % (measurement_type, measure), + "value": float(value), + "lowerIsBetter": lowerisbetter, + "alertThreshold": alertthreshold, + "unit": settings["unit"], + } + values.append((value, measure)) + subtests.append(subtest) + + # Summarize the data based on the measurement type + if len(values) > 0: + suite["value"] = self._summarize_values(measurement_type, values) + suites.append(suite) + + return perfherder_data diff --git a/testing/mozbase/mozpower/mozpower/mozpowerutils.py b/testing/mozbase/mozpower/mozpower/mozpowerutils.py new file mode 100644 index 0000000000..bedf272a0e --- /dev/null +++ b/testing/mozbase/mozpower/mozpower/mozpowerutils.py @@ -0,0 +1,58 @@ +# 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/. + + +def get_logger(logger_name): + """Returns the logger that should be used based on logger_name. + Defaults to the logging logger if mozlog cannot be imported. + + :returns: mozlog or logging logger object + """ + logger = None + try: + import mozlog + + logger = mozlog.get_default_logger(logger_name) + except ImportError: + pass + + if logger is None: + import logging + + logging.basicConfig() + logger = logging.getLogger(logger_name) + return logger + + +def average_summary(values): + """Averages all given values. + + :param list values: list of values to average. + :returns: float + """ + # pylint --py3k W1619 + return sum([float(v[0]) for v in values]) / len(values) + + +def sum_summary(values): + """Adds all values together. + + :param list values: list of values to sum. + :returns: float + """ + return sum([float(v[0]) for v in values]) + + +def frequency_summary(values): + """Returns the average frequency as the summary value. + + :param list values: list of values to search in. + :returns: float + """ + avgfreq = 0 + for val, name in values: + if "avg" in name: + avgfreq = float(val) + break + return avgfreq diff --git a/testing/mozbase/mozpower/mozpower/powerbase.py b/testing/mozbase/mozpower/mozpower/powerbase.py new file mode 100644 index 0000000000..3eb91e3e3b --- /dev/null +++ b/testing/mozbase/mozpower/mozpower/powerbase.py @@ -0,0 +1,122 @@ +# 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/. +import os + +from .mozpowerutils import get_logger + + +class IPGExecutableMissingError(Exception): + """IPGExecutableMissingError is raised when we cannot find + the executable for Intel Power Gadget at the expected location. + """ + + pass + + +class PlatformUnsupportedError(Exception): + """PlatformUnsupportedError is raised when we cannot find + an expected IPG path for the OS being tested. + """ + + pass + + +class PowerBase(object): + """PowerBase provides an interface for power measurement objects + that depend on the os and cpu. When using this class as a base class + the `initialize_power_measurements`, `finalize_power_measurements`, + and `get_perfherder_data` functions must be implemented, otherwise + a NotImplementedError will be raised. + + PowerBase should only be used as the base class for other + classes and should not be instantiated directly. To enforce this + restriction calling PowerBase's constructor will raise a + NonImplementedError exception. + + :: + + from mozpower.powerbase import PowerBase + + try: + pb = PowerBase() + except NotImplementedError: + print "PowerBase cannot be instantiated." + + """ + + def __init__(self, logger_name="mozpower", os=None, cpu=None): + """Initializes the PowerBase object. + + :param str logger_name: logging logger name. Defaults to 'mozpower'. + :param str os: operating system being tested. Defaults to None. + :param str cpu: cpu type being tested (either intel or arm64). Defaults to None. + :raises: * NotImplementedError + """ + if self.__class__ == PowerBase: + raise NotImplementedError + + self._logger_name = logger_name + self._logger = get_logger(logger_name) + self._os = os + self._cpu = cpu + self._ipg_path = self.get_ipg_path() + + @property + def ipg_path(self): + return self._ipg_path + + @property + def logger_name(self): + return self._logger_name + + def initialize_power_measurements(self): + """Starts power measurement gathering, must be implemented by subclass. + + :raises: * NotImplementedError + """ + raise NotImplementedError + + def finalize_power_measurements(self, test_name="power-testing", **kwargs): + """Stops power measurement gathering, must be implemented by subclass. + + :raises: * NotImplementedError + """ + raise NotImplementedError + + def get_perfherder_data(self): + """Returns the perfherder data output produced from the tests, must + be implemented by subclass. + + :raises: * NotImplementedError + """ + raise NotImplementedError + + def get_ipg_path(self): + """Returns the path to where we expect to find Intel Power Gadget + depending on the OS being tested. Raises a PlatformUnsupportedError + if the OS being tested is unsupported, and raises a IPGExecutableMissingError + if the Intel Power Gadget executable cannot be found at the expected + location. + + :returns: str + :raises: * IPGExecutableMissingError + * PlatformUnsupportedError + """ + if self._cpu != "intel": + return None + + if self._os == "darwin": + exe_path = "/Applications/Intel Power Gadget/PowerLog" + else: + raise PlatformUnsupportedError( + "%s platform currently not supported for Intel Power Gadget measurements" + % self._os + ) + if not os.path.exists(exe_path): + raise IPGExecutableMissingError( + "Intel Power Gadget is not installed, or cannot be found at the location %s" + % exe_path + ) + + return exe_path diff --git a/testing/mozbase/mozpower/setup.cfg b/testing/mozbase/mozpower/setup.cfg new file mode 100644 index 0000000000..2a9acf13da --- /dev/null +++ b/testing/mozbase/mozpower/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 diff --git a/testing/mozbase/mozpower/setup.py b/testing/mozbase/mozpower/setup.py new file mode 100644 index 0000000000..4d1cb0cb00 --- /dev/null +++ b/testing/mozbase/mozpower/setup.py @@ -0,0 +1,34 @@ +# 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 setuptools import setup + +PACKAGE_NAME = "mozpower" +PACKAGE_VERSION = "1.1.2" + +deps = ["mozlog >= 6.0", "mozdevice >= 4.0.0,<5"] + +setup( + name=PACKAGE_NAME, + version=PACKAGE_VERSION, + description="Mozilla-authored power usage measurement tools", + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + classifiers=[ + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.5", + ], + # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + keywords="", + author="Mozilla Performance Test Engineering Team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + license="MPL", + packages=["mozpower"], + include_package_data=True, + zip_safe=False, + install_requires=deps, + entry_points=""" + # -*- Entry points: -*- + """, +) diff --git a/testing/mozbase/mozpower/tests/conftest.py b/testing/mozbase/mozpower/tests/conftest.py new file mode 100644 index 0000000000..9d81b0229b --- /dev/null +++ b/testing/mozbase/mozpower/tests/conftest.py @@ -0,0 +1,103 @@ +import os +import tempfile +import time +from unittest import mock + +import pytest +from mozpower import MozPower +from mozpower.intel_power_gadget import IntelPowerGadget, IPGResultsHandler +from mozpower.macintelpower import MacIntelPower +from mozpower.powerbase import PowerBase + + +def os_side_effect(*args, **kwargs): + """Used as a side effect to os.path.exists when + checking if the Intel Power Gadget executable exists. + """ + return True + + +def subprocess_side_effect(*args, **kwargs): + """Used as a side effect when running the Intel Power + Gadget tool. + """ + time.sleep(1) + + +@pytest.fixture(scope="function") +def powermeasurer(): + """Returns a testing subclass of the PowerBase class + for testing. + """ + + class PowerMeasurer(PowerBase): + pass + + return PowerMeasurer() + + +@pytest.fixture(scope="function") +def ipg_obj(): + """Returns an IntelPowerGadget object with the test + output file path. + """ + return IntelPowerGadget( + "ipg-path", + output_file_path=os.path.abspath(os.path.dirname(__file__)) + + "/files/raptor-tp6-amazon-firefox_powerlog", + ) + + +@pytest.fixture(scope="function") +def ipg_rh_obj(): + """Returns an IPGResultsHandler object set up with the + test files and cleans up the directory after the tests + are complete. + """ + base_path = os.path.abspath(os.path.dirname(__file__)) + "/files/" + tmpdir = tempfile.mkdtemp() + + # Return the results handler for the test + yield IPGResultsHandler( + [ + base_path + "raptor-tp6-amazon-firefox_powerlog_1_.txt", + base_path + "raptor-tp6-amazon-firefox_powerlog_2_.txt", + base_path + "raptor-tp6-amazon-firefox_powerlog_3_.txt", + ], + tmpdir, + ) + + +@pytest.fixture(scope="function") +def macintelpower_obj(): + """Returns a MacIntelPower object with subprocess.check_output + and os.path.exists calls patched with side effects. + """ + with mock.patch("subprocess.check_output") as subprocess_mock: + with mock.patch("os.path.exists") as os_mock: + subprocess_mock.side_effect = subprocess_side_effect + os_mock.side_effect = os_side_effect + + yield MacIntelPower(ipg_measure_duration=2) + + +@pytest.fixture(scope="function") +def mozpower_obj(): + """Returns a MozPower object with subprocess.check_output + and os.path.exists calls patched with side effects. + """ + with mock.patch.object( + MozPower, "_get_os", return_value="Darwin" + ) as _, mock.patch.object( + MozPower, "_get_processor_info", return_value="GenuineIntel" + ) as _, mock.patch.object( + MacIntelPower, "get_ipg_path", return_value="/" + ) as _, mock.patch( + "subprocess.check_output" + ) as subprocess_mock, mock.patch( + "os.path.exists" + ) as os_mock: + subprocess_mock.side_effect = subprocess_side_effect + os_mock.side_effect = os_side_effect + + yield MozPower(ipg_measure_duration=2) diff --git a/testing/mozbase/mozpower/tests/files/emptyfile.txt b/testing/mozbase/mozpower/tests/files/emptyfile.txt new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/mozbase/mozpower/tests/files/emptyfile.txt diff --git a/testing/mozbase/mozpower/tests/files/raptor-tp6-amazon-firefox_powerlog_1_.txt b/testing/mozbase/mozpower/tests/files/raptor-tp6-amazon-firefox_powerlog_1_.txt new file mode 100644 index 0000000000..d5cc3f91a5 --- /dev/null +++ b/testing/mozbase/mozpower/tests/files/raptor-tp6-amazon-firefox_powerlog_1_.txt @@ -0,0 +1,30 @@ +"System Time","RDTSC","Elapsed Time (sec)","CPU Utilization(%)","CPU Frequency_0(MHz)","Processor Power_0(Watt)","Cumulative Processor Energy_0(Joules)","Cumulative Processor Energy_0(mWh)","IA Power_0(Watt)","Cumulative IA Energy_0(Joules)","Cumulative IA Energy_0(mWh)","Package Temperature_0(C)","Package Hot_0","DRAM Power_0(Watt)","Cumulative DRAM Energy_0(Joules)","Cumulative DRAM Energy_0(mWh)","GT Power_0(Watt)","Cumulative GT Energy_0(Joules)","Cumulative GT Energy_0(mWh)","Package Power Limit_0(Watt)","GT Frequency(MHz)" +"12:11:05:769","61075218263548"," 2.002"," 14.000"," 3400"," 23.427"," 23.460"," 6.517"," 21.019"," 21.048"," 5.847"," 75","0"," 0.877"," 0.878"," 0.244"," 0.020"," 0.020"," 0.006"," 45.000"," 0" +"12:11:06:774","61077822279584"," 3.007"," 20.000"," 3000"," 25.014"," 48.590"," 13.497"," 22.386"," 43.538"," 12.094"," 74","0"," 1.194"," 2.078"," 0.577"," 0.029"," 0.049"," 0.014"," 45.000"," 0" +"12:11:07:778","61080424421708"," 4.011"," 8.000"," 1300"," 9.512"," 58.140"," 16.150"," 6.904"," 50.469"," 14.019"," 65","0"," 0.836"," 2.917"," 0.810"," 0.007"," 0.057"," 0.016"," 45.000"," 0" +"12:11:08:781","61083023535972"," 5.013"," 1.000"," 1300"," 1.786"," 59.931"," 16.647"," 0.687"," 51.158"," 14.210"," 63","0"," 0.585"," 3.504"," 0.973"," 0.000"," 0.057"," 0.016"," 45.000"," 0" +"12:11:09:784","61085623402302"," 6.016"," 4.000"," 4000"," 5.249"," 65.195"," 18.110"," 3.743"," 54.912"," 15.253"," 75","0"," 0.660"," 4.166"," 1.157"," 0.003"," 0.059"," 0.017"," 45.000"," 0" +"12:11:10:787","61088224087008"," 7.020"," 20.000"," 3000"," 35.118"," 100.432"," 27.898"," 31.647"," 86.666"," 24.074"," 72","0"," 1.048"," 5.218"," 1.449"," 0.016"," 0.076"," 0.021"," 45.000"," 0" +"12:11:11:791","61090825821126"," 8.024"," 13.000"," 3000"," 25.436"," 125.965"," 34.990"," 22.228"," 108.979"," 30.272"," 71","0"," 0.868"," 6.089"," 1.691"," 0.004"," 0.080"," 0.022"," 45.000"," 0" +"12:11:12:795","61093429020836"," 9.028"," 5.000"," 1300"," 5.266"," 131.253"," 36.459"," 3.270"," 112.263"," 31.184"," 64","0"," 0.711"," 6.802"," 1.890"," 0.000"," 0.080"," 0.022"," 45.000"," 0" +"12:11:13:796","61096024160928"," 10.029"," 5.000"," 4000"," 11.070"," 142.338"," 39.538"," 9.613"," 121.888"," 33.858"," 78","0"," 0.650"," 7.453"," 2.070"," 0.000"," 0.080"," 0.022"," 45.000"," 0" +"12:11:13:796","61096024279264"," 10.029"," 57.000"," 4000"," 0.000"," 142.338"," 39.538"," 0.000"," 121.888"," 33.858"," 78","0"," 0.000"," 7.453"," 2.070"," 0.000"," 0.080"," 0.022"," 45.000"," 0" +
+"Total Elapsed Time (sec) = 10.029232" +"Measured RDTSC Frequency (GHz) = 2.592" +
+"Cumulative Processor Energy_0 (Joules) = 142.337524" +"Cumulative Processor Energy_0 (mWh) = 39.538201" +"Average Processor Power_0 (Watt) = 14.192265" +
+"Cumulative IA Energy_0 (Joules) = 121.888000" +"Cumulative IA Energy_0 (mWh) = 33.857778" +"Average IA Power_0 (Watt) = 12.153273" +
+"Cumulative DRAM Energy_0 (Joules) = 7.453308" +"Cumulative DRAM Energy_0 (mWh) = 2.070363" +"Average DRAM Power_0 (Watt) = 0.743158" +
+"Cumulative GT Energy_0 (Joules) = 0.079834" +"Cumulative GT Energy_0 (mWh) = 0.022176" +"Average GT Power_0 (Watt) = 0.007960" diff --git a/testing/mozbase/mozpower/tests/files/raptor-tp6-amazon-firefox_powerlog_2_.txt b/testing/mozbase/mozpower/tests/files/raptor-tp6-amazon-firefox_powerlog_2_.txt new file mode 100644 index 0000000000..9157ad1fad --- /dev/null +++ b/testing/mozbase/mozpower/tests/files/raptor-tp6-amazon-firefox_powerlog_2_.txt @@ -0,0 +1,30 @@ +"System Time","RDTSC","Elapsed Time (sec)","CPU Utilization(%)","CPU Frequency_0(MHz)","Processor Power_0(Watt)","Cumulative Processor Energy_0(Joules)","Cumulative Processor Energy_0(mWh)","IA Power_0(Watt)","Cumulative IA Energy_0(Joules)","Cumulative IA Energy_0(mWh)","Package Temperature_0(C)","Package Hot_0","DRAM Power_0(Watt)","Cumulative DRAM Energy_0(Joules)","Cumulative DRAM Energy_0(mWh)","GT Power_0(Watt)","Cumulative GT Energy_0(Joules)","Cumulative GT Energy_0(mWh)","Package Power Limit_0(Watt)","GT Frequency(MHz)" +"12:11:15:821","61101270635674"," 2.007"," 9.000"," 4200"," 10.341"," 10.382"," 2.884"," 7.710"," 7.740"," 2.150"," 80","0"," 0.828"," 0.831"," 0.231"," 0.000"," 0.000"," 0.000"," 45.000"," 0" +"12:11:16:823","61103869919264"," 3.010"," 4.000"," 1300"," 9.353"," 19.762"," 5.489"," 8.079"," 15.842"," 4.400"," 64","0"," 0.597"," 1.430"," 0.397"," 0.000"," 0.000"," 0.000"," 45.000"," 0" +"12:11:17:826","61106469262970"," 4.013"," 19.000"," 2500"," 21.127"," 40.950"," 11.375"," 17.753"," 33.645"," 9.346"," 69","0"," 1.091"," 2.524"," 0.701"," 0.011"," 0.011"," 0.003"," 45.000"," 0" +"12:11:18:827","61109063770516"," 5.014"," 11.000"," 2000"," 11.620"," 52.581"," 14.606"," 8.333"," 41.986"," 11.663"," 64","0"," 0.961"," 3.486"," 0.968"," 0.000"," 0.011"," 0.003"," 45.000"," 0" +"12:11:19:831","61111666134444"," 6.018"," 3.000"," 1300"," 2.722"," 55.314"," 15.365"," 1.497"," 43.489"," 12.080"," 64","0"," 0.620"," 4.108"," 1.141"," 0.000"," 0.011"," 0.003"," 45.000"," 0" +"12:11:20:835","61114266619236"," 7.021"," 14.000"," 3400"," 17.108"," 72.478"," 20.133"," 14.538"," 58.075"," 16.132"," 79","0"," 0.918"," 5.029"," 1.397"," 0.004"," 0.016"," 0.004"," 45.000"," 0" +"12:11:21:835","61116859007218"," 8.021"," 15.000"," 3000"," 16.651"," 89.132"," 24.759"," 13.055"," 71.132"," 19.759"," 70","0"," 1.103"," 6.132"," 1.703"," 0.005"," 0.020"," 0.006"," 45.000"," 0" +"12:11:22:836","61119453982614"," 9.023"," 4.000"," 2000"," 3.334"," 92.470"," 25.686"," 1.974"," 73.109"," 20.308"," 64","0"," 0.654"," 6.787"," 1.885"," 0.000"," 0.020"," 0.006"," 45.000"," 0" +"12:11:23:840","61122057636318"," 10.027"," 12.000"," 3400"," 13.925"," 106.458"," 29.572"," 11.603"," 84.764"," 23.546"," 79","0"," 0.856"," 7.647"," 2.124"," 0.006"," 0.027"," 0.007"," 45.000"," 0" +"12:11:23:840","61122057733984"," 10.027"," 66.000"," 3400"," 0.000"," 106.458"," 29.572"," 0.000"," 84.764"," 23.546"," 79","0"," 0.000"," 7.647"," 2.124"," 0.000"," 0.027"," 0.007"," 45.000"," 0" +
+"Total Elapsed Time (sec) = 10.027134" +"Measured RDTSC Frequency (GHz) = 2.592" +
+"Cumulative Processor Energy_0 (Joules) = 106.457581" +"Cumulative Processor Energy_0 (mWh) = 29.571550" +"Average Processor Power_0 (Watt) = 10.616950" +
+"Cumulative IA Energy_0 (Joules) = 84.764404" +"Cumulative IA Energy_0 (mWh) = 23.545668" +"Average IA Power_0 (Watt) = 8.453503" +
+"Cumulative DRAM Energy_0 (Joules) = 7.647095" +"Cumulative DRAM Energy_0 (mWh) = 2.124193" +"Average DRAM Power_0 (Watt) = 0.762640" +
+"Cumulative GT Energy_0 (Joules) = 0.026550" +"Cumulative GT Energy_0 (mWh) = 0.007375" +"Average GT Power_0 (Watt) = 0.002648" diff --git a/testing/mozbase/mozpower/tests/files/raptor-tp6-amazon-firefox_powerlog_3_.txt b/testing/mozbase/mozpower/tests/files/raptor-tp6-amazon-firefox_powerlog_3_.txt new file mode 100644 index 0000000000..a2779d93a1 --- /dev/null +++ b/testing/mozbase/mozpower/tests/files/raptor-tp6-amazon-firefox_powerlog_3_.txt @@ -0,0 +1,30 @@ +"System Time","RDTSC","Elapsed Time (sec)","CPU Utilization(%)","CPU Frequency_0(MHz)","Processor Power_0(Watt)","Cumulative Processor Energy_0(Joules)","Cumulative Processor Energy_0(mWh)","IA Power_0(Watt)","Cumulative IA Energy_0(Joules)","Cumulative IA Energy_0(mWh)","Package Temperature_0(C)","Package Hot_0","DRAM Power_0(Watt)","Cumulative DRAM Energy_0(Joules)","Cumulative DRAM Energy_0(mWh)","GT Power_0(Watt)","Cumulative GT Energy_0(Joules)","Cumulative GT Energy_0(mWh)","Package Power Limit_0(Watt)","GT Frequency(MHz)" +"12:11:25:867","61127310813944"," 2.006"," 6.000"," 2000"," 4.862"," 4.879"," 1.355"," 3.087"," 3.097"," 0.860"," 65","0"," 0.774"," 0.776"," 0.216"," 0.021"," 0.022"," 0.006"," 45.000"," 0" +"12:11:26:867","61129903677124"," 3.006"," 2.000"," 1300"," 2.883"," 7.763"," 2.156"," 1.253"," 4.350"," 1.208"," 63","0"," 0.644"," 1.421"," 0.395"," 0.018"," 0.039"," 0.011"," 45.000"," 0" +"12:11:27:873","61132509307964"," 4.011"," 2.000"," 1300"," 1.788"," 9.561"," 2.656"," 0.699"," 5.054"," 1.404"," 63","0"," 0.538"," 1.961"," 0.545"," 0.000"," 0.039"," 0.011"," 45.000"," 0" +"12:11:28:877","61135111021262"," 5.015"," 0.000"," 1300"," 1.177"," 10.743"," 2.984"," 0.321"," 5.375"," 1.493"," 63","0"," 0.535"," 2.499"," 0.694"," 0.000"," 0.039"," 0.011"," 45.000"," 0" +"12:11:29:881","61137714179976"," 6.020"," 0.000"," 1300"," 1.064"," 11.811"," 3.281"," 0.251"," 5.627"," 1.563"," 61","0"," 0.520"," 3.021"," 0.839"," 0.000"," 0.039"," 0.011"," 45.000"," 0" +"12:11:30:881","61140307250904"," 7.020"," 0.000"," 1300"," 1.053"," 12.864"," 3.573"," 0.256"," 5.884"," 1.634"," 61","0"," 0.528"," 3.550"," 0.986"," 0.000"," 0.039"," 0.011"," 45.000"," 0" +"12:11:31:885","61142907976768"," 8.023"," 0.000"," 1300"," 1.069"," 13.937"," 3.871"," 0.274"," 6.159"," 1.711"," 61","0"," 0.524"," 4.075"," 1.132"," 0.000"," 0.039"," 0.011"," 45.000"," 0" +"12:11:32:886","61145503940512"," 9.025"," 0.000"," 1300"," 1.008"," 14.947"," 4.152"," 0.228"," 6.387"," 1.774"," 60","0"," 0.522"," 4.598"," 1.277"," 0.000"," 0.039"," 0.011"," 45.000"," 0" +"12:11:33:891","61148109378824"," 10.030"," 0.000"," 1300"," 1.007"," 15.959"," 4.433"," 0.230"," 6.618"," 1.838"," 61","0"," 0.522"," 5.123"," 1.423"," 0.000"," 0.039"," 0.011"," 45.000"," 0" +"12:11:33:892","61148109766160"," 10.030"," 27.000"," 1300"," 0.000"," 15.959"," 4.433"," 0.000"," 6.618"," 1.838"," 61","0"," 2.448"," 5.123"," 1.423"," 0.000"," 0.039"," 0.011"," 45.000"," 0" +
+"Total Elapsed Time (sec) = 10.030310" +"Measured RDTSC Frequency (GHz) = 2.592" +
+"Cumulative Processor Energy_0 (Joules) = 15.958862" +"Cumulative Processor Energy_0 (mWh) = 4.433017" +"Average Processor Power_0 (Watt) = 1.591064" +
+"Cumulative IA Energy_0 (Joules) = 6.618042" +"Cumulative IA Energy_0 (mWh) = 1.838345" +"Average IA Power_0 (Watt) = 0.659804" +
+"Cumulative DRAM Energy_0 (Joules) = 5.122925" +"Cumulative DRAM Energy_0 (mWh) = 1.423035" +"Average DRAM Power_0 (Watt) = 0.510744" +
+"Cumulative GT Energy_0 (Joules) = 0.039490" +"Cumulative GT Energy_0 (mWh) = 0.010969" +"Average GT Power_0 (Watt) = 0.003937" diff --git a/testing/mozbase/mozpower/tests/files/valueerrorfile.txt b/testing/mozbase/mozpower/tests/files/valueerrorfile.txt new file mode 100644 index 0000000000..8f2d3485eb --- /dev/null +++ b/testing/mozbase/mozpower/tests/files/valueerrorfile.txt @@ -0,0 +1,30 @@ +"System Time","RDTSC","Elapsed Time (sec)","CPU Utilization(%)","CPU Frequency_0(MHz)","Processor Power_0(Watt)","Cumulative Processor Energy_0(Joules)","Cumulative Processor Energy_0(mWh)","IA Power_0(Watt)","Cumulative IA Energy_0(Joules)","Cumulative IA Energy_0(mWh)","Package Temperature_0(C)","Package Hot_0","DRAM Power_0(Watt)","Cumulative DRAM Energy_0(Joules)","Cumulative DRAM Energy_0(mWh)","GT Power_0(Watt)","Cumulative GT Energy_0(Joules)","Cumulative GT Energy_0(mWh)","Package Power Limit_0(Watt)","GT Frequency(MHz)" +"12:11:05:769","61075218263548"," 2.002"," 14.000"," 3400"," 23.427"," 23.460"," 6.517"," 21.019"," 21.048"," 5.847"," 75","0"," 0.877"," 0.878"," 0.244"," 0.020"," 0.020"," 0.006"," 45.000"," 0" +"12:11:06:774","61077822279584"," 3.007"," 20.000"," 3000"," 25.014"," 48.590"," 13.497"," 22.386"," 43.538"," 12.094"," 74","0"," 1.194"," 2.078"," 0.577"," 0.029"," 0.049"," 0.014"," 45.000"," 0" +"12:11:07:778","61080424421708"," 4.011"," 8.000"," 1300"," 9.512"," 58.140"," 16.150"," 6.904"," 50.469"," 14.019"," 65","0"," 0.836"," 2.917"," 0.810"," 0.007"," 0.057"," 0.016"," 45.000"," 0" +"12:11:08:781","61083023535972"," 5.013"," 1.000"," 1300"," 1.786"," 59.931"," 16.647"," 0.687"," 51.158"," 14.210"," 63","0"," 0.585"," 3.504"," 0.973"," 0.000"," 0.057"," 0.016"," 45.000"," 0" +"12:11:09:784","61085623402302"," none"," 4.000"," 4000"," 5.249"," 65.195"," 18.110"," 3.743"," 54.912"," 15.253"," 75","0"," 0.660"," 4.166"," 1.157"," 0.003"," 0.059"," 0.017"," 45.000"," 0" +"12:11:10:787","61088224087008"," 7.020"," 20.000"," 3000"," 35.118"," 100.432"," 27.898"," 31.647"," 86.666"," 24.074"," 72","0"," 1.048"," 5.218"," 1.449"," 0.016"," 0.076"," 0.021"," 45.000"," 0" +"12:11:11:791","61090825821126"," 8.024"," 13.000"," 3000"," 25.436"," 125.965"," 34.990"," 22.228"," 108.979"," 30.272"," 71","0"," 0.868"," 6.089"," 1.691"," 0.004"," 0.080"," 0.022"," 45.000"," 0" +"12:11:12:795","61093429020836"," 9.028"," 5.000"," 1300"," 5.266"," 131.253"," 36.459"," 3.270"," 112.263"," 31.184"," 64","0"," 0.711"," 6.802"," 1.890"," 0.000"," 0.080"," 0.022"," 45.000"," 0" +"12:11:13:796","61096024160928"," 10.029"," 5.000"," 4000"," 11.070"," 142.338"," 39.538"," 9.613"," 121.888"," 33.858"," 78","0"," 0.650"," 7.453"," 2.070"," 0.000"," 0.080"," 0.022"," 45.000"," 0" +"12:11:13:796","61096024279264"," 10.029"," 57.000"," 4000"," 0.000"," 142.338"," 39.538"," 0.000"," 121.888"," 33.858"," 78","0"," 0.000"," 7.453"," 2.070"," 0.000"," 0.080"," 0.022"," 45.000"," 0" + +"Total Elapsed Time (sec) = 10.029232" +"Measured RDTSC Frequency (GHz) = 2.592" + +"Cumulative Processor Energy_0 (Joules) = 142.337524" +"Cumulative Processor Energy_0 (mWh) = 39.538201" +"Average Processor Power_0 (Watt) = 14.192265" + +"Cumulative IA Energy_0 (Joules) = 121.888000" +"Cumulative IA Energy_0 (mWh) = 33.857778" +"Average IA Power_0 (Watt) = 12.153273" + +"Cumulative DRAM Energy_0 (Joules) = 7.453308" +"Cumulative DRAM Energy_0 (mWh) = 2.070363" +"Average DRAM Power_0 (Watt) = 0.743158" + +"Cumulative GT Energy_0 (Joules) = 0.079834" +"Cumulative GT Energy_0 (mWh) = 0.022176" +"Average GT Power_0 (Watt) = 0.007960" diff --git a/testing/mozbase/mozpower/tests/manifest.toml b/testing/mozbase/mozpower/tests/manifest.toml new file mode 100644 index 0000000000..d674f14ee6 --- /dev/null +++ b/testing/mozbase/mozpower/tests/manifest.toml @@ -0,0 +1,10 @@ +[DEFAULT] +subsuite = "mozbase" + +["test_intelpowergadget.py"] + +["test_macintelpower.py"] + +["test_mozpower.py"] + +["test_powerbase.py"] diff --git a/testing/mozbase/mozpower/tests/test_intelpowergadget.py b/testing/mozbase/mozpower/tests/test_intelpowergadget.py new file mode 100644 index 0000000000..9be01cb720 --- /dev/null +++ b/testing/mozbase/mozpower/tests/test_intelpowergadget.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python + +import datetime +import os +import time +from unittest import mock + +import mozunit +import pytest +import six +from mozpower.intel_power_gadget import ( + IPGEmptyFileError, + IPGMissingOutputFileError, + IPGTimeoutError, + IPGUnknownValueTypeError, +) + + +def thread_is_alive(thread): + if six.PY2: + return thread.isAlive() + return thread.is_alive() + + +def test_ipg_pathsplitting(ipg_obj): + """Tests that the output file path and prefix was properly split. + This test assumes that it is in the same directory as the conftest.py file. + """ + assert ( + ipg_obj.output_dir_path == os.path.abspath(os.path.dirname(__file__)) + "/files" + ) + assert ipg_obj.output_file_prefix == "raptor-tp6-amazon-firefox_powerlog" + + +def test_ipg_get_output_file_path(ipg_obj): + """Tests that the output file path is constantly changing + based on the file_counter value. + """ + test_path = "/test_path/" + test_ext = ".txt" + ipg_obj._file_counter = 1 + ipg_obj._output_dir_path = test_path + ipg_obj._output_file_ext = test_ext + + for i in range(1, 6): + fpath = ipg_obj._get_output_file_path() + + assert fpath.startswith(test_path) + assert fpath.endswith(test_ext) + assert str(i) in fpath + + +def test_ipg_start_and_stop(ipg_obj): + """Tests that the IPG thread can start and stop properly.""" + + def subprocess_side_effect(*args, **kwargs): + time.sleep(1) + + with mock.patch("subprocess.check_output") as m: + m.side_effect = subprocess_side_effect + + # Start recording IPG measurements + ipg_obj.start_ipg() + assert not ipg_obj._stop + + # Wait a bit for thread to start, then check it + timeout = 10 + start = time.time() + while time.time() - start < timeout and not ipg_obj._running: + time.sleep(1) + + assert ipg_obj._running + assert thread_is_alive(ipg_obj._thread) + + # Stop recording IPG measurements + ipg_obj.stop_ipg(wait_interval=1, timeout=30) + assert ipg_obj._stop + assert not ipg_obj._running + + +def test_ipg_stopping_timeout(ipg_obj): + """Tests that an IPGTimeoutError is raised when + the thread is still "running" and the wait in _wait_for_ipg + has exceeded the timeout value. + """ + with pytest.raises(IPGTimeoutError): + ipg_obj._running = True + ipg_obj._wait_for_ipg(wait_interval=1, timeout=2) + + +def test_ipg_rh_combine_cumulatives(ipg_rh_obj): + """Tests that cumulatives are correctly combined in + the _combine_cumulative_rows function. + """ + cumulatives_to_combine = [ + [0, 1, 2, 3, 4, 5], + [0, 1, 2, 3, 4, 5], + [0, 1, 2, 3, 4, 5], + [0, 1, 2, 3, 4, 5], + ] + + combined_cumulatives = ipg_rh_obj._combine_cumulative_rows(cumulatives_to_combine) + + # Check that accumulation worked, final value must be the maximum + assert combined_cumulatives[-1] == max(combined_cumulatives) + + # Check that the cumulative values are monotonically increasing + for count, val in enumerate(combined_cumulatives[:-1]): + assert combined_cumulatives[count + 1] - val >= 0 + + +def test_ipg_rh_clean_file(ipg_rh_obj): + """Tests that IPGResultsHandler correctly cleans the data + from one file. + """ + file = ipg_rh_obj._output_files[0] + linecount = 0 + with open(file, "r") as f: + for line in f: + linecount += 1 + + results, summary, clean_file = ipg_rh_obj._clean_ipg_file(file) + + # Check that each measure from the csv header + # is in the results dict and that the clean file output + # exists. + for measure in results: + assert measure in ipg_rh_obj._csv_header + assert os.path.exists(clean_file) + + clean_rows = [] + with open(clean_file, "r") as f: + for line in f: + if line.strip(): + clean_rows.append(line) + + # Make sure that the results and summary entries + # have the expected lengths. + for measure in results: + # Add 6 for new lines that were removed + assert len(results[measure]) + len(summary) + 6 == linecount + # Subtract 1 for the csv header + assert len(results[measure]) == len(clean_rows) - 1 + + +def test_ipg_rh_clean_ipg_data_no_files(ipg_rh_obj): + """Tests that IPGResultsHandler correctly handles the case + when no output files exist. + """ + ipg_rh_obj._output_files = [] + clean_data = ipg_rh_obj.clean_ipg_data() + assert clean_data is None + + +def test_ipg_rh_clean_ipg_data(ipg_rh_obj): + """Tests that IPGResultsHandler correctly handles cleaning + all known files and that the results and the merged output + are correct. + """ + clean_data = ipg_rh_obj.clean_ipg_data() + clean_files = ipg_rh_obj.cleaned_files + merged_output_path = ipg_rh_obj.merged_output_path + + # Check that the expected output exists + assert clean_data is not None + assert len(clean_files) == len(ipg_rh_obj._output_files) + assert os.path.exists(merged_output_path) + + # Check that the merged file length and results length + # is correct, and that no lines were lost and no extra lines + # were added. + expected_merged_line_count = 0 + for file in clean_files: + with open(file, "r") as f: + for count, line in enumerate(f): + if count == 0: + continue + if line.strip(): + expected_merged_line_count += 1 + + merged_line_count = 0 + with open(merged_output_path, "r") as f: + for count, line in enumerate(f): + if count == 0: + continue + if line.strip(): + merged_line_count += 1 + + assert merged_line_count == expected_merged_line_count + for measure in clean_data: + assert len(clean_data[measure]) == merged_line_count + + # Check that the clean data rows are ordered in increasing time + times_in_seconds = [] + for sys_time in clean_data["System Time"]: + split_sys_time = sys_time.split(":") + hour_min_sec = ":".join(split_sys_time[:-1]) + millis = float(split_sys_time[-1]) / 1000 + + timestruct = time.strptime(hour_min_sec, "%H:%M:%S") + times_in_seconds.append( + datetime.timedelta( + hours=timestruct.tm_hour, + minutes=timestruct.tm_min, + seconds=timestruct.tm_sec, + ).total_seconds() + + millis + ) + + for count, val in enumerate(times_in_seconds[:-1]): + assert times_in_seconds[count + 1] - val >= 0 + + +def test_ipg_rh_format_to_perfherder_with_no_results(ipg_rh_obj): + """Tests that formatting the data to a perfherder-like format + fails when clean_ipg_data was not called beforehand. + """ + formatted_data = ipg_rh_obj.format_ipg_data_to_partial_perfherder( + 1000, ipg_rh_obj._output_file_prefix + ) + assert formatted_data is None + + +def test_ipg_rh_format_to_perfherder_without_cutoff(ipg_rh_obj): + """Tests that formatting the data to a perfherder-like format + works as expected. + """ + ipg_rh_obj.clean_ipg_data() + formatted_data = ipg_rh_obj.format_ipg_data_to_partial_perfherder( + 1000, ipg_rh_obj._output_file_prefix + ) + + # Check that the expected entries exist + assert len(formatted_data.keys()) == 5 + assert "utilization" in formatted_data and "power-usage" in formatted_data + + assert ( + formatted_data["power-usage"]["test"] + == ipg_rh_obj._output_file_prefix + "-cumulative" + ) + assert ( + formatted_data["utilization"]["test"] + == ipg_rh_obj._output_file_prefix + "-utilization" + ) + assert ( + formatted_data["frequency-gpu"]["test"] + == ipg_rh_obj._output_file_prefix + "-frequency-gpu" + ) + assert ( + formatted_data["frequency-cpu"]["test"] + == ipg_rh_obj._output_file_prefix + "-frequency-cpu" + ) + assert ( + formatted_data["power-watts"]["test"] + == ipg_rh_obj._output_file_prefix + "-watts" + ) + + for measure in formatted_data: + # Make sure that the data exists + assert len(formatted_data[measure]["values"]) >= 1 + + for valkey in formatted_data[measure]["values"]: + # Make sure the names were simplified + assert "(" not in valkey + assert ")" not in valkey + + # Check that gpu utilization doesn't exist but cpu does + utilization_vals = formatted_data["utilization"]["values"] + assert "cpu" in utilization_vals + assert "gpu" not in utilization_vals + + expected_fields = ["processor-cores", "processor-package", "gpu", "dram"] + consumption_vals = formatted_data["power-usage"]["values"] + + consumption_vals_measures = list(consumption_vals.keys()) + + # This assertion ensures that the consumption values contain the expected + # fields and nothing more. + assert not list(set(consumption_vals_measures) - set(expected_fields)) + + +def test_ipg_rh_format_to_perfherder_with_cutoff(ipg_rh_obj): + """Tests that formatting the data to a perfherder-like format + works as expected. + """ + ipg_rh_obj.clean_ipg_data() + formatted_data = ipg_rh_obj.format_ipg_data_to_partial_perfherder( + 2.5, ipg_rh_obj._output_file_prefix + ) + + # Check that the formatted data was cutoff at the correct point, + # expecting that only the first row of merged will exist. + utilization_vals = formatted_data["utilization"]["values"] + assert utilization_vals["cpu"] == 14 + + # Expected vals are ordered in this way: [processor, cores, dram, gpu] + expected_vals = [6.517, 5.847, 0.244, 0.006] + consumption_vals = [ + formatted_data["power-usage"]["values"][measure] + for measure in formatted_data["power-usage"]["values"] + ] + assert not list(set(expected_vals) - set(consumption_vals)) + + +def test_ipg_rh_missingoutputfile(ipg_rh_obj): + """Tests that the IPGMissingOutputFileError is raised + when a bad file path is passed to _clean_ipg_file. + """ + bad_files = ["non-existent-file"] + with pytest.raises(IPGMissingOutputFileError): + ipg_rh_obj._clean_ipg_file(bad_files[0]) + + ipg_rh_obj._output_files = bad_files + with pytest.raises(IPGMissingOutputFileError): + ipg_rh_obj.clean_ipg_data() + + +def test_ipg_rh_emptyfile(ipg_rh_obj): + """Tests that the empty file error is raised when + a file exists, but does not contain any results in + it. + """ + base_path = os.path.abspath(os.path.dirname(__file__)) + "/files/" + bad_files = [base_path + "emptyfile.txt"] + with pytest.raises(IPGEmptyFileError): + ipg_rh_obj._clean_ipg_file(bad_files[0]) + + ipg_rh_obj._output_files = bad_files + with pytest.raises(IPGEmptyFileError): + ipg_rh_obj.clean_ipg_data() + + +def test_ipg_rh_valuetypeerrorfile(ipg_rh_obj): + """Tests that the IPGUnknownValueTypeError is raised + when a bad entry is encountered in a file that is cleaned. + """ + base_path = os.path.abspath(os.path.dirname(__file__)) + "/files/" + bad_files = [base_path + "valueerrorfile.txt"] + with pytest.raises(IPGUnknownValueTypeError): + ipg_rh_obj._clean_ipg_file(bad_files[0]) + + ipg_rh_obj._output_files = bad_files + with pytest.raises(IPGUnknownValueTypeError): + ipg_rh_obj.clean_ipg_data() + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozpower/tests/test_macintelpower.py b/testing/mozbase/mozpower/tests/test_macintelpower.py new file mode 100644 index 0000000000..2332c94c3e --- /dev/null +++ b/testing/mozbase/mozpower/tests/test_macintelpower.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python + +import time +from unittest import mock + +import mozunit + + +def test_macintelpower_init(macintelpower_obj): + """Tests that the MacIntelPower object is correctly initialized.""" + assert macintelpower_obj.ipg_path + assert macintelpower_obj.ipg + assert macintelpower_obj._os == "darwin" + assert macintelpower_obj._cpu == "intel" + + +def test_macintelpower_measuring(macintelpower_obj): + """Tests that measurement initialization and finalization works + for the MacIntelPower object. + """ + assert not macintelpower_obj.start_time + assert not macintelpower_obj.ipg._running + assert not macintelpower_obj.ipg._output_files + macintelpower_obj.initialize_power_measurements() + + # Check that initialization set start_time, and started the + # IPG measurer thread. + + # Wait a bit for thread to start, then check it + timeout = 10 + start = time.time() + while time.time() - start < timeout and not macintelpower_obj.ipg._running: + time.sleep(1) + + assert macintelpower_obj.start_time + assert macintelpower_obj.ipg._running + + test_data = {"power-usage": "data"} + + def formatter_side_effect(*args, **kwargs): + return test_data + + with mock.patch( + "mozpower.intel_power_gadget.IPGResultsHandler.clean_ipg_data" + ) as _: + with mock.patch( + "mozpower.intel_power_gadget.IPGResultsHandler." + "format_ipg_data_to_partial_perfherder" + ) as formatter: + formatter.side_effect = formatter_side_effect + + macintelpower_obj.finalize_power_measurements(wait_interval=2, timeout=30) + + # Check that finalization set the end_time, stopped the IPG measurement + # thread, added atleast one output file name, and initialized + # an IPGResultsHandler object + assert macintelpower_obj.end_time + assert not macintelpower_obj.ipg._running + assert macintelpower_obj.ipg._output_files + assert macintelpower_obj.ipg_results_handler + + # Check that the IPGResultHandler's methods were + # called + macintelpower_obj.ipg_results_handler.clean_ipg_data.assert_called() + macintelpower_obj.ipg_results_handler.format_ipg_data_to_partial_perfherder.assert_called_once_with( # NOQA: E501 + macintelpower_obj.end_time - macintelpower_obj.start_time, + "power-testing", + ) + + # Make sure we can get the expected perfherder data + # after formatting + assert macintelpower_obj.get_perfherder_data() == test_data + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozpower/tests/test_mozpower.py b/testing/mozbase/mozpower/tests/test_mozpower.py new file mode 100644 index 0000000000..1ad6194c0a --- /dev/null +++ b/testing/mozbase/mozpower/tests/test_mozpower.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python + +import subprocess +import sys +from unittest import mock + +import mozunit +import pytest +from mozpower import MozPower +from mozpower.mozpower import MissingProcessorInfoError, OsCpuComboMissingError + + +def test_mozpower_android_init_failure(): + """Tests that the MozPower object fails when the android + flag is set. Remove this test once android is implemented. + """ + with pytest.raises(NotImplementedError): + MozPower(android=True) + + +def test_mozpower_oscpu_combo_missing_error(): + """Tests that the error OsCpuComboMissingError is raised + when we can't find a OS, and CPU combination (and, therefore, cannot + find a power measurer). + """ + with mock.patch.object( + MozPower, "_get_os", return_value="Not-An-OS" + ) as _, mock.patch.object( + MozPower, "_get_processor_info", return_value="Not-A-Processor" + ) as _: + with pytest.raises(OsCpuComboMissingError): + MozPower() + + +def test_mozpower_processor_info_missing_error(): + """Tests that the error MissingProcessorInfoError is raised + when failures occur during processor information parsing. + """ + # The builtins module name differs between python 2 and 3 + builtins_name = "__builtin__" + if sys.version_info[0] == 3: + builtins_name = "builtins" + + def os_side_effect_true(*args, **kwargs): + """Used as a passing side effect for os.path.exists calls.""" + return True + + def os_side_effect_false(*args, **kwargs): + """Used as a failing side effect for os.path.exists calls.""" + return False + + def subprocess_side_effect_fail(*args, **kwargs): + """Used to mock a failure in subprocess.check_output calls.""" + raise subprocess.CalledProcessError(1, "Testing failure") + + # Test failures in macos processor information parsing + with mock.patch.object(MozPower, "_get_os", return_value="Darwin") as _: + with mock.patch("os.path.exists") as os_mock: + os_mock.side_effect = os_side_effect_false + + # Check that we fail properly if the processor + # information file doesn't exist. + with pytest.raises(MissingProcessorInfoError): + MozPower() + + # Check that we fail properly when an error occurs + # in the subprocess call. + os_mock.side_effect = os_side_effect_true + with mock.patch("subprocess.check_output") as subprocess_mock: + subprocess_mock.side_effect = subprocess_side_effect_fail + with pytest.raises(MissingProcessorInfoError): + MozPower() + + # Test failures in linux processor information parsing + with mock.patch.object(MozPower, "_get_os", return_value="Linux") as _: + with mock.patch("os.path.exists") as os_mock: + os_mock.side_effect = os_side_effect_false + + # Check that we fail properly if the processor + # information file doesn't exist. + with pytest.raises(MissingProcessorInfoError): + MozPower() + + # Check that we fail properly when the model cannot be found + # with by searching for 'model name'. + os_mock.side_effect = os_side_effect_true + with mock.patch( + "%s.open" % builtins_name, mock.mock_open(read_data="") + ) as _: + with pytest.raises(MissingProcessorInfoError): + MozPower() + + +def test_mozpower_oscpu_combo(mozpower_obj): + """Tests that the correct class is instantiated for a given + OS and CPU combination (MacIntelPower in this case). + """ + assert mozpower_obj.measurer.__class__.__name__ == "MacIntelPower" + assert ( + mozpower_obj.measurer._os == "darwin" and mozpower_obj.measurer._cpu == "intel" + ) + + +def test_mozpower_measuring(mozpower_obj): + """Tests that measurers are properly called with each method.""" + with mock.patch( + "mozpower.macintelpower.MacIntelPower.initialize_power_measurements" + ) as _, mock.patch( + "mozpower.macintelpower.MacIntelPower.finalize_power_measurements" + ) as _, mock.patch( + "mozpower.macintelpower.MacIntelPower.get_perfherder_data" + ) as _: + mozpower_obj.initialize_power_measurements() + mozpower_obj.measurer.initialize_power_measurements.assert_called() + + mozpower_obj.finalize_power_measurements() + mozpower_obj.measurer.finalize_power_measurements.assert_called() + + mozpower_obj.get_perfherder_data() + mozpower_obj.measurer.get_perfherder_data.assert_called() + + +def test_mozpower_measuring_with_no_measurer(mozpower_obj): + """Tests that no errors occur when the measurer is None, and the + initialize, finalize, and get_perfherder_data functions are called. + """ + with mock.patch( + "mozpower.macintelpower.MacIntelPower.initialize_power_measurements" + ) as _, mock.patch( + "mozpower.macintelpower.MacIntelPower.finalize_power_measurements" + ) as _, mock.patch( + "mozpower.macintelpower.MacIntelPower.get_perfherder_data" + ) as _: + measurer = mozpower_obj.measurer + mozpower_obj.measurer = None + + mozpower_obj.initialize_power_measurements() + assert not measurer.initialize_power_measurements.called + + mozpower_obj.finalize_power_measurements() + assert not measurer.finalize_power_measurements.called + + mozpower_obj.get_perfherder_data() + assert not measurer.get_perfherder_data.called + + mozpower_obj.get_full_perfherder_data("mozpower") + assert not measurer.get_perfherder_data.called + + +def test_mozpower_get_full_perfherder_data(mozpower_obj): + """Tests that the full perfherder data blob is properly + produced given a partial perfherder data blob with correct + entries. + """ + partial_perfherder = { + "utilization": { + "type": "power", + "test": "mozpower", + "unit": "%", + "values": {"cpu": 50, "gpu": 0}, + }, + "power-usage": { + "type": "power", + "test": "mozpower", + "unit": "mWh", + "values": {"cpu": 2.0, "dram": 0.1, "gpu": 4.0}, + }, + "frequency-cpu": { + "type": "power", + "test": "mozpower", + "unit": "MHz", + "values": { + "cpu-favg": 2.0, + "cpu-fmax": 5.0, + "cpu-fmin": 0.0, + }, + }, + "frequency-gpu": { + "type": "power", + "test": "mozpower", + "unit": "MHz", + "values": {"gpu-favg": 3.0, "gpu-fmax": 6.0, "gpu-fmin": 0.0}, + }, + } + utilization_vals = [0, 50] + power_usage_vals = [2.0, 0.1, 4.0] + frequency_cpu_vals = [2.0, 5.0, 0.0] + frequency_gpu_vals = [3.0, 6.0, 0.0] + + with mock.patch("mozpower.macintelpower.MacIntelPower.get_perfherder_data") as gpd: + gpd.return_value = partial_perfherder + + full_perfherder = mozpower_obj.get_full_perfherder_data("mozpower") + assert full_perfherder["framework"]["name"] == "mozpower" + assert len(full_perfherder["suites"]) == 4 + + # Check that each of the two suites were created correctly. + suites = full_perfherder["suites"] + for suite in suites: + assert "subtests" in suite + + assert suite["type"] == "power" + assert suite["alertThreshold"] == 2.0 + assert suite["lowerIsBetter"] + + all_vals = [] + for subtest in suite["subtests"]: + assert "value" in subtest + + # Check that the subtest names were created correctly + if "utilization" in suite["name"]: + assert "utilization" in subtest["name"] + elif "power-usage" in suite["name"]: + assert "power-usage" in subtest["name"] + elif "frequency-cpu" in suite["name"]: + assert "frequency-cpu" in subtest["name"] + elif "frequency-gpu" in suite["name"]: + assert "frequency-gpu" in subtest["name"] + else: + assert False, "Unknown subtest name %s" % subtest["name"] + + all_vals.append(subtest["value"]) + + if "utilization" in suite["name"]: + assert len(all_vals) == 2 + assert suite["unit"] == "%" + assert suite["name"] == "mozpower-utilization" + assert not list(set(all_vals) - set(utilization_vals)) + assert suite["value"] == float(25) + elif "power-usage" in suite["name"]: + assert len(all_vals) == 3 + assert suite["unit"] == "mWh" + assert suite["name"] == "mozpower-power-usage" + assert not list(set(all_vals) - set(power_usage_vals)) + assert suite["value"] == float(6.1) + elif "frequency-cpu" in suite["name"]: + assert len(all_vals) == 3 + assert suite["unit"] == "MHz" + assert suite["name"] == "mozpower-frequency-cpu" + assert not list(set(all_vals) - set(frequency_cpu_vals)) + assert suite["value"] == float(2.0) + elif "frequency-gpu" in suite["name"]: + assert len(all_vals) == 3 + assert suite["unit"] == "MHz" + assert suite["name"] == "mozpower-frequency-gpu" + assert not list(set(all_vals) - set(frequency_gpu_vals)) + assert suite["value"] == float(3.0) + else: + assert False, "Unknown suite name %s" % suite["name"] + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozpower/tests/test_powerbase.py b/testing/mozbase/mozpower/tests/test_powerbase.py new file mode 100644 index 0000000000..46de941457 --- /dev/null +++ b/testing/mozbase/mozpower/tests/test_powerbase.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python + +from unittest import mock + +import mozunit +import pytest +from mozpower.powerbase import ( + IPGExecutableMissingError, + PlatformUnsupportedError, + PowerBase, +) + + +def test_powerbase_intialization(): + """Tests that the PowerBase class correctly raises + a NotImplementedError when attempting to instantiate + it directly. + """ + with pytest.raises(NotImplementedError): + PowerBase() + + +def test_powerbase_missing_methods(powermeasurer): + """Tests that trying to call PowerBase methods + without an implementation in the subclass raises + the NotImplementedError. + """ + with pytest.raises(NotImplementedError): + powermeasurer.initialize_power_measurements() + + with pytest.raises(NotImplementedError): + powermeasurer.finalize_power_measurements() + + with pytest.raises(NotImplementedError): + powermeasurer.get_perfherder_data() + + +def test_powerbase_get_ipg_path_mac(powermeasurer): + """Tests that getting the IPG path returns the expected results.""" + powermeasurer._os = "darwin" + powermeasurer._cpu = "not-intel" + + def os_side_effect(arg): + """Used to get around the os.path.exists check in + get_ipg_path which raises an IPGExecutableMissingError + otherwise. + """ + return True + + with mock.patch("os.path.exists", return_value=True) as m: + m.side_effect = os_side_effect + + # None is returned when a non-intel based machine is + # tested. + ipg_path = powermeasurer.get_ipg_path() + assert ipg_path is None + + # Check the path returned for mac intel-based machines. + powermeasurer._cpu = "intel" + ipg_path = powermeasurer.get_ipg_path() + assert ipg_path == "/Applications/Intel Power Gadget/PowerLog" + + +def test_powerbase_get_ipg_path_errors(powermeasurer): + """Tests that the appropriate error is output when calling + get_ipg_path with invalid/unsupported _os and _cpu entries. + """ + + powermeasurer._cpu = "intel" + powermeasurer._os = "Not-An-OS" + + def os_side_effect(arg): + """Used to force the error IPGExecutableMissingError to occur + (in case a machine running these tests is a mac, and has intel + power gadget installed). + """ + return False + + with pytest.raises(PlatformUnsupportedError): + powermeasurer.get_ipg_path() + + with mock.patch("os.path.exists", return_value=False) as m: + m.side_effect = os_side_effect + + powermeasurer._os = "darwin" + with pytest.raises(IPGExecutableMissingError): + powermeasurer.get_ipg_path() + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozprocess/mozprocess/__init__.py b/testing/mozbase/mozprocess/mozprocess/__init__.py new file mode 100644 index 0000000000..96e382bf08 --- /dev/null +++ b/testing/mozbase/mozprocess/mozprocess/__init__.py @@ -0,0 +1,16 @@ +# flake8: noqa +# 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 .mozprocess import * +from .processhandler import * + +__all__ = [ + "ProcessHandlerMixin", + "ProcessHandler", + "LogOutput", + "StoreOutput", + "StreamOutput", + "run_and_wait", +] diff --git a/testing/mozbase/mozprocess/mozprocess/mozprocess.py b/testing/mozbase/mozprocess/mozprocess/mozprocess.py new file mode 100644 index 0000000000..79289fa527 --- /dev/null +++ b/testing/mozbase/mozprocess/mozprocess/mozprocess.py @@ -0,0 +1,119 @@ +# 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/. + +import os +import subprocess +import threading +import time + + +def run_and_wait( + args=None, + cwd=None, + env=None, + text=True, + timeout=None, + timeout_handler=None, + output_timeout=None, + output_timeout_handler=None, + output_line_handler=None, +): + """ + Run a process and wait for it to complete, with optional support for + line-by-line output handling and timeouts. + + On timeout or output timeout, the callback should kill the process; + many clients use mozcrash.kill_and_get_minidump() in the timeout + callback. + + run_and_wait is not intended to be a generic replacement for subprocess. + Clients requiring different options or behavior should use subprocess + directly. + + :param args: command to run. May be a string or a list. + :param cwd: working directory for command. + :param env: environment to use for the process (defaults to os.environ). + :param text: open streams in text mode if True; else use binary mode. + :param timeout: seconds to wait for process to complete before calling timeout_handler + :param timeout_handler: function to be called if timeout reached + :param output_timeout: seconds to wait for process to generate output + :param output_timeout_handler: function to be called if output_timeout is reached + :param output_line_handler: function to be called for every line of process output + """ + is_win = os.name == "nt" + context = {"output_timer": None, "proc_timer": None, "timed_out": False} + + def base_timeout_handler(): + context["timed_out"] = True + if context["output_timer"]: + context["output_timer"].cancel() + if timeout_handler: + timeout_handler(proc) + + def base_output_timeout_handler(): + seconds_since_last_output = time.time() - output_time + next_possible_output_timeout = output_timeout - seconds_since_last_output + if next_possible_output_timeout <= 0: + context["timed_out"] = True + if context["proc_timer"]: + context["proc_timer"].cancel() + if output_timeout_handler: + output_timeout_handler(proc) + else: + context["output_timer"] = threading.Timer( + next_possible_output_timeout, base_output_timeout_handler + ) + context["output_timer"].start() + + if env is None: + env = os.environ.copy() + + if is_win: + kwargs = { + "creationflags": subprocess.CREATE_NEW_PROCESS_GROUP, + } + else: + kwargs = { + "preexec_fn": os.setsid, + } + + proc = subprocess.Popen( + args, + cwd=cwd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=text, + **kwargs + ) + + if timeout: + context["proc_timer"] = threading.Timer(timeout, base_timeout_handler) + context["proc_timer"].start() + + if output_timeout: + output_time = time.time() + context["output_timer"] = threading.Timer( + output_timeout, base_output_timeout_handler + ) + context["output_timer"].start() + + for line in proc.stdout: + output_time = time.time() + if output_line_handler: + output_line_handler(proc, line) + else: + print(line) + if context["timed_out"]: + break + + if not context["timed_out"]: + proc.wait() + + if timeout: + context["proc_timer"].cancel() + if output_timeout: + context["output_timer"].cancel() + + return proc diff --git a/testing/mozbase/mozprocess/mozprocess/processhandler.py b/testing/mozbase/mozprocess/mozprocess/processhandler.py new file mode 100644 index 0000000000..705ff5a210 --- /dev/null +++ b/testing/mozbase/mozprocess/mozprocess/processhandler.py @@ -0,0 +1,1275 @@ +# 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/. + +# The mozprocess ProcessHandler and ProcessHandlerMixin are typically used as +# an alternative to the python subprocess module. They have been used in many +# Mozilla test harnesses with some success -- but also with on-going concerns, +# especially regarding reliability and exception handling. +# +# New code should try to use the standard subprocess module, and only use +# this ProcessHandler if absolutely necessary. + +import codecs +import errno +import os +import signal +import subprocess +import sys +import threading +import time +import traceback +from datetime import datetime +from queue import Empty, Queue + +import six + +# Set the MOZPROCESS_DEBUG environment variable to 1 to see some debugging output +MOZPROCESS_DEBUG = os.getenv("MOZPROCESS_DEBUG") + +INTERVAL_PROCESS_ALIVE_CHECK = 0.02 + +# We dont use mozinfo because it is expensive to import, see bug 933558. +isWin = os.name == "nt" +isPosix = os.name == "posix" # includes MacOS X + +if isWin: + from ctypes import WinError, addressof, byref, c_longlong, c_ulong, sizeof + + from . import winprocess + from .qijo import ( + IO_COUNTERS, + JOBOBJECT_ASSOCIATE_COMPLETION_PORT, + JOBOBJECT_BASIC_LIMIT_INFORMATION, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JobObjectAssociateCompletionPortInformation, + JobObjectExtendedLimitInformation, + ) + + +class ProcessHandlerMixin(object): + """ + A class for launching and manipulating local processes. + + :param cmd: command to run. May be a string or a list. If specified as a list, the first + element will be interpreted as the command, and all additional elements will be interpreted + as arguments to that command. + :param args: list of arguments to pass to the command (defaults to None). Must not be set when + `cmd` is specified as a list. + :param cwd: working directory for command (defaults to None). + :param env: is the environment to use for the process (defaults to os.environ). + :param ignore_children: causes system to ignore child processes when True, + defaults to False (which tracks child processes). + :param kill_on_timeout: when True, the process will be killed when a timeout is reached. + When False, the caller is responsible for killing the process. + Failure to do so could cause a call to wait() to hang indefinitely. (Defaults to True.) + :param processOutputLine: function or list of functions to be called for + each line of output produced by the process (defaults to an empty + list). + :param processStderrLine: function or list of functions to be called + for each line of error output - stderr - produced by the process + (defaults to an empty list). If this is not specified, stderr lines + will be sent to the *processOutputLine* callbacks. + :param onTimeout: function or list of functions to be called when the process times out. + :param onFinish: function or list of functions to be called when the process terminates + normally without timing out. + :param kwargs: additional keyword args to pass directly into Popen. + + NOTE: Child processes will be tracked by default. If for any reason + we are unable to track child processes and ignore_children is set to False, + then we will fall back to only tracking the root process. The fallback + will be logged. + """ + + class Process(subprocess.Popen): + """ + Represents our view of a subprocess. + It adds a kill() method which allows it to be stopped explicitly. + """ + + MAX_IOCOMPLETION_PORT_NOTIFICATION_DELAY = 180 + MAX_PROCESS_KILL_DELAY = 30 + TIMEOUT_BEFORE_SIGKILL = 1.0 + + def __init__( + self, + args, + bufsize=0, + executable=None, + stdin=None, + stdout=None, + stderr=None, + preexec_fn=None, + close_fds=False, + shell=False, + cwd=None, + env=None, + universal_newlines=False, + startupinfo=None, + creationflags=0, + ignore_children=False, + encoding="utf-8", + ): + # Parameter for whether or not we should attempt to track child processes + self._ignore_children = ignore_children + self._job = None + self._io_port = None + + if not self._ignore_children and not isWin: + # Set the process group id for linux systems + # Sets process group id to the pid of the parent process + # NOTE: This prevents you from using preexec_fn and managing + # child processes, TODO: Ideally, find a way around this + def setpgidfn(): + os.setpgid(0, 0) + + preexec_fn = setpgidfn + + kwargs = { + "bufsize": bufsize, + "executable": executable, + "stdin": stdin, + "stdout": stdout, + "stderr": stderr, + "preexec_fn": preexec_fn, + "close_fds": close_fds, + "shell": shell, + "cwd": cwd, + "env": env, + "startupinfo": startupinfo, + "creationflags": creationflags, + } + if sys.version_info.minor >= 6 and universal_newlines: + kwargs["universal_newlines"] = universal_newlines + kwargs["encoding"] = encoding + try: + subprocess.Popen.__init__(self, args, **kwargs) + except OSError: + print(args, file=sys.stderr) + raise + + def debug(self, msg): + if not MOZPROCESS_DEBUG: + return + thread = threading.current_thread().name + print("DBG::MOZPROC PID:{} ({}) | {}".format(self.pid, thread, msg)) + + def __del__(self): + if isWin: + _maxint = sys.maxsize + handle = getattr(self, "_handle", None) + if handle: + # _internal_poll is a Python3 built-in call and requires _handle to be an int on Windows + # It's only an AutoHANDLE for legacy Python2 reasons that are non-trivial to remove + self._handle = int(self._handle) + self._internal_poll(_deadstate=_maxint) + # Revert it back to the saved 'handle' (AutoHANDLE) for self._cleanup() + self._handle = handle + if handle or self._job or self._io_port: + self._cleanup() + else: + subprocess.Popen.__del__(self) + + def kill(self, sig=None, timeout=None): + if isWin: + try: + if not self._ignore_children and self._handle and self._job: + self.debug("calling TerminateJobObject") + winprocess.TerminateJobObject( + self._job, winprocess.ERROR_CONTROL_C_EXIT + ) + elif self._handle: + self.debug("calling TerminateProcess") + winprocess.TerminateProcess( + self._handle, winprocess.ERROR_CONTROL_C_EXIT + ) + except WindowsError: + self._cleanup() + + traceback.print_exc() + raise OSError("Could not terminate process") + + else: + + def send_sig(sig, retries=0): + pid = self.detached_pid or self.pid + if not self._ignore_children: + try: + os.killpg(pid, sig) + except BaseException as e: + # On Mac OSX if the process group contains zombie + # processes, killpg results in an EPERM. + # In this case, zombie processes need to be reaped + # before continuing + # Note: A negative pid refers to the entire process + # group + if retries < 1 and getattr(e, "errno", None) == errno.EPERM: + try: + os.waitpid(-pid, 0) + finally: + return send_sig(sig, retries + 1) + + # ESRCH is a "no such process" failure, which is fine because the + # application might already have been terminated itself. Any other + # error would indicate a problem in killing the process. + if getattr(e, "errno", None) != errno.ESRCH: + print( + "Could not terminate process: %s" % self.pid, + file=sys.stderr, + ) + raise + else: + os.kill(pid, sig) + + if sig is None and isPosix: + # ask the process for termination and wait a bit + send_sig(signal.SIGTERM) + limit = time.time() + self.TIMEOUT_BEFORE_SIGKILL + while time.time() <= limit: + if self.poll() is not None: + # process terminated nicely + break + time.sleep(INTERVAL_PROCESS_ALIVE_CHECK) + else: + # process did not terminate - send SIGKILL to force + send_sig(signal.SIGKILL) + else: + # a signal was explicitly set or not posix + send_sig(sig or signal.SIGKILL) + + self.returncode = self.wait(timeout) + self._cleanup() + return self.returncode + + def poll(self): + """Popen.poll + Check if child process has terminated. Set and return returncode attribute. + """ + # If we have a handle, the process is alive + if isWin and getattr(self, "_handle", None): + return None + + return subprocess.Popen.poll(self) + + def wait(self, timeout=None): + """Popen.wait + Called to wait for a running process to shut down and return + its exit code + Returns the main process's exit code + """ + # This call will be different for each OS + self.returncode = self._custom_wait(timeout=timeout) + self._cleanup() + return self.returncode + + """ Private Members of Process class """ + + if isWin: + # Redefine the execute child so that we can track process groups + def _execute_child(self, *args_tuple): + ( + args, + executable, + preexec_fn, + close_fds, + pass_fds, + cwd, + env, + startupinfo, + creationflags, + shell, + p2cread, + p2cwrite, + c2pread, + c2pwrite, + errread, + errwrite, + *_, + ) = args_tuple + if not isinstance(args, six.string_types): + args = subprocess.list2cmdline(args) + + # Always or in the create new process group + creationflags |= winprocess.CREATE_NEW_PROCESS_GROUP + + if startupinfo is None: + startupinfo = winprocess.STARTUPINFO() + + if None not in (p2cread, c2pwrite, errwrite): + startupinfo.dwFlags |= winprocess.STARTF_USESTDHANDLES + startupinfo.hStdInput = int(p2cread) + startupinfo.hStdOutput = int(c2pwrite) + startupinfo.hStdError = int(errwrite) + if shell: + startupinfo.dwFlags |= winprocess.STARTF_USESHOWWINDOW + startupinfo.wShowWindow = winprocess.SW_HIDE + comspec = os.environ.get("COMSPEC", "cmd.exe") + args = comspec + " /c " + args + + # Determine if we can create a job or create nested jobs. + can_create_job = winprocess.CanCreateJobObject() + can_nest_jobs = self._can_nest_jobs() + + # Ensure we write a warning message if we are falling back + if not (can_create_job or can_nest_jobs) and not self._ignore_children: + # We can't create job objects AND the user wanted us to + # Warn the user about this. + print( + "ProcessManager UNABLE to use job objects to manage " + "child processes", + file=sys.stderr, + ) + + # set process creation flags + creationflags |= winprocess.CREATE_SUSPENDED + creationflags |= winprocess.CREATE_UNICODE_ENVIRONMENT + if can_create_job: + creationflags |= winprocess.CREATE_BREAKAWAY_FROM_JOB + if not (can_create_job or can_nest_jobs): + # Since we've warned, we just log info here to inform you + # of the consequence of setting ignore_children = True + print("ProcessManager NOT managing child processes") + + # create the process + hp, ht, pid, tid = winprocess.CreateProcess( + executable, + args, + None, + None, # No special security + 1, # Must inherit handles! + creationflags, + winprocess.EnvironmentBlock(env), + cwd, + startupinfo, + ) + self._child_created = True + self._handle = hp + self._thread = ht + self.pid = pid + self.tid = tid + + if not self._ignore_children and (can_create_job or can_nest_jobs): + try: + # We create a new job for this process, so that we can kill + # the process and any sub-processes + # Create the IO Completion Port + self._io_port = winprocess.CreateIoCompletionPort() + self._job = winprocess.CreateJobObject() + + # Now associate the io comp port and the job object + joacp = JOBOBJECT_ASSOCIATE_COMPLETION_PORT( + winprocess.COMPKEY_JOBOBJECT, self._io_port + ) + winprocess.SetInformationJobObject( + self._job, + JobObjectAssociateCompletionPortInformation, + addressof(joacp), + sizeof(joacp), + ) + + # Allow subprocesses to break away from us - necessary when + # Firefox restarts, or flash with protected mode + limit_flags = winprocess.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if not can_nest_jobs: + # This allows sandbox processes to create their own job, + # and is necessary to set for older versions of Windows + # without nested job support. + limit_flags |= winprocess.JOB_OBJECT_LIMIT_BREAKAWAY_OK + + jbli = JOBOBJECT_BASIC_LIMIT_INFORMATION( + c_longlong(0), # per process time limit (ignored) + c_longlong(0), # per job user time limit (ignored) + limit_flags, + 0, # min working set (ignored) + 0, # max working set (ignored) + 0, # active process limit (ignored) + None, # affinity (ignored) + 0, # Priority class (ignored) + 0, # Scheduling class (ignored) + ) + + iocntr = IO_COUNTERS() + jeli = JOBOBJECT_EXTENDED_LIMIT_INFORMATION( + jbli, # basic limit info struct + iocntr, # io_counters (ignored) + 0, # process mem limit (ignored) + 0, # job mem limit (ignored) + 0, # peak process limit (ignored) + 0, + ) # peak job limit (ignored) + + winprocess.SetInformationJobObject( + self._job, + JobObjectExtendedLimitInformation, + addressof(jeli), + sizeof(jeli), + ) + + # Assign the job object to the process + winprocess.AssignProcessToJobObject(self._job, int(hp)) + + # It's overkill, but we use Queue to signal between threads + # because it handles errors more gracefully than event or condition. + self._process_events = Queue() + + # Spin up our thread for managing the IO Completion Port + self._procmgrthread = threading.Thread(target=self._procmgr) + except Exception: + print( + """Exception trying to use job objects; +falling back to not using job objects for managing child processes""", + file=sys.stderr, + ) + tb = traceback.format_exc() + print(tb, file=sys.stderr) + # Ensure no dangling handles left behind + self._cleanup_job_io_port() + else: + self._job = None + + winprocess.ResumeThread(int(ht)) + if getattr(self, "_procmgrthread", None): + self._procmgrthread.start() + ht.Close() + + for i in (p2cread, c2pwrite, errwrite): + if i is not None: + i.Close() + + # Per: + # https://msdn.microsoft.com/en-us/library/windows/desktop/hh448388%28v=vs.85%29.aspx + # Nesting jobs came in with windows versions starting with 6.2 according to the table + # on this page: + # https://msdn.microsoft.com/en-us/library/ms724834%28v=vs.85%29.aspx + def _can_nest_jobs(self): + winver = sys.getwindowsversion() + return winver.major > 6 or winver.major == 6 and winver.minor >= 2 + + # Windows Process Manager - watches the IO Completion Port and + # keeps track of child processes + def _procmgr(self): + if not (self._io_port) or not (self._job): + return + + try: + self._poll_iocompletion_port() + except KeyboardInterrupt: + raise KeyboardInterrupt + + def _poll_iocompletion_port(self): + # Watch the IO Completion port for status + self._spawned_procs = {} + countdowntokill = 0 + + self.debug("start polling IO completion port") + + while True: + msgid = c_ulong(0) + compkey = c_ulong(0) + pid = c_ulong(0) + portstatus = winprocess.GetQueuedCompletionStatus( + self._io_port, byref(msgid), byref(compkey), byref(pid), 5000 + ) + + # If the countdowntokill has been activated, we need to check + # if we should start killing the children or not. + if countdowntokill != 0: + diff = datetime.now() - countdowntokill + # Arbitrarily wait 3 minutes for windows to get its act together + # Windows sometimes takes a small nap between notifying the + # IO Completion port and actually killing the children, and we + # don't want to mistake that situation for the situation of an unexpected + # parent abort (which is what we're looking for here). + if diff.seconds > self.MAX_IOCOMPLETION_PORT_NOTIFICATION_DELAY: + print( + "WARNING | IO Completion Port failed to signal " + "process shutdown", + file=sys.stderr, + ) + print( + "Parent process %s exited with children alive:" + % self.pid, + file=sys.stderr, + ) + print( + "PIDS: %s" + % ", ".join([str(i) for i in self._spawned_procs]), + file=sys.stderr, + ) + print( + "Attempting to kill them, but no guarantee of success", + file=sys.stderr, + ) + + self.kill() + self._process_events.put({self.pid: "FINISHED"}) + break + + if not portstatus: + # Check to see what happened + errcode = winprocess.GetLastError() + if errcode == winprocess.ERROR_ABANDONED_WAIT_0: + # Then something has killed the port, break the loop + print( + "IO Completion Port unexpectedly closed", + file=sys.stderr, + ) + self._process_events.put({self.pid: "FINISHED"}) + break + elif errcode == winprocess.WAIT_TIMEOUT: + # Timeouts are expected, just keep on polling + continue + else: + print( + "Error Code %s trying to query IO Completion Port, " + "exiting" % errcode, + file=sys.stderr, + ) + raise WinError(errcode) + break + + if compkey.value == winprocess.COMPKEY_TERMINATE.value: + self.debug("compkeyterminate detected") + # Then we're done + break + + # Check the status of the IO Port and do things based on it + if compkey.value == winprocess.COMPKEY_JOBOBJECT.value: + if msgid.value == winprocess.JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO: + # No processes left, time to shut down + # Signal anyone waiting on us that it is safe to shut down + self.debug("job object msg active processes zero") + self._process_events.put({self.pid: "FINISHED"}) + break + elif msgid.value == winprocess.JOB_OBJECT_MSG_NEW_PROCESS: + # New Process started + # Add the child proc to our list in case our parent flakes out on us + # without killing everything. + if pid.value != self.pid: + self._spawned_procs[pid.value] = 1 + self.debug( + "new process detected with pid value: %s" + % pid.value + ) + elif msgid.value == winprocess.JOB_OBJECT_MSG_EXIT_PROCESS: + self.debug("process id %s exited normally" % pid.value) + # One process exited normally + if pid.value == self.pid and len(self._spawned_procs) > 0: + # Parent process dying, start countdown timer + countdowntokill = datetime.now() + elif pid.value in self._spawned_procs: + # Child Process died remove from list + del self._spawned_procs[pid.value] + elif ( + msgid.value + == winprocess.JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS + ): + # One process existed abnormally + self.debug("process id %s exited abnormally" % pid.value) + if pid.value == self.pid and len(self._spawned_procs) > 0: + # Parent process dying, start countdown timer + countdowntokill = datetime.now() + elif pid.value in self._spawned_procs: + # Child Process died remove from list + del self._spawned_procs[pid.value] + else: + # We don't care about anything else + self.debug("We got a message %s" % msgid.value) + pass + + def _custom_wait(self, timeout=None): + """Custom implementation of wait. + + - timeout: number of seconds before timing out. If None, + will wait indefinitely. + """ + # First, check to see if the process is still running + if self._handle: + self.returncode = winprocess.GetExitCodeProcess(self._handle) + else: + # Dude, the process is like totally dead! + return self.returncode + + threadalive = False + if hasattr(self, "_procmgrthread"): + threadalive = self._procmgrthread.is_alive() + if ( + self._job + and threadalive + and threading.current_thread() != self._procmgrthread + ): + self.debug("waiting with IO completion port") + if timeout is None: + timeout = ( + self.MAX_IOCOMPLETION_PORT_NOTIFICATION_DELAY + + self.MAX_PROCESS_KILL_DELAY + ) + # Then we are managing with IO Completion Ports + # wait on a signal so we know when we have seen the last + # process come through. + # We use queues to synchronize between the thread and this + # function because events just didn't have robust enough error + # handling on pre-2.7 versions + try: + # timeout is the max amount of time the procmgr thread will wait for + # child processes to shutdown before killing them with extreme prejudice. + item = self._process_events.get(timeout=timeout) + if item[self.pid] == "FINISHED": + self.debug("received 'FINISHED' from _procmgrthread") + self._process_events.task_done() + except Exception: + traceback.print_exc() + raise OSError( + "IO Completion Port failed to signal process shutdown" + ) + finally: + if self._handle: + self.returncode = winprocess.GetExitCodeProcess( + self._handle + ) + self._cleanup() + + else: + # Not managing with job objects, so all we can reasonably do + # is call waitforsingleobject and hope for the best + self.debug("waiting without IO completion port") + + if not self._ignore_children: + self.debug("NOT USING JOB OBJECTS!!!") + # First, make sure we have not already ended + if self.returncode != winprocess.STILL_ACTIVE: + self._cleanup() + return self.returncode + + rc = None + if self._handle: + if timeout is None: + timeout = -1 + else: + # timeout for WaitForSingleObject is in ms + timeout = timeout * 1000 + + rc = winprocess.WaitForSingleObject(self._handle, timeout) + + if rc == winprocess.WAIT_TIMEOUT: + # The process isn't dead, so kill it + print( + "Timed out waiting for process to close, " + "attempting TerminateProcess" + ) + self.kill() + elif rc == winprocess.WAIT_OBJECT_0: + # We caught WAIT_OBJECT_0, which indicates all is well + print("Single process terminated successfully") + self.returncode = winprocess.GetExitCodeProcess(self._handle) + else: + # An error occured we should probably throw + rc = winprocess.GetLastError() + if rc: + raise WinError(rc) + + self._cleanup() + + return self.returncode + + def _cleanup_job_io_port(self): + """Do the job and IO port cleanup separately because there are + cases where we want to clean these without killing _handle + (i.e. if we fail to create the job object in the first place) + """ + if ( + getattr(self, "_job") + and self._job != winprocess.INVALID_HANDLE_VALUE + ): + self._job.Close() + self._job = None + else: + # If windows already freed our handle just set it to none + # (saw this intermittently while testing) + self._job = None + + if ( + getattr(self, "_io_port", None) + and self._io_port != winprocess.INVALID_HANDLE_VALUE + ): + self._io_port.Close() + self._io_port = None + else: + self._io_port = None + + if getattr(self, "_procmgrthread", None): + self._procmgrthread = None + + def _cleanup(self): + self._cleanup_job_io_port() + if self._thread and self._thread != winprocess.INVALID_HANDLE_VALUE: + self._thread.Close() + self._thread = None + else: + self._thread = None + + if self._handle and self._handle != winprocess.INVALID_HANDLE_VALUE: + self._handle.Close() + self._handle = None + else: + self._handle = None + + else: + + def _custom_wait(self, timeout=None): + """Haven't found any reason to differentiate between these platforms + so they all use the same wait callback. If it is necessary to + craft different styles of wait, then a new _custom_wait method + could be easily implemented. + """ + # For non-group wait, call base class + try: + subprocess.Popen.wait(self, timeout=timeout) + except subprocess.TimeoutExpired: + # We want to return None in this case + pass + return self.returncode + + def _cleanup(self): + pass + + def __init__( + self, + cmd, + args=None, + cwd=None, + env=None, + ignore_children=False, + kill_on_timeout=True, + processOutputLine=(), + processStderrLine=(), + onTimeout=(), + onFinish=(), + **kwargs + ): + self.cmd = cmd + self.args = args + self.cwd = cwd + self.didTimeout = False + self.didOutputTimeout = False + self._ignore_children = ignore_children + self.keywordargs = kwargs + self.read_buffer = "" + + if env is None: + env = os.environ.copy() + self.env = env + + # handlers + def to_callable_list(arg): + if callable(arg): + arg = [arg] + return CallableList(arg) + + processOutputLine = to_callable_list(processOutputLine) + processStderrLine = to_callable_list(processStderrLine) + onTimeout = to_callable_list(onTimeout) + onFinish = to_callable_list(onFinish) + + def on_timeout(): + self.didTimeout = True + self.didOutputTimeout = self.reader.didOutputTimeout + if kill_on_timeout: + self.kill() + + onTimeout.insert(0, on_timeout) + + self._stderr = subprocess.STDOUT + if processStderrLine: + self._stderr = subprocess.PIPE + self.reader = ProcessReader( + stdout_callback=processOutputLine, + stderr_callback=processStderrLine, + finished_callback=onFinish, + timeout_callback=onTimeout, + ) + + # It is common for people to pass in the entire array with the cmd and + # the args together since this is how Popen uses it. Allow for that. + if isinstance(self.cmd, list): + if self.args is not None: + raise TypeError("cmd and args must not both be lists") + (self.cmd, self.args) = (self.cmd[0], self.cmd[1:]) + elif self.args is None: + self.args = [] + + def debug(self, msg): + if not MOZPROCESS_DEBUG: + return + cmd = self.cmd.split(os.sep)[-1:] + print("DBG::MOZPROC ProcessHandlerMixin {} | {}".format(cmd, msg)) + + @property + def timedOut(self): + """True if the process has timed out for any reason.""" + return self.didTimeout + + @property + def outputTimedOut(self): + """True if the process has timed out for no output.""" + return self.didOutputTimeout + + @property + def commandline(self): + """the string value of the command line (command + args)""" + return subprocess.list2cmdline([self.cmd] + self.args) + + def run(self, timeout=None, outputTimeout=None): + """ + Starts the process. + + If timeout is not None, the process will be allowed to continue for + that number of seconds before being killed. If the process is killed + due to a timeout, the onTimeout handler will be called. + + If outputTimeout is not None, the process will be allowed to continue + for that number of seconds without producing any output before + being killed. + """ + self.didTimeout = False + self.didOutputTimeout = False + + # default arguments + args = dict( + stdout=subprocess.PIPE, + stderr=self._stderr, + cwd=self.cwd, + env=self.env, + ignore_children=self._ignore_children, + ) + + # build process arguments + args.update(self.keywordargs) + + # launch the process + self.proc = self.Process([self.cmd] + self.args, **args) + + if isPosix: + # Keep track of the initial process group in case the process detaches itself + self.proc.pgid = self._getpgid(self.proc.pid) + self.proc.detached_pid = None + + self.processOutput(timeout=timeout, outputTimeout=outputTimeout) + + def kill(self, sig=None, timeout=None): + """ + Kills the managed process. + + If you created the process with 'ignore_children=False' (the + default) then it will also also kill all child processes spawned by + it. If you specified 'ignore_children=True' when creating the + process, only the root process will be killed. + + Note that this does not manage any state, save any output etc, + it immediately kills the process. + + :param sig: Signal used to kill the process, defaults to SIGKILL + (has no effect on Windows) + """ + if not hasattr(self, "proc"): + raise RuntimeError("Process hasn't been started yet") + + self.proc.kill(sig=sig, timeout=timeout) + + # When we kill the the managed process we also have to wait for the + # reader thread to be finished. Otherwise consumers would have to assume + # that it still has not completely shutdown. + rc = self.wait(timeout) + if rc is None: + self.debug("kill: wait failed -- process is still alive") + return rc + + def poll(self): + """Check if child process has terminated + + Returns the current returncode value: + - None if the process hasn't terminated yet + - A negative number if the process was killed by signal N (Unix only) + - '0' if the process ended without failures + + """ + if not hasattr(self, "proc"): + raise RuntimeError("Process hasn't been started yet") + + # Ensure that we first check for the reader status. Otherwise + # we might mark the process as finished while output is still getting + # processed. + elif self.reader.is_alive(): + return None + elif hasattr(self, "returncode"): + return self.returncode + else: + return self.proc.poll() + + def processOutput(self, timeout=None, outputTimeout=None): + """ + Handle process output until the process terminates or times out. + + If timeout is not None, the process will be allowed to continue for + that number of seconds before being killed. + + If outputTimeout is not None, the process will be allowed to continue + for that number of seconds without producing any output before + being killed. + """ + # this method is kept for backward compatibility + if not hasattr(self, "proc"): + self.run(timeout=timeout, outputTimeout=outputTimeout) + # self.run will call this again + return + if not self.reader.is_alive(): + self.reader.timeout = timeout + self.reader.output_timeout = outputTimeout + self.reader.start(self.proc) + + def wait(self, timeout=None): + """ + Waits until all output has been read and the process is + terminated. + + If timeout is not None, will return after timeout seconds. + This timeout only causes the wait function to return and + does not kill the process. + + Returns the process exit code value: + - None if the process hasn't terminated yet + - A negative number if the process was killed by signal N (Unix only) + - '0' if the process ended without failures + + """ + # Thread.join() blocks the main thread until the reader thread is finished + # wake up once a second in case a keyboard interrupt is sent + if self.reader.thread and self.reader.thread is not threading.current_thread(): + count = 0 + while self.reader.is_alive(): + if timeout is not None and count > timeout: + self.debug("wait timeout for reader thread") + return None + self.reader.join(timeout=1) + count += 1 + + self.returncode = self.proc.wait(timeout) + return self.returncode + + @property + def pid(self): + if not hasattr(self, "proc"): + raise RuntimeError("Process hasn't been started yet") + + return self.proc.pid + + @staticmethod + def pid_exists(pid): + if pid < 0: + return False + + if isWin: + try: + process = winprocess.OpenProcess( + winprocess.PROCESS_QUERY_INFORMATION | winprocess.PROCESS_VM_READ, + False, + pid, + ) + return winprocess.GetExitCodeProcess(process) == winprocess.STILL_ACTIVE + + except WindowsError as e: + # no such process + if e.winerror == winprocess.ERROR_INVALID_PARAMETER: + return False + + # access denied + if e.winerror == winprocess.ERROR_ACCESS_DENIED: + return True + + # re-raise for any other type of exception + raise + + elif isPosix: + try: + os.kill(pid, 0) + except OSError as e: + return e.errno == errno.EPERM + else: + return True + + @classmethod + def _getpgid(cls, pid): + try: + return os.getpgid(pid) + except OSError as e: + # Do not raise for "No such process" + if e.errno != errno.ESRCH: + raise + + def check_for_detached(self, new_pid): + """Check if the current process has been detached and mark it appropriately. + + In case of application restarts the process can spawn itself into a new process group. + From now on the process can no longer be tracked by mozprocess anymore and has to be + marked as detached. If the consumer of mozprocess still knows the new process id it could + check for the detached state. + + new_pid is the new process id of the child process. + """ + if not hasattr(self, "proc"): + raise RuntimeError("Process hasn't been started yet") + + if isPosix: + new_pgid = self._getpgid(new_pid) + + if new_pgid and new_pgid != self.proc.pgid: + self.proc.detached_pid = new_pid + print( + 'Child process with id "%s" has been marked as detached because it is no ' + "longer in the managed process group. Keeping reference to the process id " + '"%s" which is the new child process.' % (self.pid, new_pid), + file=sys.stdout, + ) + + +class CallableList(list): + def __call__(self, *args, **kwargs): + for e in self: + e(*args, **kwargs) + + def __add__(self, lst): + return CallableList(list.__add__(self, lst)) + + +class ProcessReader(object): + def __init__( + self, + stdout_callback=None, + stderr_callback=None, + finished_callback=None, + timeout_callback=None, + timeout=None, + output_timeout=None, + ): + self.stdout_callback = stdout_callback or (lambda line: True) + self.stderr_callback = stderr_callback or (lambda line: True) + self.finished_callback = finished_callback or (lambda: True) + self.timeout_callback = timeout_callback or (lambda: True) + self.timeout = timeout + self.output_timeout = output_timeout + self.thread = None + self.didOutputTimeout = False + + def debug(self, msg): + if not MOZPROCESS_DEBUG: + return + print("DBG::MOZPROC ProcessReader | {}".format(msg)) + + def _create_stream_reader(self, name, stream, queue, callback): + thread = threading.Thread( + name=name, target=self._read_stream, args=(stream, queue, callback) + ) + thread.daemon = True + thread.start() + return thread + + def _read_stream(self, stream, queue, callback): + while True: + line = stream.readline() + if not line: + break + queue.put((line, callback)) + stream.close() + + def start(self, proc): + queue = Queue() + stdout_reader = None + if proc.stdout: + stdout_reader = self._create_stream_reader( + "ProcessReaderStdout", proc.stdout, queue, self.stdout_callback + ) + stderr_reader = None + if proc.stderr and proc.stderr != proc.stdout: + stderr_reader = self._create_stream_reader( + "ProcessReaderStderr", proc.stderr, queue, self.stderr_callback + ) + self.thread = threading.Thread( + name="ProcessReader", + target=self._read, + args=(stdout_reader, stderr_reader, queue), + ) + self.thread.daemon = True + self.thread.start() + self.debug("ProcessReader started") + + def _read(self, stdout_reader, stderr_reader, queue): + start_time = time.time() + timed_out = False + timeout = self.timeout + if timeout is not None: + timeout += start_time + output_timeout = self.output_timeout + if output_timeout is not None: + output_timeout += start_time + + while (stdout_reader and stdout_reader.is_alive()) or ( + stderr_reader and stderr_reader.is_alive() + ): + has_line = True + try: + line, callback = queue.get(True, INTERVAL_PROCESS_ALIVE_CHECK) + except Empty: + has_line = False + now = time.time() + if not has_line: + if output_timeout is not None and now > output_timeout: + timed_out = True + self.didOutputTimeout = True + break + else: + if output_timeout is not None: + output_timeout = now + self.output_timeout + callback(line.rstrip()) + if timeout is not None and now > timeout: + timed_out = True + break + self.debug("_read loop exited") + # process remaining lines to read + while not queue.empty(): + line, callback = queue.get(False) + try: + callback(line.rstrip()) + except Exception: + traceback.print_exc() + if timed_out: + try: + self.timeout_callback() + except Exception: + traceback.print_exc() + if stdout_reader: + stdout_reader.join() + if stderr_reader: + stderr_reader.join() + if not timed_out: + try: + self.finished_callback() + except Exception: + traceback.print_exc() + self.debug("_read exited") + + def is_alive(self): + if self.thread: + return self.thread.is_alive() + return False + + def join(self, timeout=None): + if self.thread: + self.thread.join(timeout=timeout) + + +# default output handlers +# these should be callables that take the output line + + +class StoreOutput(object): + """accumulate stdout""" + + def __init__(self): + self.output = [] + + def __call__(self, line): + self.output.append(line) + + +class StreamOutput(object): + """pass output to a stream and flush""" + + def __init__(self, stream, text=True): + self.stream = stream + self.text = text + + def __call__(self, line): + ensure = six.ensure_text if self.text else six.ensure_binary + try: + self.stream.write(ensure(line, errors="ignore") + ensure("\n")) + except TypeError: + print( + "HEY! If you're reading this, you're about to encounter a " + "type error, probably as a result of a conversion from " + "Python 2 to Python 3. This is almost definitely because " + "you're trying to write binary data to a text-encoded " + "stream, or text data to a binary-encoded stream. Check how " + "you're instantiating your ProcessHandler and if the output " + "should be text-encoded, make sure you pass " + "universal_newlines=True.", + file=sys.stderr, + ) + raise + self.stream.flush() + + +class LogOutput(StreamOutput): + """pass output to a file""" + + def __init__(self, filename): + self.file_obj = open(filename, "a") + StreamOutput.__init__(self, self.file_obj, True) + + def __del__(self): + if self.file_obj is not None: + self.file_obj.close() + + +# front end class with the default handlers + + +class ProcessHandler(ProcessHandlerMixin): + """ + Convenience class for handling processes with default output handlers. + + By default, all output is sent to stdout. This can be disabled by setting + the *stream* argument to None. + + If processOutputLine keyword argument is specified the function or the + list of functions specified by this argument will be called for each line + of output; the output will not be written to stdout automatically then + if stream is True (the default). + + If storeOutput==True, the output produced by the process will be saved + as self.output. + + If logfile is not None, the output produced by the process will be + appended to the given file. + """ + + def __init__(self, cmd, logfile=None, stream=True, storeOutput=True, **kwargs): + kwargs.setdefault("processOutputLine", []) + if callable(kwargs["processOutputLine"]): + kwargs["processOutputLine"] = [kwargs["processOutputLine"]] + + if logfile: + logoutput = LogOutput(logfile) + kwargs["processOutputLine"].append(logoutput) + + text = kwargs.get("universal_newlines", False) or kwargs.get("text", False) + + if stream is True: + if text: + # The encoding of stdout isn't guaranteed to be utf-8. Fix that. + stdout = codecs.getwriter("utf-8")(sys.stdout.buffer) + else: + stdout = sys.stdout.buffer + + if not kwargs["processOutputLine"]: + kwargs["processOutputLine"].append(StreamOutput(stdout, text)) + elif stream: + streamoutput = StreamOutput(stream, text) + kwargs["processOutputLine"].append(streamoutput) + + self.output = None + if storeOutput: + storeoutput = StoreOutput() + self.output = storeoutput.output + kwargs["processOutputLine"].append(storeoutput) + + ProcessHandlerMixin.__init__(self, cmd, **kwargs) diff --git a/testing/mozbase/mozprocess/mozprocess/qijo.py b/testing/mozbase/mozprocess/mozprocess/qijo.py new file mode 100644 index 0000000000..a9cedd2ad5 --- /dev/null +++ b/testing/mozbase/mozprocess/mozprocess/qijo.py @@ -0,0 +1,175 @@ +# 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 ctypes import ( + POINTER, + WINFUNCTYPE, + Structure, + WinError, + addressof, + c_size_t, + c_ulong, + c_void_p, + sizeof, + windll, +) +from ctypes.wintypes import BOOL, BYTE, DWORD, HANDLE, LARGE_INTEGER + +import six + +LPVOID = c_void_p +LPDWORD = POINTER(DWORD) +SIZE_T = c_size_t +ULONG_PTR = POINTER(c_ulong) + +# A ULONGLONG is a 64-bit unsigned integer. +# Thus there are 8 bytes in a ULONGLONG. +# XXX why not import c_ulonglong ? +ULONGLONG = BYTE * 8 + + +class IO_COUNTERS(Structure): + # The IO_COUNTERS struct is 6 ULONGLONGs. + # TODO: Replace with non-dummy fields. + _fields_ = [("dummy", ULONGLONG * 6)] + + +class JOBOBJECT_BASIC_ACCOUNTING_INFORMATION(Structure): + _fields_ = [ + ("TotalUserTime", LARGE_INTEGER), + ("TotalKernelTime", LARGE_INTEGER), + ("ThisPeriodTotalUserTime", LARGE_INTEGER), + ("ThisPeriodTotalKernelTime", LARGE_INTEGER), + ("TotalPageFaultCount", DWORD), + ("TotalProcesses", DWORD), + ("ActiveProcesses", DWORD), + ("TotalTerminatedProcesses", DWORD), + ] + + +class JOBOBJECT_BASIC_AND_IO_ACCOUNTING_INFORMATION(Structure): + _fields_ = [ + ("BasicInfo", JOBOBJECT_BASIC_ACCOUNTING_INFORMATION), + ("IoInfo", IO_COUNTERS), + ] + + +# see http://msdn.microsoft.com/en-us/library/ms684147%28VS.85%29.aspx +class JOBOBJECT_BASIC_LIMIT_INFORMATION(Structure): + _fields_ = [ + ("PerProcessUserTimeLimit", LARGE_INTEGER), + ("PerJobUserTimeLimit", LARGE_INTEGER), + ("LimitFlags", DWORD), + ("MinimumWorkingSetSize", SIZE_T), + ("MaximumWorkingSetSize", SIZE_T), + ("ActiveProcessLimit", DWORD), + ("Affinity", ULONG_PTR), + ("PriorityClass", DWORD), + ("SchedulingClass", DWORD), + ] + + +class JOBOBJECT_ASSOCIATE_COMPLETION_PORT(Structure): + _fields_ = [("CompletionKey", c_ulong), ("CompletionPort", HANDLE)] + + +# see http://msdn.microsoft.com/en-us/library/ms684156%28VS.85%29.aspx +class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(Structure): + _fields_ = [ + ("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION), + ("IoInfo", IO_COUNTERS), + ("ProcessMemoryLimit", SIZE_T), + ("JobMemoryLimit", SIZE_T), + ("PeakProcessMemoryUsed", SIZE_T), + ("PeakJobMemoryUsed", SIZE_T), + ] + + +# These numbers below come from: +# http://msdn.microsoft.com/en-us/library/ms686216%28v=vs.85%29.aspx +JobObjectAssociateCompletionPortInformation = 7 +JobObjectBasicAndIoAccountingInformation = 8 +JobObjectExtendedLimitInformation = 9 + + +class JobObjectInfo(object): + mapping = { + "JobObjectBasicAndIoAccountingInformation": 8, + "JobObjectExtendedLimitInformation": 9, + "JobObjectAssociateCompletionPortInformation": 7, + } + structures = { + 7: JOBOBJECT_ASSOCIATE_COMPLETION_PORT, + 8: JOBOBJECT_BASIC_AND_IO_ACCOUNTING_INFORMATION, + 9: JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + } + + def __init__(self, _class): + if isinstance(_class, six.string_types): + assert _class in self.mapping, "Class should be one of %s; you gave %s" % ( + self.mapping, + _class, + ) + _class = self.mapping[_class] + assert _class in self.structures, "Class should be one of %s; you gave %s" % ( + self.structures, + _class, + ) + self.code = _class + self.info = self.structures[_class]() + + +QueryInformationJobObjectProto = WINFUNCTYPE( + BOOL, # Return type + HANDLE, # hJob + DWORD, # JobObjectInfoClass + LPVOID, # lpJobObjectInfo + DWORD, # cbJobObjectInfoLength + LPDWORD, # lpReturnLength +) + +QueryInformationJobObjectFlags = ( + (1, "hJob"), + (1, "JobObjectInfoClass"), + (1, "lpJobObjectInfo"), + (1, "cbJobObjectInfoLength"), + (1, "lpReturnLength", None), +) + +_QueryInformationJobObject = QueryInformationJobObjectProto( + ("QueryInformationJobObject", windll.kernel32), QueryInformationJobObjectFlags +) + + +class SubscriptableReadOnlyStruct(object): + def __init__(self, struct): + self._struct = struct + + def _delegate(self, name): + result = getattr(self._struct, name) + if isinstance(result, Structure): + return SubscriptableReadOnlyStruct(result) + return result + + def __getitem__(self, name): + match = [fname for fname, ftype in self._struct._fields_ if fname == name] + if match: + return self._delegate(name) + raise KeyError(name) + + def __getattr__(self, name): + return self._delegate(name) + + +def QueryInformationJobObject(hJob, JobObjectInfoClass): + jobinfo = JobObjectInfo(JobObjectInfoClass) + result = _QueryInformationJobObject( + hJob=hJob, + JobObjectInfoClass=jobinfo.code, + lpJobObjectInfo=addressof(jobinfo.info), + cbJobObjectInfoLength=sizeof(jobinfo.info), + ) + if not result: + raise WinError() + return SubscriptableReadOnlyStruct(jobinfo.info) diff --git a/testing/mozbase/mozprocess/mozprocess/winprocess.py b/testing/mozbase/mozprocess/mozprocess/winprocess.py new file mode 100644 index 0000000000..1d0bfe9676 --- /dev/null +++ b/testing/mozbase/mozprocess/mozprocess/winprocess.py @@ -0,0 +1,565 @@ +# 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/. + +# A module to expose various thread/process/job related structures and +# methods from kernel32 +# +# The MIT License +# +# Copyright (c) 2003-2004 by Peter Astrand <astrand@lysator.liu.se> +# +# Additions and modifications written by Benjamin Smedberg +# <benjamin@smedbergs.us> are Copyright (c) 2006 by the Mozilla Foundation +# <http://www.mozilla.org/> +# +# More Modifications +# Copyright (c) 2006-2007 by Mike Taylor <bear@code-bear.com> +# Copyright (c) 2007-2008 by Mikeal Rogers <mikeal@mozilla.com> +# +# By obtaining, using, and/or copying this software and/or its +# associated documentation, you agree that you have read, understood, +# and will comply with the following terms and conditions: +# +# Permission to use, copy, modify, and distribute this software and +# its associated documentation for any purpose and without fee is +# hereby granted, provided that the above copyright notice appears in +# all copies, and that both that copyright notice and this permission +# notice appear in supporting documentation, and that the name of the +# author not be used in advertising or publicity pertaining to +# distribution of the software without specific, written prior +# permission. +# +# THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, +# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. +# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, INDIRECT OR +# CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +# OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION +# WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +import subprocess +import sys +from ctypes import ( + POINTER, + WINFUNCTYPE, + Structure, + WinError, + c_ulong, + c_void_p, + cast, + create_unicode_buffer, + sizeof, + windll, +) +from ctypes.wintypes import BOOL, BYTE, DWORD, HANDLE, LPCWSTR, LPWSTR, UINT, WORD + +from .qijo import QueryInformationJobObject + +LPVOID = c_void_p +LPBYTE = POINTER(BYTE) +LPDWORD = POINTER(DWORD) +LPBOOL = POINTER(BOOL) +LPULONG = POINTER(c_ulong) + + +def ErrCheckBool(result, func, args): + """errcheck function for Windows functions that return a BOOL True + on success""" + if not result: + raise WinError() + return args + + +# AutoHANDLE + + +class AutoHANDLE(HANDLE): + """Subclass of HANDLE which will call CloseHandle() on deletion.""" + + CloseHandleProto = WINFUNCTYPE(BOOL, HANDLE) + CloseHandle = CloseHandleProto(("CloseHandle", windll.kernel32)) + CloseHandle.errcheck = ErrCheckBool + + def Close(self): + if self.value and self.value != HANDLE(-1).value: + self.CloseHandle(self) + self.value = 0 + + def __del__(self): + self.Close() + + def __int__(self): + return self.value + + +def ErrCheckHandle(result, func, args): + """errcheck function for Windows functions that return a HANDLE.""" + if not result: + raise WinError() + return AutoHANDLE(result) + + +# PROCESS_INFORMATION structure + + +class PROCESS_INFORMATION(Structure): + _fields_ = [ + ("hProcess", HANDLE), + ("hThread", HANDLE), + ("dwProcessID", DWORD), + ("dwThreadID", DWORD), + ] + + def __init__(self): + Structure.__init__(self) + + self.cb = sizeof(self) + + +LPPROCESS_INFORMATION = POINTER(PROCESS_INFORMATION) + + +# STARTUPINFO structure + + +class STARTUPINFO(Structure): + _fields_ = [ + ("cb", DWORD), + ("lpReserved", LPWSTR), + ("lpDesktop", LPWSTR), + ("lpTitle", LPWSTR), + ("dwX", DWORD), + ("dwY", DWORD), + ("dwXSize", DWORD), + ("dwYSize", DWORD), + ("dwXCountChars", DWORD), + ("dwYCountChars", DWORD), + ("dwFillAttribute", DWORD), + ("dwFlags", DWORD), + ("wShowWindow", WORD), + ("cbReserved2", WORD), + ("lpReserved2", LPBYTE), + ("hStdInput", HANDLE), + ("hStdOutput", HANDLE), + ("hStdError", HANDLE), + ] + + +LPSTARTUPINFO = POINTER(STARTUPINFO) + +SW_HIDE = 0 + +STARTF_USESHOWWINDOW = 0x01 +STARTF_USESIZE = 0x02 +STARTF_USEPOSITION = 0x04 +STARTF_USECOUNTCHARS = 0x08 +STARTF_USEFILLATTRIBUTE = 0x10 +STARTF_RUNFULLSCREEN = 0x20 +STARTF_FORCEONFEEDBACK = 0x40 +STARTF_FORCEOFFFEEDBACK = 0x80 +STARTF_USESTDHANDLES = 0x100 + + +# EnvironmentBlock + + +class EnvironmentBlock: + """An object which can be passed as the lpEnv parameter of CreateProcess. + It is initialized with a dictionary.""" + + def __init__(self, env): + if not env: + self._as_parameter_ = None + else: + values = [] + fs_encoding = sys.getfilesystemencoding() or "mbcs" + for k, v in env.items(): + if isinstance(k, bytes): + k = k.decode(fs_encoding, "replace") + if isinstance(v, bytes): + v = v.decode(fs_encoding, "replace") + values.append("{}={}".format(k, v)) + + # The lpEnvironment parameter of the 'CreateProcess' function expects a series + # of null terminated strings followed by a final null terminator. We write this + # value to a buffer and then cast it to LPCWSTR to avoid a Python ctypes bug + # that probihits embedded null characters (https://bugs.python.org/issue32745). + values = create_unicode_buffer("\0".join(values) + "\0") + self._as_parameter_ = cast(values, LPCWSTR) + + +# Error Messages we need to watch for go here + +# https://msdn.microsoft.com/en-us/library/windows/desktop/ms681382(v=vs.85).aspx (0 - 499) +ERROR_ACCESS_DENIED = 5 +ERROR_INVALID_PARAMETER = 87 + +# http://msdn.microsoft.com/en-us/library/ms681388%28v=vs.85%29.aspx (500 - 999) +ERROR_ABANDONED_WAIT_0 = 735 + +# GetLastError() +GetLastErrorProto = WINFUNCTYPE(DWORD) # Return Type +GetLastErrorFlags = () +GetLastError = GetLastErrorProto(("GetLastError", windll.kernel32), GetLastErrorFlags) + +# CreateProcess() + +CreateProcessProto = WINFUNCTYPE( + BOOL, # Return type + LPCWSTR, # lpApplicationName + LPWSTR, # lpCommandLine + LPVOID, # lpProcessAttributes + LPVOID, # lpThreadAttributes + BOOL, # bInheritHandles + DWORD, # dwCreationFlags + LPVOID, # lpEnvironment + LPCWSTR, # lpCurrentDirectory + LPSTARTUPINFO, # lpStartupInfo + LPPROCESS_INFORMATION, # lpProcessInformation +) + +CreateProcessFlags = ( + (1, "lpApplicationName", None), + (1, "lpCommandLine"), + (1, "lpProcessAttributes", None), + (1, "lpThreadAttributes", None), + (1, "bInheritHandles", True), + (1, "dwCreationFlags", 0), + (1, "lpEnvironment", None), + (1, "lpCurrentDirectory", None), + (1, "lpStartupInfo"), + (2, "lpProcessInformation"), +) + + +def ErrCheckCreateProcess(result, func, args): + ErrCheckBool(result, func, args) + # return a tuple (hProcess, hThread, dwProcessID, dwThreadID) + pi = args[9] + return ( + AutoHANDLE(pi.hProcess), + AutoHANDLE(pi.hThread), + pi.dwProcessID, + pi.dwThreadID, + ) + + +CreateProcess = CreateProcessProto( + ("CreateProcessW", windll.kernel32), CreateProcessFlags +) +CreateProcess.errcheck = ErrCheckCreateProcess + +# flags for CreateProcess +CREATE_BREAKAWAY_FROM_JOB = 0x01000000 +CREATE_DEFAULT_ERROR_MODE = 0x04000000 +CREATE_NEW_CONSOLE = 0x00000010 +CREATE_NEW_PROCESS_GROUP = 0x00000200 +CREATE_NO_WINDOW = 0x08000000 +CREATE_SUSPENDED = 0x00000004 +CREATE_UNICODE_ENVIRONMENT = 0x00000400 + +# Flags for IOCompletion ports (some of these would probably be defined if +# we used the win32 extensions for python, but we don't want to do that if we +# can help it. +INVALID_HANDLE_VALUE = HANDLE(-1) # From winbase.h + +# Self Defined Constants for IOPort <--> Job Object communication +COMPKEY_TERMINATE = c_ulong(0) +COMPKEY_JOBOBJECT = c_ulong(1) + +# flags for job limit information +# see http://msdn.microsoft.com/en-us/library/ms684147%28VS.85%29.aspx +JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x00000800 +JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK = 0x00001000 +JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 + +# Flags for Job Object Completion Port Message IDs from winnt.h +# See also: http://msdn.microsoft.com/en-us/library/ms684141%28v=vs.85%29.aspx +JOB_OBJECT_MSG_END_OF_JOB_TIME = 1 +JOB_OBJECT_MSG_END_OF_PROCESS_TIME = 2 +JOB_OBJECT_MSG_ACTIVE_PROCESS_LIMIT = 3 +JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO = 4 +JOB_OBJECT_MSG_NEW_PROCESS = 6 +JOB_OBJECT_MSG_EXIT_PROCESS = 7 +JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS = 8 +JOB_OBJECT_MSG_PROCESS_MEMORY_LIMIT = 9 +JOB_OBJECT_MSG_JOB_MEMORY_LIMIT = 10 + +# See winbase.h +DEBUG_ONLY_THIS_PROCESS = 0x00000002 +DEBUG_PROCESS = 0x00000001 +DETACHED_PROCESS = 0x00000008 + +# OpenProcess - +# https://msdn.microsoft.com/en-us/library/windows/desktop/ms684320(v=vs.85).aspx +PROCESS_QUERY_INFORMATION = 0x0400 +PROCESS_VM_READ = 0x0010 + +OpenProcessProto = WINFUNCTYPE( + HANDLE, # Return type + DWORD, # dwDesiredAccess + BOOL, # bInheritHandle + DWORD, # dwProcessId +) + +OpenProcessFlags = ( + (1, "dwDesiredAccess", 0), + (1, "bInheritHandle", False), + (1, "dwProcessId", 0), +) + + +def ErrCheckOpenProcess(result, func, args): + ErrCheckBool(result, func, args) + + return AutoHANDLE(result) + + +OpenProcess = OpenProcessProto(("OpenProcess", windll.kernel32), OpenProcessFlags) +OpenProcess.errcheck = ErrCheckOpenProcess + +# GetQueuedCompletionPortStatus - +# http://msdn.microsoft.com/en-us/library/aa364986%28v=vs.85%29.aspx +GetQueuedCompletionStatusProto = WINFUNCTYPE( + BOOL, # Return Type + HANDLE, # Completion Port + LPDWORD, # Msg ID + LPULONG, # Completion Key + # PID Returned from the call (may be null) + LPULONG, + DWORD, +) # milliseconds to wait +GetQueuedCompletionStatusFlags = ( + (1, "CompletionPort", INVALID_HANDLE_VALUE), + (1, "lpNumberOfBytes", None), + (1, "lpCompletionKey", None), + (1, "lpPID", None), + (1, "dwMilliseconds", 0), +) +GetQueuedCompletionStatus = GetQueuedCompletionStatusProto( + ("GetQueuedCompletionStatus", windll.kernel32), GetQueuedCompletionStatusFlags +) + +# CreateIOCompletionPort +# Note that the completion key is just a number, not a pointer. +CreateIoCompletionPortProto = WINFUNCTYPE( + HANDLE, # Return Type + HANDLE, # File Handle + HANDLE, # Existing Completion Port + c_ulong, # Completion Key + DWORD, +) # Number of Threads + +CreateIoCompletionPortFlags = ( + (1, "FileHandle", INVALID_HANDLE_VALUE), + (1, "ExistingCompletionPort", 0), + (1, "CompletionKey", c_ulong(0)), + (1, "NumberOfConcurrentThreads", 0), +) +CreateIoCompletionPort = CreateIoCompletionPortProto( + ("CreateIoCompletionPort", windll.kernel32), CreateIoCompletionPortFlags +) +CreateIoCompletionPort.errcheck = ErrCheckHandle + +# SetInformationJobObject +SetInformationJobObjectProto = WINFUNCTYPE( + BOOL, # Return Type + HANDLE, # Job Handle + DWORD, # Type of Class next param is + LPVOID, # Job Object Class + DWORD, +) # Job Object Class Length + +SetInformationJobObjectProtoFlags = ( + (1, "hJob", None), + (1, "JobObjectInfoClass", None), + (1, "lpJobObjectInfo", None), + (1, "cbJobObjectInfoLength", 0), +) +SetInformationJobObject = SetInformationJobObjectProto( + ("SetInformationJobObject", windll.kernel32), SetInformationJobObjectProtoFlags +) +SetInformationJobObject.errcheck = ErrCheckBool + +# CreateJobObject() +CreateJobObjectProto = WINFUNCTYPE( + HANDLE, LPVOID, LPCWSTR # Return type # lpJobAttributes # lpName +) + +CreateJobObjectFlags = ((1, "lpJobAttributes", None), (1, "lpName", None)) + +CreateJobObject = CreateJobObjectProto( + ("CreateJobObjectW", windll.kernel32), CreateJobObjectFlags +) +CreateJobObject.errcheck = ErrCheckHandle + +# AssignProcessToJobObject() + +AssignProcessToJobObjectProto = WINFUNCTYPE( + BOOL, HANDLE, HANDLE # Return type # hJob # hProcess +) +AssignProcessToJobObjectFlags = ((1, "hJob"), (1, "hProcess")) +AssignProcessToJobObject = AssignProcessToJobObjectProto( + ("AssignProcessToJobObject", windll.kernel32), AssignProcessToJobObjectFlags +) +AssignProcessToJobObject.errcheck = ErrCheckBool + +# GetCurrentProcess() +# because os.getPid() is way too easy +GetCurrentProcessProto = WINFUNCTYPE(HANDLE) # Return type +GetCurrentProcessFlags = () +GetCurrentProcess = GetCurrentProcessProto( + ("GetCurrentProcess", windll.kernel32), GetCurrentProcessFlags +) +GetCurrentProcess.errcheck = ErrCheckHandle + +# IsProcessInJob() +try: + IsProcessInJobProto = WINFUNCTYPE( + BOOL, # Return type + HANDLE, # Process Handle + HANDLE, # Job Handle + LPBOOL, # Result + ) + IsProcessInJobFlags = ( + (1, "ProcessHandle"), + (1, "JobHandle", HANDLE(0)), + (2, "Result"), + ) + IsProcessInJob = IsProcessInJobProto( + ("IsProcessInJob", windll.kernel32), IsProcessInJobFlags + ) + IsProcessInJob.errcheck = ErrCheckBool +except AttributeError: + # windows 2k doesn't have this API + def IsProcessInJob(process): + return False + + +# ResumeThread() + + +def ErrCheckResumeThread(result, func, args): + if result == -1: + raise WinError() + + return args + + +ResumeThreadProto = WINFUNCTYPE(DWORD, HANDLE) # Return type # hThread +ResumeThreadFlags = ((1, "hThread"),) +ResumeThread = ResumeThreadProto(("ResumeThread", windll.kernel32), ResumeThreadFlags) +ResumeThread.errcheck = ErrCheckResumeThread + +# TerminateProcess() + +TerminateProcessProto = WINFUNCTYPE( + BOOL, HANDLE, UINT # Return type # hProcess # uExitCode +) +TerminateProcessFlags = ((1, "hProcess"), (1, "uExitCode", 127)) +TerminateProcess = TerminateProcessProto( + ("TerminateProcess", windll.kernel32), TerminateProcessFlags +) +TerminateProcess.errcheck = ErrCheckBool + +# TerminateJobObject() + +TerminateJobObjectProto = WINFUNCTYPE( + BOOL, HANDLE, UINT # Return type # hJob # uExitCode +) +TerminateJobObjectFlags = ((1, "hJob"), (1, "uExitCode", 127)) +TerminateJobObject = TerminateJobObjectProto( + ("TerminateJobObject", windll.kernel32), TerminateJobObjectFlags +) +TerminateJobObject.errcheck = ErrCheckBool + +# WaitForSingleObject() + +WaitForSingleObjectProto = WINFUNCTYPE( + DWORD, + HANDLE, + DWORD, # Return type # hHandle # dwMilliseconds +) +WaitForSingleObjectFlags = ((1, "hHandle"), (1, "dwMilliseconds", -1)) +WaitForSingleObject = WaitForSingleObjectProto( + ("WaitForSingleObject", windll.kernel32), WaitForSingleObjectFlags +) + +# http://msdn.microsoft.com/en-us/library/ms681381%28v=vs.85%29.aspx +INFINITE = -1 +WAIT_TIMEOUT = 0x0102 +WAIT_OBJECT_0 = 0x0 +WAIT_ABANDONED = 0x0080 + +# http://msdn.microsoft.com/en-us/library/ms683189%28VS.85%29.aspx +STILL_ACTIVE = 259 + +# Used when we terminate a process. +ERROR_CONTROL_C_EXIT = 0x23C +ERROR_CONTROL_C_EXIT_DECIMAL = 3221225786 + +# GetExitCodeProcess() + +GetExitCodeProcessProto = WINFUNCTYPE( + BOOL, + HANDLE, + LPDWORD, # Return type # hProcess # lpExitCode +) +GetExitCodeProcessFlags = ((1, "hProcess"), (2, "lpExitCode")) +GetExitCodeProcess = GetExitCodeProcessProto( + ("GetExitCodeProcess", windll.kernel32), GetExitCodeProcessFlags +) +GetExitCodeProcess.errcheck = ErrCheckBool + + +def CanCreateJobObject(): + currentProc = GetCurrentProcess() + if IsProcessInJob(currentProc): + jobinfo = QueryInformationJobObject( + HANDLE(0), "JobObjectExtendedLimitInformation" + ) + limitflags = jobinfo["BasicLimitInformation"]["LimitFlags"] + return bool(limitflags & JOB_OBJECT_LIMIT_BREAKAWAY_OK) or bool( + limitflags & JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK + ) + else: + return True + + +# testing functions + + +def parent(): + print("Starting parent") + currentProc = GetCurrentProcess() + if IsProcessInJob(currentProc): + print("You should not be in a job object to test", file=sys.stderr) + sys.exit(1) + assert CanCreateJobObject() + print("File: %s" % __file__) + command = [sys.executable, __file__, "-child"] + print("Running command: %s" % command) + process = subprocess.Popen(command) + process.kill() + code = process.returncode + print("Child code: %s" % code) + assert code == 127 + + +def child(): + print("Starting child") + currentProc = GetCurrentProcess() + injob = IsProcessInJob(currentProc) + print("Is in a job?: %s" % injob) + can_create = CanCreateJobObject() + print("Can create job?: %s" % can_create) + process = subprocess.Popen("c:\\windows\\notepad.exe") + assert process._job + jobinfo = QueryInformationJobObject( + process._job, "JobObjectExtendedLimitInformation" + ) + print("Job info: %s" % jobinfo) + limitflags = jobinfo["BasicLimitInformation"]["LimitFlags"] + print("LimitFlags: %s" % limitflags) + process.kill() diff --git a/testing/mozbase/mozprocess/setup.cfg b/testing/mozbase/mozprocess/setup.cfg new file mode 100644 index 0000000000..2a9acf13da --- /dev/null +++ b/testing/mozbase/mozprocess/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 diff --git a/testing/mozbase/mozprocess/setup.py b/testing/mozbase/mozprocess/setup.py new file mode 100644 index 0000000000..917e2aa9f7 --- /dev/null +++ b/testing/mozbase/mozprocess/setup.py @@ -0,0 +1,36 @@ +# 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 setuptools import setup + +PACKAGE_VERSION = "1.3.1" + +setup( + name="mozprocess", + version=PACKAGE_VERSION, + description="Mozilla-authored process handling", + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + classifiers=[ + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.5", + "Topic :: Software Development :: Libraries :: Python Modules", + ], + keywords="mozilla", + author="Mozilla Automation and Tools team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + license="MPL 2.0", + packages=["mozprocess"], + include_package_data=True, + zip_safe=False, + install_requires=["mozinfo"], + entry_points=""" + # -*- Entry points: -*- + """, +) diff --git a/testing/mozbase/mozprocess/tests/manifest.toml b/testing/mozbase/mozprocess/tests/manifest.toml new file mode 100644 index 0000000000..f8d2d63c4e --- /dev/null +++ b/testing/mozbase/mozprocess/tests/manifest.toml @@ -0,0 +1,23 @@ +[DEFAULT] +subsuite = "mozbase" + +["test_detached.py"] +skip-if = ["os == 'win'"] # Bug 1493796 + +["test_kill.py"] + +["test_misc.py"] + +["test_output.py"] + +["test_params.py"] + +["test_pid.py"] + +["test_poll.py"] + +["test_process_reader.py"] + +["test_run_and_wait.py"] + +["test_wait.py"] diff --git a/testing/mozbase/mozprocess/tests/process_normal_broad.ini b/testing/mozbase/mozprocess/tests/process_normal_broad.ini new file mode 100644 index 0000000000..28109cb31e --- /dev/null +++ b/testing/mozbase/mozprocess/tests/process_normal_broad.ini @@ -0,0 +1,30 @@ +; Generate a Broad Process Tree +; This generates a Tree of the form: +; +; main +; \_ c1 +; | \_ c2 +; | \_ c2 +; | \_ c2 +; | \_ c2 +; | \_ c2 +; | +; \_ c1 +; | \_ c2 +; | \_ c2 +; | \_ c2 +; | \_ c2 +; | \_ c2 +; | +; \_ ... 23 more times + +[main] +children=25*c1 +maxtime=10 + +[c1] +children=5*c2 +maxtime=10 + +[c2] +maxtime=5 diff --git a/testing/mozbase/mozprocess/tests/process_normal_deep.ini b/testing/mozbase/mozprocess/tests/process_normal_deep.ini new file mode 100644 index 0000000000..ef9809f6ab --- /dev/null +++ b/testing/mozbase/mozprocess/tests/process_normal_deep.ini @@ -0,0 +1,65 @@ +; Deep Process Tree +; Should generate a process tree of the form: +; +; main +; \_ c2 +; | \_ c5 +; | | \_ c6 +; | | \_ c7 +; | | \_ c8 +; | | \_ c1 +; | | \_ c4 +; | \_ c5 +; | \_ c6 +; | \_ c7 +; | \_ c8 +; | \_ c1 +; | \_ c4 +; \_ c2 +; | \_ c5 +; | | \_ c6 +; | | \_ c7 +; | | \_ c8 +; | | \_ c1 +; | | \_ c4 +; | \_ c5 +; | \_ c6 +; | \_ c7 +; | \_ c8 +; | \_ c1 +; | \_ c4 +; \_ c1 +; | \_ c4 +; \_ c1 +; \_ c4 + +[main] +children=2*c1, 2*c2 +maxtime=20 + +[c1] +children=c4 +maxtime=20 + +[c2] +children=2*c5 +maxtime=20 + +[c4] +maxtime=20 + +[c5] +children=c6 +maxtime=20 + +[c6] +children=c7 +maxtime=20 + +[c7] +children=c8 +maxtime=20 + +[c8] +children=c1 +maxtime=20 diff --git a/testing/mozbase/mozprocess/tests/process_normal_finish.ini b/testing/mozbase/mozprocess/tests/process_normal_finish.ini new file mode 100644 index 0000000000..4519c70830 --- /dev/null +++ b/testing/mozbase/mozprocess/tests/process_normal_finish.ini @@ -0,0 +1,17 @@ +; Generates a normal process tree +; Tree is of the form: +; main +; \_ c1 +; \_ c2 + +[main] +children=c1,c2 +maxtime=10 + +[c1] +children=c2 +maxtime=5 + +[c2] +maxtime=5 + diff --git a/testing/mozbase/mozprocess/tests/process_normal_finish_no_process_group.ini b/testing/mozbase/mozprocess/tests/process_normal_finish_no_process_group.ini new file mode 100644 index 0000000000..2b0f1f9a4f --- /dev/null +++ b/testing/mozbase/mozprocess/tests/process_normal_finish_no_process_group.ini @@ -0,0 +1,2 @@ +[main] +maxtime=10 diff --git a/testing/mozbase/mozprocess/tests/process_waittimeout.ini b/testing/mozbase/mozprocess/tests/process_waittimeout.ini new file mode 100644 index 0000000000..5800267d18 --- /dev/null +++ b/testing/mozbase/mozprocess/tests/process_waittimeout.ini @@ -0,0 +1,16 @@ +; Generates a normal process tree +; Tree is of the form: +; main +; \_ c1 +; \_ c2 + +[main] +children=2*c1 +maxtime=300 + +[c1] +children=2*c2 +maxtime=300 + +[c2] +maxtime=300 diff --git a/testing/mozbase/mozprocess/tests/process_waittimeout_10s.ini b/testing/mozbase/mozprocess/tests/process_waittimeout_10s.ini new file mode 100644 index 0000000000..abf8d6a4ef --- /dev/null +++ b/testing/mozbase/mozprocess/tests/process_waittimeout_10s.ini @@ -0,0 +1,16 @@ +; Generate a normal process tree +; Tree is of the form: +; main +; \_ c1 +; \_ c2 + +[main] +children=c1 +maxtime=10 + +[c1] +children=2*c2 +maxtime=5 + +[c2] +maxtime=5 diff --git a/testing/mozbase/mozprocess/tests/proclaunch.py b/testing/mozbase/mozprocess/tests/proclaunch.py new file mode 100644 index 0000000000..c57e2bb12c --- /dev/null +++ b/testing/mozbase/mozprocess/tests/proclaunch.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python + +import argparse +import collections +import multiprocessing +import time + +from six.moves import configparser + +ProcessNode = collections.namedtuple("ProcessNode", ["maxtime", "children"]) + + +class ProcessLauncher(object): + """Create and Launch process trees specified by a '.ini' file + + Typical .ini file accepted by this class : + + [main] + children=c1, 1*c2, 4*c3 + maxtime=10 + + [c1] + children= 2*c2, c3 + maxtime=20 + + [c2] + children=3*c3 + maxtime=5 + + [c3] + maxtime=3 + + This generates a process tree of the form: + [main] + |---[c1] + | |---[c2] + | | |---[c3] + | | |---[c3] + | | |---[c3] + | | + | |---[c2] + | | |---[c3] + | | |---[c3] + | | |---[c3] + | | + | |---[c3] + | + |---[c2] + | |---[c3] + | |---[c3] + | |---[c3] + | + |---[c3] + |---[c3] + |---[c3] + + Caveat: The section names cannot contain a '*'(asterisk) or a ','(comma) + character as these are used as delimiters for parsing. + """ + + # Unit time for processes in seconds + UNIT_TIME = 1 + + def __init__(self, manifest, verbose=False): + """ + Parses the manifest and stores the information about the process tree + in a format usable by the class. + + Raises IOError if : + - The path does not exist + - The file cannot be read + Raises ConfigParser.*Error if: + - Files does not contain section headers + - File cannot be parsed because of incorrect specification + + :param manifest: Path to the manifest file that contains the + configuration for the process tree to be launched + :verbose: Print the process start and end information. + Genrates a lot of output. Disabled by default. + """ + + self.verbose = verbose + + # Children is a dictionary used to store information from the, + # Configuration file in a more usable format. + # Key : string contain the name of child process + # Value : A Named tuple of the form (max_time, (list of child processes of Key)) + # Where each child process is a list of type: [count to run, name of child] + self.children = {} + + cfgparser = configparser.ConfigParser() + + if not cfgparser.read(manifest): + raise IOError("The manifest %s could not be found/opened", manifest) + + sections = cfgparser.sections() + for section in sections: + # Maxtime is a mandatory option + # ConfigParser.NoOptionError is raised if maxtime does not exist + if "*" in section or "," in section: + raise configparser.ParsingError( + "%s is not a valid section name. " + "Section names cannot contain a '*' or ','." % section + ) + m_time = cfgparser.get(section, "maxtime") + try: + m_time = int(m_time) + except ValueError: + raise ValueError( + "Expected maxtime to be an integer, specified %s" % m_time + ) + + # No children option implies there are no further children + # Leaving the children option blank is an error. + try: + c = cfgparser.get(section, "children") + if not c: + # If children is an empty field, assume no children + children = None + + else: + # Tokenize chilren field, ignore empty strings + children = [ + [y.strip() for y in x.strip().split("*", 1)] + for x in c.split(",") + if x + ] + try: + for i, child in enumerate(children): + # No multiplicate factor infront of a process implies 1 + if len(child) == 1: + children[i] = [1, child[0]] + else: + children[i][0] = int(child[0]) + + if children[i][1] not in sections: + raise configparser.ParsingError( + "No section corresponding to child %s" % child[1] + ) + except ValueError: + raise ValueError( + "Expected process count to be an integer, specified %s" + % child[0] + ) + + except configparser.NoOptionError: + children = None + pn = ProcessNode(maxtime=m_time, children=children) + self.children[section] = pn + + def run(self): + """ + This function launches the process tree. + """ + self._run("main", 0) + + def _run(self, proc_name, level): + """ + Runs the process specified by the section-name `proc_name` in the manifest file. + Then makes calls to launch the child processes of `proc_name` + + :param proc_name: File name of the manifest as a string. + :param level: Depth of the current process in the tree. + """ + if proc_name not in self.children: + raise IOError("%s is not a valid process" % proc_name) + + maxtime = self.children[proc_name].maxtime + if self.verbose: + print( + "%sLaunching %s for %d*%d seconds" + % (" " * level, proc_name, maxtime, self.UNIT_TIME) + ) + + while self.children[proc_name].children: + child = self.children[proc_name].children.pop() + + count, child_proc = child + for i in range(count): + p = multiprocessing.Process( + target=self._run, args=(child[1], level + 1) + ) + p.start() + + self._launch(maxtime) + if self.verbose: + print("%sFinished %s" % (" " * level, proc_name)) + + def _launch(self, running_time): + """ + Create and launch a process and idles for the time specified by + `running_time` + + :param running_time: Running time of the process in seconds. + """ + elapsed_time = 0 + + while elapsed_time < running_time: + time.sleep(self.UNIT_TIME) + elapsed_time += self.UNIT_TIME + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("manifest", help="Specify the configuration .ini file") + args = parser.parse_args() + + proclaunch = ProcessLauncher(args.manifest) + proclaunch.run() diff --git a/testing/mozbase/mozprocess/tests/proctest.py b/testing/mozbase/mozprocess/tests/proctest.py new file mode 100644 index 0000000000..d1e7138a1d --- /dev/null +++ b/testing/mozbase/mozprocess/tests/proctest.py @@ -0,0 +1,62 @@ +import os +import sys +import unittest + +from mozprocess import ProcessHandler + +here = os.path.dirname(os.path.abspath(__file__)) + + +class ProcTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.proclaunch = os.path.join(here, "proclaunch.py") + cls.python = sys.executable + + def determine_status(self, proc, isalive=False, expectedfail=()): + """ + Use to determine if the situation has failed. + Parameters: + proc -- the processhandler instance + isalive -- Use True to indicate we pass if the process exists; however, by default + the test will pass if the process does not exist (isalive == False) + expectedfail -- Defaults to [], used to indicate a list of fields + that are expected to fail + """ + returncode = proc.proc.returncode + didtimeout = proc.didTimeout + detected = ProcessHandler.pid_exists(proc.pid) + output = "" + # ProcessHandler has output when store_output is set to True in the constructor + # (this is the default) + if getattr(proc, "output"): + output = proc.output + + if "returncode" in expectedfail: + self.assertTrue( + returncode, "Detected an unexpected return code of: %s" % returncode + ) + elif isalive: + self.assertEqual( + returncode, None, "Detected not None return code of: %s" % returncode + ) + else: + self.assertNotEqual( + returncode, None, "Detected unexpected None return code of" + ) + + if "didtimeout" in expectedfail: + self.assertTrue(didtimeout, "Detected that process didn't time out") + else: + self.assertTrue(not didtimeout, "Detected that process timed out") + + if isalive: + self.assertTrue( + detected, + "Detected process is not running, " "process output: %s" % output, + ) + else: + self.assertTrue( + not detected, + "Detected process is still running, " "process output: %s" % output, + ) diff --git a/testing/mozbase/mozprocess/tests/scripts/ignore_sigterm.py b/testing/mozbase/mozprocess/tests/scripts/ignore_sigterm.py new file mode 100644 index 0000000000..15870d6267 --- /dev/null +++ b/testing/mozbase/mozprocess/tests/scripts/ignore_sigterm.py @@ -0,0 +1,13 @@ +import signal +import time + +signal.pthread_sigmask(signal.SIG_SETMASK, {signal.SIGTERM}) + + +def main(): + while True: + time.sleep(1) + + +if __name__ == "__main__": + main() diff --git a/testing/mozbase/mozprocess/tests/scripts/infinite_loop.py b/testing/mozbase/mozprocess/tests/scripts/infinite_loop.py new file mode 100644 index 0000000000..9af60b3811 --- /dev/null +++ b/testing/mozbase/mozprocess/tests/scripts/infinite_loop.py @@ -0,0 +1,18 @@ +import signal +import sys +import threading +import time + +if "deadlock" in sys.argv: + lock = threading.Lock() + + def trap(sig, frame): + lock.acquire() + + # get the lock once + lock.acquire() + # and take it again on SIGTERM signal: deadlock. + signal.signal(signal.SIGTERM, trap) + +while 1: + time.sleep(1) diff --git a/testing/mozbase/mozprocess/tests/scripts/proccountfive.py b/testing/mozbase/mozprocess/tests/scripts/proccountfive.py new file mode 100644 index 0000000000..39fabee508 --- /dev/null +++ b/testing/mozbase/mozprocess/tests/scripts/proccountfive.py @@ -0,0 +1,2 @@ +for i in range(0, 5): + print(i) diff --git a/testing/mozbase/mozprocess/tests/scripts/procnonewline.py b/testing/mozbase/mozprocess/tests/scripts/procnonewline.py new file mode 100644 index 0000000000..341a94be0a --- /dev/null +++ b/testing/mozbase/mozprocess/tests/scripts/procnonewline.py @@ -0,0 +1,4 @@ +import sys + +print("this is a newline") +sys.stdout.write("this has NO newline") diff --git a/testing/mozbase/mozprocess/tests/test_detached.py b/testing/mozbase/mozprocess/tests/test_detached.py new file mode 100644 index 0000000000..bc310fa3db --- /dev/null +++ b/testing/mozbase/mozprocess/tests/test_detached.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python + +import os + +import mozunit +import proctest +from mozprocess import processhandler + +here = os.path.dirname(os.path.abspath(__file__)) + + +class ProcTestDetached(proctest.ProcTest): + """Class to test for detached processes.""" + + def test_check_for_detached_before_run(self): + """Process is not started yet when checked for detached.""" + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_normal_finish.ini"], cwd=here + ) + + with self.assertRaises(RuntimeError): + p.check_for_detached(1234) + + def test_check_for_detached_while_running_with_current_pid(self): + """Process is started, and check for detached with original pid.""" + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_normal_finish.ini"], cwd=here + ) + p.run() + + orig_pid = p.pid + p.check_for_detached(p.pid) + + self.assertEqual(p.pid, orig_pid) + self.assertIsNone(p.proc.detached_pid) + + self.determine_status(p, True) + p.kill() + + def test_check_for_detached_after_fork(self): + """Process is started, and check for detached with new pid.""" + pass + + def test_check_for_detached_after_kill(self): + """Process is killed before checking for detached pid.""" + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_normal_finish.ini"], cwd=here + ) + p.run() + p.kill() + + orig_pid = p.pid + p.check_for_detached(p.pid) + + self.assertEqual(p.pid, orig_pid) + self.assertIsNone(p.proc.detached_pid) + + self.determine_status(p) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozprocess/tests/test_kill.py b/testing/mozbase/mozprocess/tests/test_kill.py new file mode 100644 index 0000000000..bba19f6fe8 --- /dev/null +++ b/testing/mozbase/mozprocess/tests/test_kill.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python + +import os +import signal +import sys +import time +import unittest + +import mozunit +import proctest +from mozprocess import processhandler + +here = os.path.dirname(os.path.abspath(__file__)) + + +class ProcTestKill(proctest.ProcTest): + """Class to test various process tree killing scenatios""" + + def test_kill_before_run(self): + """Process is not started, and kill() is called""" + + p = processhandler.ProcessHandler([self.python, "-V"]) + self.assertRaises(RuntimeError, p.kill) + + def test_process_kill(self): + """Process is started, we kill it""" + + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_normal_finish.ini"], cwd=here + ) + p.run() + p.kill() + + self.determine_status(p, expectedfail=("returncode",)) + + def test_process_kill_deep(self): + """Process is started, we kill it, we use a deep process tree""" + + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_normal_deep.ini"], cwd=here + ) + p.run() + p.kill() + + self.determine_status(p, expectedfail=("returncode",)) + + def test_process_kill_deep_wait(self): + """Process is started, we use a deep process tree, we let it spawn + for a bit, we kill it""" + + myenv = None + # On macosx1014, subprocess fails to find `six` when run with python3. + # This ensures that subprocess first looks to sys.path to find `six`. + # See https://bugzilla.mozilla.org/show_bug.cgi?id=1562083 + if sys.platform == "darwin" and sys.version_info[0] > 2: + myenv = os.environ.copy() + myenv["PYTHONPATH"] = ":".join(sys.path) + + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_normal_deep.ini"], + cwd=here, + env=myenv, + ) + p.run() + # Let the tree spawn a bit, before attempting to kill + time.sleep(3) + p.kill() + + self.determine_status(p, expectedfail=("returncode",)) + + def test_process_kill_broad(self): + """Process is started, we kill it, we use a broad process tree""" + + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_normal_broad.ini"], cwd=here + ) + p.run() + p.kill() + + self.determine_status(p, expectedfail=("returncode",)) + + def test_process_kill_broad_delayed(self): + """Process is started, we use a broad process tree, we let it spawn + for a bit, we kill it""" + + myenv = None + # On macosx1014, subprocess fails to find `six` when run with python3. + # This ensures that subprocess first looks to sys.path to find `six`. + # See https://bugzilla.mozilla.org/show_bug.cgi?id=1562083 + if sys.platform == "darwin" and sys.version_info[0] > 2: + myenv = os.environ.copy() + myenv["PYTHONPATH"] = ":".join(sys.path) + + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_normal_broad.ini"], + cwd=here, + env=myenv, + ) + p.run() + # Let the tree spawn a bit, before attempting to kill + time.sleep(3) + p.kill() + + self.determine_status(p, expectedfail=("returncode",)) + + @unittest.skipUnless(processhandler.isPosix, "posix only") + def test_process_kill_with_sigterm(self): + script = os.path.join(here, "scripts", "infinite_loop.py") + p = processhandler.ProcessHandler([self.python, script]) + + p.run() + p.kill() + + self.assertEqual(p.proc.returncode, -signal.SIGTERM) + + @unittest.skipUnless(processhandler.isPosix, "posix only") + def test_process_kill_with_sigint_if_needed(self): + script = os.path.join(here, "scripts", "infinite_loop.py") + p = processhandler.ProcessHandler([self.python, script, "deadlock"]) + + p.run() + time.sleep(1) + p.kill() + + self.assertEqual(p.proc.returncode, -signal.SIGKILL) + + @unittest.skipUnless(processhandler.isPosix, "posix only") + def test_process_kill_with_timeout(self): + script = os.path.join(here, "scripts", "ignore_sigterm.py") + p = processhandler.ProcessHandler([self.python, script]) + + p.run() + time.sleep(1) + t0 = time.time() + p.kill(sig=signal.SIGTERM, timeout=2) + self.assertEqual(p.proc.returncode, None) + self.assertGreaterEqual(time.time(), t0 + 2) + + p.kill(sig=signal.SIGKILL) + self.assertEqual(p.proc.returncode, -signal.SIGKILL) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozprocess/tests/test_misc.py b/testing/mozbase/mozprocess/tests/test_misc.py new file mode 100644 index 0000000000..a4908fe203 --- /dev/null +++ b/testing/mozbase/mozprocess/tests/test_misc.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import os +import sys + +import mozunit +import proctest +from mozprocess import processhandler + +here = os.path.dirname(os.path.abspath(__file__)) + + +class ProcTestMisc(proctest.ProcTest): + """Class to test misc operations""" + + def test_process_timeout_no_kill(self): + """Process is started, runs but we time out waiting on it + to complete. Process should not be killed. + """ + p = None + + def timeout_handler(): + self.assertEqual(p.proc.poll(), None) + p.kill() + + myenv = None + # On macosx1014, subprocess fails to find `six` when run with python3. + # This ensures that subprocess first looks to sys.path to find `six`. + # See https://bugzilla.mozilla.org/show_bug.cgi?id=1562083 + if sys.platform == "darwin" and sys.version_info[0] > 2: + myenv = os.environ.copy() + myenv["PYTHONPATH"] = ":".join(sys.path) + + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_waittimeout.ini"], + cwd=here, + env=myenv, + onTimeout=(timeout_handler,), + kill_on_timeout=False, + ) + p.run(timeout=1) + p.wait() + self.assertTrue(p.didTimeout) + + self.determine_status(p, False, ["returncode", "didtimeout"]) + + def test_unicode_in_environment(self): + env = { + "FOOBAR": "ʘ", + } + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_normal_finish.ini"], + cwd=here, + env=env, + ) + # passes if no exceptions are raised + p.run() + p.wait() + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozprocess/tests/test_output.py b/testing/mozbase/mozprocess/tests/test_output.py new file mode 100644 index 0000000000..fb1551eaf4 --- /dev/null +++ b/testing/mozbase/mozprocess/tests/test_output.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python + +import io +import os + +import mozunit +import proctest +from mozprocess import processhandler + +here = os.path.dirname(os.path.abspath(__file__)) + + +class ProcTestOutput(proctest.ProcTest): + """Class to test operations related to output handling""" + + def test_process_output_twice(self): + """ + Process is started, then processOutput is called a second time explicitly + """ + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_waittimeout_10s.ini"], cwd=here + ) + + p.run() + p.processOutput(timeout=5) + p.wait() + + self.determine_status(p, False, ()) + + def test_process_output_nonewline(self): + """ + Process is started, outputs data with no newline + """ + p = processhandler.ProcessHandler( + [self.python, os.path.join("scripts", "procnonewline.py")], cwd=here + ) + + p.run() + p.processOutput(timeout=5) + p.wait() + + self.determine_status(p, False, ()) + + def test_stream_process_output(self): + """ + Process output stream does not buffer + """ + expected = "\n".join([str(n) for n in range(0, 10)]) + + stream = io.BytesIO() + buf = io.BufferedRandom(stream) + + p = processhandler.ProcessHandler( + [self.python, os.path.join("scripts", "proccountfive.py")], + cwd=here, + stream=buf, + ) + + p.run() + p.wait() + for i in range(5, 10): + stream.write(str(i).encode("utf8") + "\n".encode("utf8")) + + buf.flush() + self.assertEqual(stream.getvalue().strip().decode("utf8"), expected) + + # make sure mozprocess doesn't close the stream + # since mozprocess didn't create it + self.assertFalse(buf.closed) + buf.close() + + self.determine_status(p, False, ()) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozprocess/tests/test_params.py b/testing/mozbase/mozprocess/tests/test_params.py new file mode 100644 index 0000000000..4a8e98affd --- /dev/null +++ b/testing/mozbase/mozprocess/tests/test_params.py @@ -0,0 +1,94 @@ +#!/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/. + +import unittest + +import mozunit +from mozprocess import processhandler + + +class ParamTests(unittest.TestCase): + def test_process_outputline_handler(self): + """Parameter processOutputLine is accepted with a single function""" + + def output(line): + print("output " + str(line)) + + err = None + try: + processhandler.ProcessHandler(["ls", "-l"], processOutputLine=output) + except (TypeError, AttributeError) as e: + err = e + self.assertFalse(err) + + def test_process_outputline_handler_list(self): + """Parameter processOutputLine is accepted with a list of functions""" + + def output(line): + print("output " + str(line)) + + err = None + try: + processhandler.ProcessHandler(["ls", "-l"], processOutputLine=[output]) + except (TypeError, AttributeError) as e: + err = e + self.assertFalse(err) + + def test_process_ontimeout_handler(self): + """Parameter onTimeout is accepted with a single function""" + + def timeout(): + print("timeout!") + + err = None + try: + processhandler.ProcessHandler(["sleep", "2"], onTimeout=timeout) + except (TypeError, AttributeError) as e: + err = e + self.assertFalse(err) + + def test_process_ontimeout_handler_list(self): + """Parameter onTimeout is accepted with a list of functions""" + + def timeout(): + print("timeout!") + + err = None + try: + processhandler.ProcessHandler(["sleep", "2"], onTimeout=[timeout]) + except (TypeError, AttributeError) as e: + err = e + self.assertFalse(err) + + def test_process_onfinish_handler(self): + """Parameter onFinish is accepted with a single function""" + + def finish(): + print("finished!") + + err = None + try: + processhandler.ProcessHandler(["sleep", "1"], onFinish=finish) + except (TypeError, AttributeError) as e: + err = e + self.assertFalse(err) + + def test_process_onfinish_handler_list(self): + """Parameter onFinish is accepted with a list of functions""" + + def finish(): + print("finished!") + + err = None + try: + processhandler.ProcessHandler(["sleep", "1"], onFinish=[finish]) + except (TypeError, AttributeError) as e: + err = e + self.assertFalse(err) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozprocess/tests/test_pid.py b/testing/mozbase/mozprocess/tests/test_pid.py new file mode 100644 index 0000000000..ddc352db13 --- /dev/null +++ b/testing/mozbase/mozprocess/tests/test_pid.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python + +import os + +import mozunit +import proctest +from mozprocess import processhandler + +here = os.path.dirname(os.path.abspath(__file__)) + + +class ProcTestPid(proctest.ProcTest): + """Class to test process pid.""" + + def test_pid_before_run(self): + """Process is not started, and pid is checked.""" + p = processhandler.ProcessHandler([self.python]) + with self.assertRaises(RuntimeError): + p.pid + + def test_pid_while_running(self): + """Process is started, and pid is checked.""" + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_normal_finish.ini"], cwd=here + ) + p.run() + + self.assertIsNotNone(p.pid) + + self.determine_status(p, True) + p.kill() + + def test_pid_after_kill(self): + """Process is killed, and pid is checked.""" + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_normal_finish.ini"], cwd=here + ) + p.run() + p.kill() + + self.assertIsNotNone(p.pid) + self.determine_status(p) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozprocess/tests/test_poll.py b/testing/mozbase/mozprocess/tests/test_poll.py new file mode 100644 index 0000000000..475c61576c --- /dev/null +++ b/testing/mozbase/mozprocess/tests/test_poll.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python + +import os +import signal +import sys +import time +import unittest + +import mozinfo +import mozunit +import proctest +from mozprocess import processhandler + +here = os.path.dirname(os.path.abspath(__file__)) + + +class ProcTestPoll(proctest.ProcTest): + """Class to test process poll.""" + + def test_poll_before_run(self): + """Process is not started, and poll() is called.""" + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_normal_finish.ini"], cwd=here + ) + self.assertRaises(RuntimeError, p.poll) + + def test_poll_while_running(self): + """Process is started, and poll() is called.""" + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_normal_finish.ini"], cwd=here + ) + p.run() + returncode = p.poll() + + self.assertEqual(returncode, None) + + self.determine_status(p, True) + p.kill() + + def test_poll_after_kill(self): + """Process is killed, and poll() is called.""" + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_normal_finish.ini"], cwd=here + ) + p.run() + returncode = p.kill() + + # We killed the process, so the returncode should be non-zero + if mozinfo.isWin: + self.assertGreater( + returncode, 0, 'Positive returncode expected, got "%s"' % returncode + ) + else: + self.assertLess( + returncode, 0, 'Negative returncode expected, got "%s"' % returncode + ) + + self.assertEqual(returncode, p.poll()) + + self.determine_status(p) + + def test_poll_after_kill_no_process_group(self): + """Process (no group) is killed, and poll() is called.""" + p = processhandler.ProcessHandler( + [ + self.python, + self.proclaunch, + "process_normal_finish_no_process_group.ini", + ], + cwd=here, + ignore_children=True, + ) + p.run() + returncode = p.kill() + + # We killed the process, so the returncode should be non-zero + if mozinfo.isWin: + self.assertGreater( + returncode, 0, 'Positive returncode expected, got "%s"' % returncode + ) + else: + self.assertLess( + returncode, 0, 'Negative returncode expected, got "%s"' % returncode + ) + + self.assertEqual(returncode, p.poll()) + + self.determine_status(p) + + def test_poll_after_double_kill(self): + """Process is killed twice, and poll() is called.""" + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_normal_finish.ini"], cwd=here + ) + p.run() + p.kill() + returncode = p.kill() + + # We killed the process, so the returncode should be non-zero + if mozinfo.isWin: + self.assertGreater( + returncode, 0, 'Positive returncode expected, got "%s"' % returncode + ) + else: + self.assertLess( + returncode, 0, 'Negative returncode expected, got "%s"' % returncode + ) + + self.assertEqual(returncode, p.poll()) + + self.determine_status(p) + + @unittest.skipIf(sys.platform.startswith("win"), "Bug 1493796") + def test_poll_after_external_kill(self): + """Process is killed externally, and poll() is called.""" + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_normal_finish.ini"], cwd=here + ) + p.run() + + os.kill(p.pid, signal.SIGTERM) + + # Allow the output reader thread to finish processing remaining data + for i in range(0, 100): + time.sleep(processhandler.INTERVAL_PROCESS_ALIVE_CHECK) + returncode = p.poll() + if returncode is not None: + break + + # We killed the process, so the returncode should be non-zero + if mozinfo.isWin: + self.assertEqual( + returncode, + signal.SIGTERM, + 'Positive returncode expected, got "%s"' % returncode, + ) + else: + self.assertEqual( + returncode, + -signal.SIGTERM, + '%s expected, got "%s"' % (-signal.SIGTERM, returncode), + ) + + self.assertEqual(returncode, p.wait()) + + self.determine_status(p) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozprocess/tests/test_process_reader.py b/testing/mozbase/mozprocess/tests/test_process_reader.py new file mode 100644 index 0000000000..36e2188ead --- /dev/null +++ b/testing/mozbase/mozprocess/tests/test_process_reader.py @@ -0,0 +1,114 @@ +import subprocess +import sys +import unittest + +import mozunit +from mozprocess.processhandler import ProcessReader, StoreOutput + + +def run_python(str_code, stdout=subprocess.PIPE, stderr=subprocess.PIPE): + cmd = [sys.executable, "-c", str_code] + return subprocess.Popen(cmd, stdout=stdout, stderr=stderr) + + +class TestProcessReader(unittest.TestCase): + def setUp(self): + self.out = StoreOutput() + self.err = StoreOutput() + self.finished = False + + def on_finished(): + self.finished = True + + self.timeout = False + + def on_timeout(): + self.timeout = True + + self.reader = ProcessReader( + stdout_callback=self.out, + stderr_callback=self.err, + finished_callback=on_finished, + timeout_callback=on_timeout, + ) + + def test_stdout_callback(self): + proc = run_python("print(1); print(2)") + self.reader.start(proc) + self.reader.thread.join() + + self.assertEqual([x.decode("utf8") for x in self.out.output], ["1", "2"]) + self.assertEqual(self.err.output, []) + + def test_stderr_callback(self): + proc = run_python('import sys; sys.stderr.write("hello world\\n")') + self.reader.start(proc) + self.reader.thread.join() + + self.assertEqual(self.out.output, []) + self.assertEqual([x.decode("utf8") for x in self.err.output], ["hello world"]) + + def test_stdout_and_stderr_callbacks(self): + proc = run_python( + 'import sys; sys.stderr.write("hello world\\n"); print(1); print(2)' + ) + self.reader.start(proc) + self.reader.thread.join() + + self.assertEqual([x.decode("utf8") for x in self.out.output], ["1", "2"]) + self.assertEqual([x.decode("utf8") for x in self.err.output], ["hello world"]) + + def test_finished_callback(self): + self.assertFalse(self.finished) + proc = run_python("") + self.reader.start(proc) + self.reader.thread.join() + self.assertTrue(self.finished) + + def test_timeout(self): + self.reader.timeout = 0.05 + self.assertFalse(self.timeout) + proc = run_python("import time; time.sleep(0.1)") + self.reader.start(proc) + self.reader.thread.join() + self.assertTrue(self.timeout) + self.assertFalse(self.finished) + + def test_output_timeout(self): + self.reader.output_timeout = 0.05 + self.assertFalse(self.timeout) + proc = run_python("import time; time.sleep(0.1)") + self.reader.start(proc) + self.reader.thread.join() + self.assertTrue(self.timeout) + self.assertFalse(self.finished) + + def test_read_without_eol(self): + proc = run_python('import sys; sys.stdout.write("1")') + self.reader.start(proc) + self.reader.thread.join() + self.assertEqual([x.decode("utf8") for x in self.out.output], ["1"]) + + def test_read_with_strange_eol(self): + proc = run_python('import sys; sys.stdout.write("1\\r\\r\\r\\n")') + self.reader.start(proc) + self.reader.thread.join() + self.assertEqual([x.decode("utf8") for x in self.out.output], ["1"]) + + def test_mixed_stdout_stderr(self): + proc = run_python( + 'import sys; sys.stderr.write("hello world\\n"); print(1); print(2)', + stderr=subprocess.STDOUT, + ) + self.reader.start(proc) + self.reader.thread.join() + + self.assertEqual( + sorted([x.decode("utf8") for x in self.out.output]), + sorted(["1", "2", "hello world"]), + ) + self.assertEqual(self.err.output, []) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozprocess/tests/test_run_and_wait.py b/testing/mozbase/mozprocess/tests/test_run_and_wait.py new file mode 100644 index 0000000000..7cda4d2274 --- /dev/null +++ b/testing/mozbase/mozprocess/tests/test_run_and_wait.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python + +import os +import signal + +import mozprocess +import mozunit +import proctest + +here = os.path.dirname(os.path.abspath(__file__)) + + +def kill(proc): + is_win = os.name == "nt" + if is_win: + proc.send_signal(signal.CTRL_BREAK_EVENT) + else: + os.killpg(proc.pid, signal.SIGKILL) + proc.wait() + + +class ProcTestSimpleRunAndWait(proctest.ProcTest): + """Class to test mozprocess.run_and_wait""" + + def test_normal_finish(self): + """Process is started, runs to completion while we wait for it""" + + p = mozprocess.run_and_wait( + [self.python, self.proclaunch, "process_normal_finish.ini"], cwd=here + ) + self.assertEqual(p.returncode, 0) + + def test_outputhandler(self): + """Output handler receives output generated by process""" + found = False + + def olh(p, line): + nonlocal found + self.assertEqual(line, "XYZ\n") + found = True + + p = mozprocess.run_and_wait( + [self.python, "-c", "print('XYZ')"], cwd=here, output_line_handler=olh + ) + self.assertTrue(found) + self.assertEqual(p.returncode, 0) + + def test_wait(self): + """Process is started runs to completion while we wait indefinitely""" + + p = mozprocess.run_and_wait( + [self.python, self.proclaunch, "process_waittimeout_10s.ini"], cwd=here + ) + self.assertEqual(p.returncode, 0) + + def test_timeout(self): + """Process is started, runs but we time out waiting on it + to complete + """ + timed_out = False + + def th(p): + nonlocal timed_out + timed_out = True + kill(p) + + mozprocess.run_and_wait( + [self.python, self.proclaunch, "process_waittimeout.ini"], + cwd=here, + timeout=10, + timeout_handler=th, + ) + self.assertTrue(timed_out) + + def test_waitnotimeout(self): + """Process is started, runs to completion before our wait times out""" + p = mozprocess.run_and_wait( + [self.python, self.proclaunch, "process_waittimeout_10s.ini"], + cwd=here, + timeout=30, + ) + self.assertEqual(p.returncode, 0) + + def test_outputtimeout(self): + """Process produces output, but output stalls and exceeds output timeout""" + + pgm = """ +import time + +for i in range(10): + print(i) + time.sleep(1) +time.sleep(10) +print("survived sleep!") + """ + found = False + found9 = False + timed_out = False + + def olh(p, line): + nonlocal found + nonlocal found9 + if "9" in line: + found9 = True + if "survived" in line: + found = True + + def oth(p): + nonlocal timed_out + timed_out = True + kill(p) + + mozprocess.run_and_wait( + [self.python, "-u", "-c", pgm], + cwd=here, + output_timeout=5, + output_timeout_handler=oth, + output_line_handler=olh, + ) + self.assertFalse(found) + self.assertTrue(found9) + self.assertTrue(timed_out) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozprocess/tests/test_wait.py b/testing/mozbase/mozprocess/tests/test_wait.py new file mode 100644 index 0000000000..20d1f0ca17 --- /dev/null +++ b/testing/mozbase/mozprocess/tests/test_wait.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python + +import os +import signal +import sys + +import mozinfo +import mozunit +import proctest +from mozprocess import processhandler + +here = os.path.dirname(os.path.abspath(__file__)) + + +class ProcTestWait(proctest.ProcTest): + """Class to test process waits and timeouts""" + + def test_normal_finish(self): + """Process is started, runs to completion while we wait for it""" + + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_normal_finish.ini"], cwd=here + ) + p.run() + p.wait() + + self.determine_status(p) + + def test_wait(self): + """Process is started runs to completion while we wait indefinitely""" + + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_waittimeout_10s.ini"], cwd=here + ) + p.run() + p.wait() + + self.determine_status(p) + + def test_timeout(self): + """Process is started, runs but we time out waiting on it + to complete + """ + myenv = None + # On macosx1014, subprocess fails to find `six` when run with python3. + # This ensures that subprocess first looks to sys.path to find `six`. + # See https://bugzilla.mozilla.org/show_bug.cgi?id=1562083 + if sys.platform == "darwin" and sys.version_info[0] > 2: + myenv = os.environ.copy() + myenv["PYTHONPATH"] = ":".join(sys.path) + + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_waittimeout.ini"], + cwd=here, + env=myenv, + ) + p.run(timeout=10) + p.wait() + + if mozinfo.isUnix: + # process was killed, so returncode should be negative + self.assertLess(p.proc.returncode, 0) + + self.determine_status(p, False, ["returncode", "didtimeout"]) + + def test_waittimeout(self): + """ + Process is started, then wait is called and times out. + Process is still running and didn't timeout + """ + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_waittimeout_10s.ini"], cwd=here + ) + + p.run() + p.wait(timeout=0) + + self.determine_status(p, True, ()) + + def test_waitnotimeout(self): + """Process is started, runs to completion before our wait times out""" + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_waittimeout_10s.ini"], cwd=here + ) + p.run(timeout=30) + p.wait() + + self.determine_status(p) + + def test_wait_twice_after_kill(self): + """Bug 968718: Process is started and stopped. wait() twice afterward.""" + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_waittimeout.ini"], cwd=here + ) + p.run() + p.kill() + returncode1 = p.wait() + returncode2 = p.wait() + + self.determine_status(p) + + # We killed the process, so the returncode should be non-zero + if mozinfo.isWin: + self.assertGreater( + returncode2, 0, 'Positive returncode expected, got "%s"' % returncode2 + ) + else: + self.assertLess( + returncode2, 0, 'Negative returncode expected, got "%s"' % returncode2 + ) + self.assertEqual( + returncode1, returncode2, "Expected both returncodes of wait() to be equal" + ) + + def test_wait_after_external_kill(self): + """Process is killed externally, and poll() is called.""" + p = processhandler.ProcessHandler( + [self.python, self.proclaunch, "process_normal_finish.ini"], cwd=here + ) + p.run() + os.kill(p.pid, signal.SIGTERM) + returncode = p.wait() + + # We killed the process, so the returncode should be non-zero + if mozinfo.isWin: + self.assertEqual( + returncode, + signal.SIGTERM, + 'Positive returncode expected, got "%s"' % returncode, + ) + else: + self.assertEqual( + returncode, + -signal.SIGTERM, + '%s expected, got "%s"' % (-signal.SIGTERM, returncode), + ) + + self.assertEqual(returncode, p.poll()) + + self.determine_status(p) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozprofile/mozprofile/__init__.py b/testing/mozbase/mozprofile/mozprofile/__init__.py new file mode 100644 index 0000000000..454514a7d1 --- /dev/null +++ b/testing/mozbase/mozprofile/mozprofile/__init__.py @@ -0,0 +1,20 @@ +# flake8: noqa +# 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/. + +""" +To use mozprofile as an API you can import mozprofile.profile_ and/or the AddonManager_. + +``mozprofile.profile`` features a generic ``Profile`` class. In addition, +subclasses ``FirefoxProfile`` and ``ThundebirdProfile`` are available +with preset preferences for those applications. +""" + +from mozprofile.addons import * +from mozprofile.cli import * +from mozprofile.diff import * +from mozprofile.permissions import * +from mozprofile.prefs import * +from mozprofile.profile import * +from mozprofile.view import * diff --git a/testing/mozbase/mozprofile/mozprofile/addons.py b/testing/mozbase/mozprofile/mozprofile/addons.py new file mode 100644 index 0000000000..e2450e61dc --- /dev/null +++ b/testing/mozbase/mozprofile/mozprofile/addons.py @@ -0,0 +1,354 @@ +# 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/. + +import binascii +import hashlib +import json +import os +import shutil +import sys +import tempfile +import zipfile +from xml.dom import minidom + +import mozfile +from mozlog.unstructured import getLogger +from six import reraise, string_types + +_SALT = binascii.hexlify(os.urandom(32)) +_TEMPORARY_ADDON_SUFFIX = "@temporary-addon" + +# Logger for 'mozprofile.addons' module +module_logger = getLogger(__name__) + + +class AddonFormatError(Exception): + """Exception for not well-formed add-on manifest files""" + + +class AddonManager(object): + """ + Handles all operations regarding addons in a profile including: + installing and cleaning addons + """ + + def __init__(self, profile, restore=True): + """ + :param profile: the path to the profile for which we install addons + :param restore: whether to reset to the previous state on instance garbage collection + """ + self.profile = profile + self.restore = restore + + # Initialize all class members + self._internal_init() + + def _internal_init(self): + """Internal: Initialize all class members to their default value""" + + # Add-ons installed; needed for cleanup + self._addons = [] + + # Backup folder for already existing addons + self.backup_dir = None + + # Information needed for profile reset (see http://bit.ly/17JesUf) + self.installed_addons = [] + + def __del__(self): + # reset to pre-instance state + if self.restore: + self.clean() + + def clean(self): + """Clean up addons in the profile.""" + + # Remove all add-ons installed + for addon in self._addons: + # TODO (bug 934642) + # Once we have a proper handling of add-ons we should kill the id + # from self._addons once the add-on is removed. For now lets forget + # about the exception + try: + self.remove_addon(addon) + except IOError: + pass + + # restore backups + if self.backup_dir and os.path.isdir(self.backup_dir): + extensions_path = os.path.join(self.profile, "extensions") + + for backup in os.listdir(self.backup_dir): + backup_path = os.path.join(self.backup_dir, backup) + shutil.move(backup_path, extensions_path) + + if not os.listdir(self.backup_dir): + mozfile.remove(self.backup_dir) + + # reset instance variables to defaults + self._internal_init() + + def get_addon_path(self, addon_id): + """Returns the path to the installed add-on + + :param addon_id: id of the add-on to retrieve the path from + """ + # By default we should expect add-ons being located under the + # extensions folder. + extensions_path = os.path.join(self.profile, "extensions") + paths = [ + os.path.join(extensions_path, addon_id), + os.path.join(extensions_path, addon_id + ".xpi"), + ] + for path in paths: + if os.path.exists(path): + return path + + raise IOError("Add-on not found: %s" % addon_id) + + @classmethod + def is_addon(self, addon_path): + """ + Checks if the given path is a valid addon + + :param addon_path: path to the add-on directory or XPI + """ + try: + self.addon_details(addon_path) + return True + except AddonFormatError: + return False + + def _install_addon(self, path, unpack=False): + addons = [path] + + # if path is not an add-on, try to install all contained add-ons + try: + self.addon_details(path) + except AddonFormatError as e: + module_logger.warning("Could not install %s: %s" % (path, str(e))) + + # If the path doesn't exist, then we don't really care, just return + if not os.path.isdir(path): + return + + addons = [ + os.path.join(path, x) + for x in os.listdir(path) + if self.is_addon(os.path.join(path, x)) + ] + addons.sort() + + # install each addon + for addon in addons: + # determine the addon id + addon_details = self.addon_details(addon) + addon_id = addon_details.get("id") + + # if the add-on has to be unpacked force it now + # note: we might want to let Firefox do it in case of addon details + orig_path = None + if os.path.isfile(addon) and (unpack or addon_details["unpack"]): + orig_path = addon + addon = tempfile.mkdtemp() + mozfile.extract(orig_path, addon) + + # copy the addon to the profile + extensions_path = os.path.join(self.profile, "extensions") + addon_path = os.path.join(extensions_path, addon_id) + + if os.path.isfile(addon): + addon_path += ".xpi" + + # move existing xpi file to backup location to restore later + if os.path.exists(addon_path): + self.backup_dir = self.backup_dir or tempfile.mkdtemp() + shutil.move(addon_path, self.backup_dir) + + # copy new add-on to the extension folder + if not os.path.exists(extensions_path): + os.makedirs(extensions_path) + shutil.copy(addon, addon_path) + else: + # move existing folder to backup location to restore later + if os.path.exists(addon_path): + self.backup_dir = self.backup_dir or tempfile.mkdtemp() + shutil.move(addon_path, self.backup_dir) + + # copy new add-on to the extension folder + shutil.copytree(addon, addon_path, symlinks=True) + + # if we had to extract the addon, remove the temporary directory + if orig_path: + mozfile.remove(addon) + addon = orig_path + + self._addons.append(addon_id) + self.installed_addons.append(addon) + + def install(self, addons, **kwargs): + """ + Installs addons from a filepath or directory of addons in the profile. + + :param addons: paths to .xpi or addon directories + :param unpack: whether to unpack unless specified otherwise in the install.rdf + """ + if not addons: + return + + # install addon paths + if isinstance(addons, string_types): + addons = [addons] + for addon in set(addons): + self._install_addon(addon, **kwargs) + + @classmethod + def _gen_iid(cls, addon_path): + hash = hashlib.sha1(_SALT) + hash.update(addon_path.encode()) + return hash.hexdigest() + _TEMPORARY_ADDON_SUFFIX + + @classmethod + def addon_details(cls, addon_path): + """ + Returns a dictionary of details about the addon. + + :param addon_path: path to the add-on directory or XPI + + Returns:: + + {'id': u'rainbow@colors.org', # id of the addon + 'version': u'1.4', # version of the addon + 'name': u'Rainbow', # name of the addon + 'unpack': False } # whether to unpack the addon + """ + + details = {"id": None, "unpack": False, "name": None, "version": None} + + def get_namespace_id(doc, url): + attributes = doc.documentElement.attributes + namespace = "" + for i in range(attributes.length): + if attributes.item(i).value == url: + if ":" in attributes.item(i).name: + # If the namespace is not the default one remove 'xlmns:' + namespace = attributes.item(i).name.split(":")[1] + ":" + break + return namespace + + def get_text(element): + """Retrieve the text value of a given node""" + rc = [] + for node in element.childNodes: + if node.nodeType == node.TEXT_NODE: + rc.append(node.data) + return "".join(rc).strip() + + if not os.path.exists(addon_path): + raise IOError("Add-on path does not exist: %s" % addon_path) + + is_webext = False + try: + if zipfile.is_zipfile(addon_path): + with zipfile.ZipFile(addon_path, "r") as compressed_file: + filenames = [f.filename for f in (compressed_file).filelist] + if "install.rdf" in filenames: + manifest = compressed_file.read("install.rdf") + elif "manifest.json" in filenames: + is_webext = True + manifest = compressed_file.read("manifest.json").decode() + manifest = json.loads(manifest) + else: + raise KeyError("No manifest") + elif os.path.isdir(addon_path): + entries = os.listdir(addon_path) + # Beginning with https://phabricator.services.mozilla.com/D126174 + # directories may exist that contain one single XPI. If that's + # the case we need to process it just as we do above. + if len(entries) == 1 and zipfile.is_zipfile( + os.path.join(addon_path, entries[0]) + ): + with zipfile.ZipFile( + os.path.join(addon_path, entries[0]), "r" + ) as compressed_file: + filenames = [f.filename for f in (compressed_file).filelist] + if "install.rdf" in filenames: + manifest = compressed_file.read("install.rdf") + elif "manifest.json" in filenames: + is_webext = True + manifest = compressed_file.read("manifest.json").decode() + manifest = json.loads(manifest) + else: + raise KeyError("No manifest") + # Otherwise, treat is an already unpacked XPI. + else: + try: + with open(os.path.join(addon_path, "install.rdf")) as f: + manifest = f.read() + except IOError: + with open(os.path.join(addon_path, "manifest.json")) as f: + manifest = json.loads(f.read()) + is_webext = True + else: + raise IOError( + "Add-on path is neither an XPI nor a directory: %s" % addon_path + ) + except (IOError, KeyError) as e: + reraise(AddonFormatError, AddonFormatError(str(e)), sys.exc_info()[2]) + + if is_webext: + details["version"] = manifest["version"] + details["name"] = manifest["name"] + # Bug 1572404 - we support two locations for gecko-specific + # metadata. + for location in ("applications", "browser_specific_settings"): + try: + details["id"] = manifest[location]["gecko"]["id"] + break + except KeyError: + pass + if details["id"] is None: + details["id"] = cls._gen_iid(addon_path) + details["unpack"] = False + else: + try: + doc = minidom.parseString(manifest) + + # Get the namespaces abbreviations + em = get_namespace_id(doc, "http://www.mozilla.org/2004/em-rdf#") + rdf = get_namespace_id( + doc, "http://www.w3.org/1999/02/22-rdf-syntax-ns#" + ) + + description = doc.getElementsByTagName(rdf + "Description").item(0) + for entry, value in description.attributes.items(): + # Remove the namespace prefix from the tag for comparison + entry = entry.replace(em, "") + if entry in details.keys(): + details.update({entry: value}) + for node in description.childNodes: + # Remove the namespace prefix from the tag for comparison + entry = node.nodeName.replace(em, "") + if entry in details.keys(): + details.update({entry: get_text(node)}) + except Exception as e: + reraise(AddonFormatError, AddonFormatError(str(e)), sys.exc_info()[2]) + + # turn unpack into a true/false value + if isinstance(details["unpack"], string_types): + details["unpack"] = details["unpack"].lower() == "true" + + # If no ID is set, the add-on is invalid + if details.get("id") is None and not is_webext: + raise AddonFormatError("Add-on id could not be found.") + + return details + + def remove_addon(self, addon_id): + """Remove the add-on as specified by the id + + :param addon_id: id of the add-on to be removed + """ + path = self.get_addon_path(addon_id) + mozfile.remove(path) diff --git a/testing/mozbase/mozprofile/mozprofile/cli.py b/testing/mozbase/mozprofile/mozprofile/cli.py new file mode 100755 index 0000000000..104f6bd08c --- /dev/null +++ b/testing/mozbase/mozprofile/mozprofile/cli.py @@ -0,0 +1,203 @@ +#!/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/. + +""" +Creates and/or modifies a Firefox profile. +The profile can be modified by passing in addons to install or preferences to set. +If no profile is specified, a new profile is created and the path of the +resulting profile is printed. +""" +import sys +from optparse import OptionParser + +from .prefs import Preferences +from .profile import FirefoxProfile, Profile + +__all__ = [ + "MozProfileCLI", + "cli", + "KeyValueParseError", + "parse_key_value", + "parse_preferences", +] + + +class KeyValueParseError(Exception): + """Error when parsing strings of serialized key-values.""" + + def __init__(self, msg, errors=()): + self.errors = errors + Exception.__init__(self, msg) + + +def parse_key_value(strings, separator="=", context="key, value"): + """Parse string-serialized key-value pairs in the form of `key = value`. + + Args: + strings (list): List of strings to parse. + separator (str): Identifier used to split the strings. + + Returns: + list: A list of (<key>, <value>) tuples. Whitespace is not stripped. + + Raises: + KeyValueParseError + """ + + # syntax check + missing = [string for string in strings if separator not in string] + if missing: + raise KeyValueParseError( + "Error: syntax error in %s: %s" % (context, ",".join(missing)), + errors=missing, + ) + return [string.split(separator, 1) for string in strings] + + +def parse_preferences(prefs, context="--setpref="): + """Parse preferences specified on the command line. + + Args: + prefs (list): A list of strings, usually of the form "<pref>=<value>". + + Returns: + dict: A dictionary of the form {<pref>: <value>} where values have been + cast. + """ + try: + prefs = dict(parse_key_value(prefs, context=context)) + except KeyValueParseError as e: + print(str(e)) + sys.exit(1) + + return {k: Preferences.cast(v) for k, v in prefs.items()} + + +class MozProfileCLI(object): + """The Command Line Interface for ``mozprofile``.""" + + module = "mozprofile" + profile_class = Profile + + def __init__(self, args=sys.argv[1:], add_options=None): + self.parser = OptionParser(description=__doc__) + self.add_options(self.parser) + if add_options: + add_options(self.parser) + (self.options, self.args) = self.parser.parse_args(args) + + def add_options(self, parser): + parser.add_option( + "-p", + "--profile", + dest="profile", + help="The path to the profile to operate on. " + "If none, creates a new profile in temp directory", + ) + parser.add_option( + "-a", + "--addon", + dest="addons", + action="append", + default=[], + help="Addon paths to install. Can be a filepath, " + "a directory containing addons, or a url", + ) + parser.add_option( + "--pref", + dest="prefs", + action="append", + default=[], + help="A preference to set. " "Must be a key-value pair separated by a ':'", + ) + parser.add_option( + "--preferences", + dest="prefs_files", + action="append", + default=[], + metavar="FILE", + help="read preferences from a JSON or INI file. " + "For INI, use 'file.ini:section' to specify a particular section.", + ) + + def profile_args(self): + """arguments to instantiate the profile class""" + return dict( + profile=self.options.profile, + addons=self.options.addons, + preferences=self.preferences(), + ) + + def preferences(self): + """profile preferences""" + + # object to hold preferences + prefs = Preferences() + + # add preferences files + for prefs_file in self.options.prefs_files: + prefs.add_file(prefs_file) + + # change CLI preferences into 2-tuples + cli_prefs = parse_key_value(self.options.prefs, separator=":") + + # string preferences + prefs.add(cli_prefs, cast=True) + + return prefs() + + def profile(self, restore=False): + """create the profile""" + + kwargs = self.profile_args() + kwargs["restore"] = restore + return self.profile_class(**kwargs) + + +def cli(args=sys.argv[1:]): + """Handles the command line arguments for ``mozprofile`` via ``sys.argv``""" + + # add a view method for this cli method only + def add_options(parser): + parser.add_option( + "--view", + dest="view", + action="store_true", + default=False, + help="view summary of profile following invocation", + ) + parser.add_option( + "--firefox", + dest="firefox_profile", + action="store_true", + default=False, + help="use FirefoxProfile defaults", + ) + + # process the command line + cli = MozProfileCLI(args, add_options) + + if cli.args: + cli.parser.error("Program doesn't support positional arguments.") + + if cli.options.firefox_profile: + cli.profile_class = FirefoxProfile + + # create the profile + profile = cli.profile() + + if cli.options.view: + # view the profile, if specified + print(profile.summary()) + return + + # if no profile was passed in print the newly created profile + if not cli.options.profile: + print(profile.profile) + + +if __name__ == "__main__": + cli() diff --git a/testing/mozbase/mozprofile/mozprofile/diff.py b/testing/mozbase/mozprofile/mozprofile/diff.py new file mode 100644 index 0000000000..192b59cd9e --- /dev/null +++ b/testing/mozbase/mozprofile/mozprofile/diff.py @@ -0,0 +1,84 @@ +#!/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/. + + +""" +diff two profile summaries +""" + +import difflib +import optparse +import os +import profile +import sys + +__all__ = ["diff", "diff_profiles"] + + +def diff(profile1, profile2, diff_function=difflib.unified_diff): + profiles = (profile1, profile2) + parts = {} + parts_dict = {} + for index in (0, 1): + prof = profiles[index] + + # first part, the path, isn't useful for diffing + parts[index] = prof.summary(return_parts=True)[1:] + + parts_dict[index] = dict(parts[index]) + + # keys the first profile is missing + first_missing = [i for i in parts_dict[1] if i not in parts_dict[0]] + parts[0].extend([(i, "") for i in first_missing]) + + # diffs + retval = [] + for key, value in parts[0]: + other = parts_dict[1].get(key, "") + value = value.strip() + other = other.strip() + + if key == "Files": + # first line of files is the path; we don't care to diff that + value = "\n".join(value.splitlines()[1:]) + if other: + other = "\n".join(other.splitlines()[1:]) + + value = value.splitlines() + other = other.splitlines() + section_diff = list( + diff_function(value, other, profile1.profile, profile2.profile) + ) + if section_diff: + retval.append((key, "\n".join(section_diff))) + + return retval + + +def diff_profiles(args=sys.argv[1:]): + # parse command line + usage = "%prog [options] profile1 profile2" + parser = optparse.OptionParser(usage=usage, description=__doc__) + options, args = parser.parse_args(args) + if len(args) != 2: + parser.error("Must give two profile paths") + missing = [arg for arg in args if not os.path.exists(arg)] + if missing: + parser.error("Profile not found: %s" % (", ".join(missing))) + + # get the profile differences + diffs = diff(*([profile.Profile(arg) for arg in args])) + + # display them + while diffs: + key, value = diffs.pop(0) + print("[%s]:\n" % key) + print(value) + if diffs: + print("-" * 4) + + +if __name__ == "__main__": + diff_profiles() diff --git a/testing/mozbase/mozprofile/mozprofile/permissions.py b/testing/mozbase/mozprofile/mozprofile/permissions.py new file mode 100644 index 0000000000..ffb4e5acdb --- /dev/null +++ b/testing/mozbase/mozprofile/mozprofile/permissions.py @@ -0,0 +1,335 @@ +# 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/. + + +""" +add permissions to the profile +""" + +import codecs +import os + +from six import string_types +from six.moves.urllib import parse + +__all__ = [ + "MissingPrimaryLocationError", + "MultiplePrimaryLocationsError", + "DEFAULT_PORTS", + "DuplicateLocationError", + "BadPortLocationError", + "LocationsSyntaxError", + "Location", + "ServerLocations", + "Permissions", +] + +# http://hg.mozilla.org/mozilla-central/file/b871dfb2186f/build/automation.py.in#l28 +DEFAULT_PORTS = {"http": "8888", "https": "4443", "ws": "4443", "wss": "4443"} + + +class LocationError(Exception): + """Signifies an improperly formed location.""" + + def __str__(self): + s = "Bad location" + m = str(Exception.__str__(self)) + if m: + s += ": %s" % m + return s + + +class MissingPrimaryLocationError(LocationError): + """No primary location defined in locations file.""" + + def __init__(self): + LocationError.__init__(self, "missing primary location") + + +class MultiplePrimaryLocationsError(LocationError): + """More than one primary location defined.""" + + def __init__(self): + LocationError.__init__(self, "multiple primary locations") + + +class DuplicateLocationError(LocationError): + """Same location defined twice.""" + + def __init__(self, url): + LocationError.__init__(self, "duplicate location: %s" % url) + + +class BadPortLocationError(LocationError): + """Location has invalid port value.""" + + def __init__(self, given_port): + LocationError.__init__(self, "bad value for port: %s" % given_port) + + +class LocationsSyntaxError(Exception): + """Signifies a syntax error on a particular line in server-locations.txt.""" + + def __init__(self, lineno, err=None): + self.err = err + self.lineno = lineno + + def __str__(self): + s = "Syntax error on line %s" % self.lineno + if self.err: + s += ": %s." % self.err + else: + s += "." + return s + + +class Location(object): + """Represents a location line in server-locations.txt.""" + + attrs = ("scheme", "host", "port") + + def __init__(self, scheme, host, port, options): + for attr in self.attrs: + setattr(self, attr, locals()[attr]) + self.options = options + try: + int(self.port) + except ValueError: + raise BadPortLocationError(self.port) + + def isEqual(self, location): + """compare scheme://host:port, but ignore options""" + return len( + [i for i in self.attrs if getattr(self, i) == getattr(location, i)] + ) == len(self.attrs) + + __eq__ = isEqual + + def __hash__(self): + # pylint --py3k: W1641 + return hash(tuple(getattr(attr) for attr in self.attrs)) + + def url(self): + return "%s://%s:%s" % (self.scheme, self.host, self.port) + + def __str__(self): + return "%s %s" % (self.url(), ",".join(self.options)) + + +class ServerLocations(object): + """Iterable collection of locations. + Use provided functions to add new locations, rather that manipulating + _locations directly, in order to check for errors and to ensure the + callback is called, if given. + """ + + def __init__(self, filename=None): + self._locations = [] + self.hasPrimary = False + if filename: + self.read(filename) + + def __iter__(self): + return self._locations.__iter__() + + def __len__(self): + return len(self._locations) + + def add(self, location): + if "primary" in location.options: + if self.hasPrimary: + raise MultiplePrimaryLocationsError() + self.hasPrimary = True + + self._locations.append(location) + + def add_host(self, host, port="80", scheme="http", options="privileged"): + if isinstance(options, string_types): + options = options.split(",") + self.add(Location(scheme, host, port, options)) + + def read(self, filename, check_for_primary=True): + """ + Reads the file and adds all valid locations to the ``self._locations`` array. + + :param filename: in the format of server-locations.txt_ + :param check_for_primary: if True, a ``MissingPrimaryLocationError`` exception is raised + if no primary is found + + .. _server-locations.txt: http://searchfox.org/mozilla-central/source/build/pgo/server-locations.txt # noqa + + The only exception is that the port, if not defined, defaults to 80 or 443. + + FIXME: Shouldn't this default to the protocol-appropriate port? Is + there any reason to have defaults at all? + """ + + locationFile = codecs.open(filename, "r", "UTF-8") + lineno = 0 + new_locations = [] + + for line in locationFile: + line = line.strip() + lineno += 1 + + # check for comments and blank lines + if line.startswith("#") or not line: + continue + + # split the server from the options + try: + server, options = line.rsplit(None, 1) + options = options.split(",") + except ValueError: + server = line + options = [] + + # parse the server url + if "://" not in server: + server = "http://" + server + scheme, netloc, path, query, fragment = parse.urlsplit(server) + # get the host and port + try: + host, port = netloc.rsplit(":", 1) + except ValueError: + host = netloc + port = DEFAULT_PORTS.get(scheme, "80") + + try: + location = Location(scheme, host, port, options) + self.add(location) + except LocationError as e: + raise LocationsSyntaxError(lineno, e) + + new_locations.append(location) + + # ensure that a primary is found + if check_for_primary and not self.hasPrimary: + raise LocationsSyntaxError(lineno + 1, MissingPrimaryLocationError()) + + +class Permissions(object): + """Allows handling of permissions for ``mozprofile``""" + + def __init__(self, locations=None): + self._locations = ServerLocations() + if locations: + if isinstance(locations, ServerLocations): + self._locations = locations + elif isinstance(locations, list): + for l in locations: + self._locations.add_host(**l) + elif isinstance(locations, dict): + self._locations.add_host(**locations) + elif os.path.exists(locations): + self._locations.read(locations) + + def network_prefs(self, proxy=None): + """ + take known locations and generate preferences to handle permissions and proxy + returns a tuple of prefs, user_prefs + """ + + prefs = [] + + if proxy: + dohServerPort = proxy.get("dohServerPort") + if dohServerPort is not None: + # make sure we don't use proxy + user_prefs = [("network.proxy.type", 0)] + # Use TRR_ONLY mode + user_prefs.append(("network.trr.mode", 3)) + trrUri = "https://foo.example.com:{}/dns-query".format(dohServerPort) + user_prefs.append(("network.trr.uri", trrUri)) + user_prefs.append(("network.trr.bootstrapAddr", "127.0.0.1")) + user_prefs.append(("network.dns.force_use_https_rr", True)) + else: + user_prefs = self.pac_prefs(proxy) + else: + user_prefs = [] + + return prefs, user_prefs + + def pac_prefs(self, user_proxy=None): + """ + return preferences for Proxy Auto Config. + """ + proxy = DEFAULT_PORTS.copy() + + # We need to proxy every server but the primary one. + origins = ["'%s'" % l.url() for l in self._locations] + origins = ", ".join(origins) + proxy["origins"] = origins + + for l in self._locations: + if "primary" in l.options: + proxy["remote"] = l.host + proxy[l.scheme] = l.port + + # overwrite defaults with user specified proxy + if isinstance(user_proxy, dict): + proxy.update(user_proxy) + + # TODO: this should live in a template! + # If you must escape things in this string with backslashes, be aware + # of the multiple layers of escaping at work: + # + # - Python will unescape backslashes; + # - Writing out the prefs will escape things via JSON serialization; + # - The prefs file reader will unescape backslashes; + # - The JS engine parser will unescape backslashes. + pacURL = ( + """data:text/plain, +var knownOrigins = (function () { + return [%(origins)s].reduce(function(t, h) { t[h] = true; return t; }, {}) +})(); +var uriRegex = new RegExp('^([a-z][-a-z0-9+.]*)' + + '://' + + '(?:[^/@]*@)?' + + '(.*?)' + + '(?::(\\\\d+))?/'); +var defaultPortsForScheme = { + 'http': 80, + 'ws': 80, + 'https': 443, + 'wss': 443 +}; +var originSchemesRemap = { + 'ws': 'http', + 'wss': 'https' +}; +var proxyForScheme = { + 'http': 'PROXY %(remote)s:%(http)s', + 'https': 'PROXY %(remote)s:%(https)s', + 'ws': 'PROXY %(remote)s:%(ws)s', + 'wss': 'PROXY %(remote)s:%(wss)s' +}; + +function FindProxyForURL(url, host) +{ + var matches = uriRegex.exec(url); + if (!matches) + return 'DIRECT'; + var originalScheme = matches[1]; + var host = matches[2]; + var port = matches[3]; + if (!port && originalScheme in defaultPortsForScheme) { + port = defaultPortsForScheme[originalScheme]; + } + var schemeForOriginChecking = originSchemesRemap[originalScheme] || originalScheme; + + var origin = schemeForOriginChecking + '://' + host + ':' + port; + if (!(origin in knownOrigins)) + return 'DIRECT'; + return proxyForScheme[originalScheme] || 'DIRECT'; +}""" + % proxy + ) + pacURL = "".join(pacURL.splitlines()) + + prefs = [] + prefs.append(("network.proxy.type", 2)) + prefs.append(("network.proxy.autoconfig_url", pacURL)) + + return prefs diff --git a/testing/mozbase/mozprofile/mozprofile/prefs.py b/testing/mozbase/mozprofile/mozprofile/prefs.py new file mode 100644 index 0000000000..b84d531f23 --- /dev/null +++ b/testing/mozbase/mozprofile/mozprofile/prefs.py @@ -0,0 +1,271 @@ +# 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/. + +""" +user preferences +""" +import json +import os +import tokenize + +import mozfile +import six +from six import StringIO, string_types + +try: + from six.moves.configparser import SafeConfigParser as ConfigParser +except ImportError: # SafeConfigParser was removed in 3.12 + from configparser import ConfigParser +try: + ConfigParser.read_file +except AttributeError: # read_file was added in 3.2, readfp removed in 3.12 + ConfigParser.read_file = ConfigParser.readfp + +if six.PY3: + + def unicode(input): + return input + + +__all__ = ("PreferencesReadError", "Preferences") + + +class PreferencesReadError(Exception): + """read error for preferences files""" + + +class Preferences(object): + """assembly of preferences from various sources""" + + def __init__(self, prefs=None): + self._prefs = [] + if prefs: + self.add(prefs) + + def add(self, prefs, cast=False): + """ + :param prefs: + :param cast: whether to cast strings to value, e.g. '1' -> 1 + """ + # wants a list of 2-tuples + if isinstance(prefs, dict): + prefs = prefs.items() + if cast: + prefs = [(i, self.cast(j)) for i, j in prefs] + self._prefs += prefs + + def add_file(self, path): + """a preferences from a file + + :param path: + """ + self.add(self.read(path)) + + def __call__(self): + return self._prefs + + @classmethod + def cast(cls, value): + """ + interpolate a preference from a string + from the command line or from e.g. an .ini file, there is no good way to denote + what type the preference value is, as natively it is a string + + - integers will get cast to integers + - true/false will get cast to True/False + - anything enclosed in single quotes will be treated as a string + with the ''s removed from both sides + """ + + if not isinstance(value, string_types): + return value # no op + quote = "'" + if value == "true": + return True + if value == "false": + return False + try: + return int(value) + except ValueError: + pass + if value.startswith(quote) and value.endswith(quote): + value = value[1:-1] + return value + + @classmethod + def read(cls, path): + """read preferences from a file""" + + section = None # for .ini files + basename = os.path.basename(path) + if ":" in basename: + # section of INI file + path, section = path.rsplit(":", 1) + + if not os.path.exists(path) and not mozfile.is_url(path): + raise PreferencesReadError("'%s' does not exist" % path) + + if section: + try: + return cls.read_ini(path, section) + except PreferencesReadError: + raise + except Exception as e: + raise PreferencesReadError(str(e)) + + # try both JSON and .ini format + try: + return cls.read_json(path) + except Exception as e: + try: + return cls.read_ini(path) + except Exception as f: + for exception in e, f: + if isinstance(exception, PreferencesReadError): + raise exception + raise PreferencesReadError("Could not recognize format of %s" % path) + + @classmethod + def read_ini(cls, path, section=None): + """read preferences from an .ini file""" + + parser = ConfigParser() + parser.optionxform = str + parser.read_file(mozfile.load(path)) + + if section: + if section not in parser.sections(): + raise PreferencesReadError("No section '%s' in %s" % (section, path)) + retval = parser.items(section, raw=True) + else: + retval = parser.defaults().items() + + # cast the preferences since .ini is just strings + return [(i, cls.cast(j)) for i, j in retval] + + @classmethod + def read_json(cls, path): + """read preferences from a JSON blob""" + + prefs = json.loads(mozfile.load(path).read()) + + if type(prefs) not in [list, dict]: + raise PreferencesReadError("Malformed preferences: %s" % path) + if isinstance(prefs, list): + if [i for i in prefs if type(i) != list or len(i) != 2]: + raise PreferencesReadError("Malformed preferences: %s" % path) + values = [i[1] for i in prefs] + elif isinstance(prefs, dict): + values = prefs.values() + else: + raise PreferencesReadError("Malformed preferences: %s" % path) + types = (bool, string_types, int) + if [i for i in values if not any([isinstance(i, j) for j in types])]: + raise PreferencesReadError("Only bool, string, and int values allowed") + return prefs + + @classmethod + def read_prefs(cls, path, pref_setter="user_pref", interpolation=None): + """ + Read preferences from (e.g.) prefs.js + + :param path: The path to the preference file to read. + :param pref_setter: The name of the function used to set preferences + in the preference file. + :param interpolation: If provided, a dict that will be passed + to str.format to interpolate preference values. + """ + + marker = "##//" # magical marker + lines = [i.strip() for i in mozfile.load(path).readlines()] + _lines = [] + multi_line_pref = None + for line in lines: + # decode bytes in case of URL processing + if isinstance(line, bytes): + line = line.decode() + pref_start = line.startswith(pref_setter) + + # Handle preferences split over multiple lines + # Some lines may include brackets so do our best to ensure this + # is an actual expected end of function call by checking for a + # semi-colon as well. + if pref_start and not ");" in line: + multi_line_pref = line + continue + elif multi_line_pref: + multi_line_pref = multi_line_pref + line + if ");" in line: + if "//" in multi_line_pref: + multi_line_pref = multi_line_pref.replace("//", marker) + _lines.append(multi_line_pref) + multi_line_pref = None + continue + elif not pref_start: + continue + + if "//" in line: + line = line.replace("//", marker) + _lines.append(line) + string = "\n".join(_lines) + + # skip trailing comments + processed_tokens = [] + f_obj = StringIO(string) + for token in tokenize.generate_tokens(f_obj.readline): + if token[0] == tokenize.COMMENT: + continue + processed_tokens.append( + token[:2] + ) # [:2] gets around http://bugs.python.org/issue9974 + string = tokenize.untokenize(processed_tokens) + + retval = [] + + def pref(a, b): + if interpolation and isinstance(b, string_types): + b = b.format(**interpolation) + retval.append((a, b)) + + lines = [i.strip().rstrip(";") for i in string.split("\n") if i.strip()] + + _globals = {"retval": retval, "true": True, "false": False} + _globals[pref_setter] = pref + for line in lines: + try: + eval(line, _globals, {}) + except SyntaxError: + print(line) + raise + + # de-magic the marker + for index, (key, value) in enumerate(retval): + if isinstance(value, string_types) and marker in value: + retval[index] = (key, value.replace(marker, "//")) + + return retval + + @classmethod + def write(cls, _file, prefs, pref_string="user_pref(%s, %s);"): + """write preferences to a file""" + + if isinstance(_file, string_types): + f = open(_file, "a") + else: + f = _file + + if isinstance(prefs, dict): + # order doesn't matter + prefs = prefs.items() + + # serialize -> JSON + _prefs = [(json.dumps(k), json.dumps(v)) for k, v in prefs] + + # write the preferences + for _pref in _prefs: + print(unicode(pref_string % _pref), file=f) + + # close the file if opened internally + if isinstance(_file, string_types): + f.close() diff --git a/testing/mozbase/mozprofile/mozprofile/profile.py b/testing/mozbase/mozprofile/mozprofile/profile.py new file mode 100644 index 0000000000..25e10d23bf --- /dev/null +++ b/testing/mozbase/mozprofile/mozprofile/profile.py @@ -0,0 +1,594 @@ +# 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/. + +import json +import os +import platform +import tempfile +import time +import uuid +from abc import ABCMeta, abstractmethod, abstractproperty +from io import open +from shutil import copytree + +import mozfile +import six +from six import python_2_unicode_compatible, string_types + +if six.PY3: + + def unicode(input): + return input + + +from .addons import AddonManager +from .permissions import Permissions +from .prefs import Preferences + +__all__ = [ + "BaseProfile", + "ChromeProfile", + "ChromiumProfile", + "Profile", + "FirefoxProfile", + "ThunderbirdProfile", + "create_profile", +] + + +@six.add_metaclass(ABCMeta) +class BaseProfile(object): + def __init__(self, profile=None, addons=None, preferences=None, restore=True): + """Create a new Profile. + + All arguments are optional. + + :param profile: Path to a profile. If not specified, a new profile + directory will be created. + :param addons: List of paths to addons which should be installed in the profile. + :param preferences: Dict of preferences to set in the profile. + :param restore: Whether or not to clean up any modifications made to this profile + (default True). + """ + self._addons = addons or [] + + # Prepare additional preferences + if preferences: + if isinstance(preferences, dict): + # unordered + preferences = preferences.items() + + # sanity check + assert not [i for i in preferences if len(i) != 2] + else: + preferences = [] + self._preferences = preferences + + # Handle profile creation + self.restore = restore + self.create_new = not profile + if profile: + # Ensure we have a full path to the profile + self.profile = os.path.abspath(os.path.expanduser(profile)) + else: + self.profile = tempfile.mkdtemp(suffix=".mozrunner") + + def __enter__(self): + return self + + def __exit__(self, type, value, traceback): + self.cleanup() + + def __del__(self): + self.cleanup() + + def cleanup(self): + """Cleanup operations for the profile.""" + + if self.restore: + # If it's a temporary profile we have to remove it + if self.create_new: + mozfile.remove(self.profile) + + @abstractmethod + def _reset(self): + pass + + def reset(self): + """ + reset the profile to the beginning state + """ + self.cleanup() + self._reset() + + @abstractmethod + def set_preferences(self, preferences, filename="user.js"): + pass + + @abstractproperty + def preference_file_names(self): + """A tuple of file basenames expected to contain preferences.""" + + def merge(self, other, interpolation=None): + """Merges another profile into this one. + + This will handle pref files matching the profile's + `preference_file_names` property, and any addons in the + other/extensions directory. + """ + for basename in os.listdir(other): + if basename not in self.preference_file_names: + continue + + path = os.path.join(other, basename) + try: + prefs = Preferences.read_json(path) + except ValueError: + prefs = Preferences.read_prefs(path, interpolation=interpolation) + self.set_preferences(prefs, filename=basename) + + extension_dir = os.path.join(other, "extensions") + if not os.path.isdir(extension_dir): + return + + for basename in os.listdir(extension_dir): + path = os.path.join(extension_dir, basename) + + if self.addons.is_addon(path): + self._addons.append(path) + self.addons.install(path) + + @classmethod + def clone(cls, path_from, path_to=None, ignore=None, **kwargs): + """Instantiate a temporary profile via cloning + - path: path of the basis to clone + - ignore: callable passed to shutil.copytree + - kwargs: arguments to the profile constructor + """ + if not path_to: + tempdir = tempfile.mkdtemp() # need an unused temp dir name + mozfile.remove(tempdir) # copytree requires that dest does not exist + path_to = tempdir + copytree(path_from, path_to, ignore=ignore, ignore_dangling_symlinks=True) + + c = cls(path_to, **kwargs) + c.create_new = True # deletes a cloned profile when restore is True + return c + + def exists(self): + """returns whether the profile exists or not""" + return os.path.exists(self.profile) + + +@python_2_unicode_compatible +class Profile(BaseProfile): + """Handles all operations regarding profile. + + Creating new profiles, installing add-ons, setting preferences and + handling cleanup. + + The files associated with the profile will be removed automatically after + the object is garbage collected: :: + + profile = Profile() + print profile.profile # this is the path to the created profile + del profile + # the profile path has been removed from disk + + :meth:`cleanup` is called under the hood to remove the profile files. You + can ensure this method is called (even in the case of exception) by using + the profile as a context manager: :: + + with Profile() as profile: + # do things with the profile + pass + # profile.cleanup() has been called here + """ + + preference_file_names = ("user.js", "prefs.js") + + def __init__( + self, + profile=None, + addons=None, + preferences=None, + locations=None, + proxy=None, + restore=True, + whitelistpaths=None, + **kwargs + ): + """ + :param profile: Path to the profile + :param addons: String of one or list of addons to install + :param preferences: Dictionary or class of preferences + :param locations: ServerLocations object + :param proxy: Setup a proxy + :param restore: Flag for removing all custom settings during cleanup + :param whitelistpaths: List of paths to pass to Firefox to allow read + access to from the content process sandbox. + """ + super(Profile, self).__init__( + profile=profile, + addons=addons, + preferences=preferences, + restore=restore, + **kwargs + ) + + self._locations = locations + self._proxy = proxy + self._whitelistpaths = whitelistpaths + + # Initialize all class members + self._reset() + + def _reset(self): + """Internal: Initialize all class members to their default value""" + + if not os.path.exists(self.profile): + os.makedirs(self.profile) + + # Preferences files written to + self.written_prefs = set() + + # Our magic markers + nonce = "%s %s" % (str(time.time()), uuid.uuid4()) + self.delimeters = ( + "#MozRunner Prefs Start %s" % nonce, + "#MozRunner Prefs End %s" % nonce, + ) + + # If sub-classes want to set default preferences + if hasattr(self.__class__, "preferences"): + self.set_preferences(self.__class__.preferences) + # Set additional preferences + self.set_preferences(self._preferences) + + self.permissions = Permissions(self._locations) + prefs_js, user_js = self.permissions.network_prefs(self._proxy) + + if self._whitelistpaths: + # On macOS we don't want to support a generalized read whitelist, + # and the macOS sandbox policy language doesn't have support for + # lists, so we handle these specially. + if platform.system() == "Darwin": + assert len(self._whitelistpaths) <= 2 + if len(self._whitelistpaths) == 2: + prefs_js.append( + ( + "security.sandbox.content.mac.testing_read_path2", + self._whitelistpaths[1], + ) + ) + prefs_js.append( + ( + "security.sandbox.content.mac.testing_read_path1", + self._whitelistpaths[0], + ) + ) + else: + prefs_js.append( + ( + "security.sandbox.content.read_path_whitelist", + ",".join(self._whitelistpaths), + ) + ) + self.set_preferences(prefs_js, "prefs.js") + self.set_preferences(user_js) + + # handle add-on installation + self.addons = AddonManager(self.profile, restore=self.restore) + self.addons.install(self._addons) + + def cleanup(self): + """Cleanup operations for the profile.""" + + if self.restore: + # If copies of those class instances exist ensure we correctly + # reset them all (see bug 934484) + self.clean_preferences() + if getattr(self, "addons", None) is not None: + self.addons.clean() + super(Profile, self).cleanup() + + def clean_preferences(self): + """Removed preferences added by mozrunner.""" + for filename in self.written_prefs: + if not os.path.exists(os.path.join(self.profile, filename)): + # file has been deleted + break + while True: + if not self.pop_preferences(filename): + break + + # methods for preferences + + def set_preferences(self, preferences, filename="user.js"): + """Adds preferences dict to profile preferences""" + prefs_file = os.path.join(self.profile, filename) + with open(prefs_file, "a") as f: + if not preferences: + return + + # note what files we've touched + self.written_prefs.add(filename) + + # opening delimeter + f.write(unicode("\n%s\n" % self.delimeters[0])) + + Preferences.write(f, preferences) + + # closing delimeter + f.write(unicode("%s\n" % self.delimeters[1])) + + def set_persistent_preferences(self, preferences): + """ + Adds preferences dict to profile preferences and save them during a + profile reset + """ + + # this is a dict sometimes, convert + if isinstance(preferences, dict): + preferences = preferences.items() + + # add new prefs to preserve them during reset + for new_pref in preferences: + # if dupe remove item from original list + self._preferences = [ + pref for pref in self._preferences if not new_pref[0] == pref[0] + ] + self._preferences.append(new_pref) + + self.set_preferences(preferences, filename="user.js") + + def pop_preferences(self, filename): + """ + pop the last set of preferences added + returns True if popped + """ + + path = os.path.join(self.profile, filename) + with open(path, "r", encoding="utf-8") as f: + lines = f.read().splitlines() + + def last_index(_list, value): + """ + returns the last index of an item; + this should actually be part of python code but it isn't + """ + for index in reversed(range(len(_list))): + if _list[index] == value: + return index + + s = last_index(lines, self.delimeters[0]) + e = last_index(lines, self.delimeters[1]) + + # ensure both markers are found + if s is None: + assert e is None, "%s found without %s" % ( + self.delimeters[1], + self.delimeters[0], + ) + return False # no preferences found + elif e is None: + assert s is None, "%s found without %s" % ( + self.delimeters[0], + self.delimeters[1], + ) + + # ensure the markers are in the proper order + assert e > s, "%s found at %s, while %s found at %s" % ( + self.delimeters[1], + e, + self.delimeters[0], + s, + ) + + # write the prefs + cleaned_prefs = "\n".join(lines[:s] + lines[e + 1 :]) + with open(path, "w") as f: + f.write(cleaned_prefs) + return True + + # methods for introspection + + def summary(self, return_parts=False): + """ + returns string summarizing profile information. + if return_parts is true, return the (Part_name, value) list + of tuples instead of the assembled string + """ + + parts = [("Path", self.profile)] # profile path + + # directory tree + parts.append(("Files", "\n%s" % mozfile.tree(self.profile))) + + # preferences + for prefs_file in ("user.js", "prefs.js"): + path = os.path.join(self.profile, prefs_file) + if os.path.exists(path): + # prefs that get their own section + # This is currently only 'network.proxy.autoconfig_url' + # but could be expanded to include others + section_prefs = ["network.proxy.autoconfig_url"] + line_length = 80 + # buffer for 80 character display: + # length = 80 - len(key) - len(': ') - line_length_buffer + line_length_buffer = 10 + line_length_buffer += len(": ") + + def format_value(key, value): + if key not in section_prefs: + return value + max_length = line_length - len(key) - line_length_buffer + if len(value) > max_length: + value = "%s..." % value[:max_length] + return value + + prefs = Preferences.read_prefs(path) + if prefs: + prefs = dict(prefs) + parts.append( + ( + prefs_file, + "\n%s" + % ( + "\n".join( + [ + "%s: %s" % (key, format_value(key, prefs[key])) + for key in sorted(prefs.keys()) + ] + ) + ), + ) + ) + + # Currently hardcorded to 'network.proxy.autoconfig_url' + # but could be generalized, possibly with a generalized (simple) + # JS-parser + network_proxy_autoconfig = prefs.get("network.proxy.autoconfig_url") + if network_proxy_autoconfig and network_proxy_autoconfig.strip(): + network_proxy_autoconfig = network_proxy_autoconfig.strip() + lines = network_proxy_autoconfig.replace( + ";", ";\n" + ).splitlines() + lines = [line.strip() for line in lines] + origins_string = "var origins = [" + origins_end = "];" + if origins_string in lines[0]: + start = lines[0].find(origins_string) + end = lines[0].find(origins_end, start) + splitline = [ + lines[0][:start], + lines[0][start : start + len(origins_string) - 1], + ] + splitline.extend( + lines[0][start + len(origins_string) : end] + .replace(",", ",\n") + .splitlines() + ) + splitline.append(lines[0][end:]) + lines[0:1] = [i.strip() for i in splitline] + parts.append( + ( + "Network Proxy Autoconfig, %s" % (prefs_file), + "\n%s" % "\n".join(lines), + ) + ) + + if return_parts: + return parts + + retval = "%s\n" % ( + "\n\n".join(["[%s]: %s" % (key, value) for key, value in parts]) + ) + return retval + + def __str__(self): + return self.summary() + + +class FirefoxProfile(Profile): + """Specialized Profile subclass for Firefox""" + + preferences = {} + + +class ThunderbirdProfile(Profile): + """Specialized Profile subclass for Thunderbird""" + + preferences = { + "extensions.update.enabled": False, + "extensions.update.notifyUser": False, + "browser.shell.checkDefaultBrowser": False, + "browser.tabs.warnOnClose": False, + "browser.warnOnQuit": False, + "browser.sessionstore.resume_from_crash": False, + # prevents the 'new e-mail address' wizard on new profile + "mail.provider.enabled": False, + } + + +class ChromiumProfile(BaseProfile): + preference_file_names = ("Preferences",) + + class AddonManager(list): + def install(self, addons): + if isinstance(addons, string_types): + addons = [addons] + self.extend(addons) + + @classmethod + def is_addon(self, addon): + # Don't include testing/profiles on Google Chrome + return False + + def __init__(self, **kwargs): + super(ChromiumProfile, self).__init__(**kwargs) + + if self.create_new: + self.profile = os.path.join(self.profile, "Default") + self._reset() + + def _reset(self): + if not os.path.isdir(self.profile): + os.makedirs(self.profile) + + if self._preferences: + self.set_preferences(self._preferences) + + self.addons = self.AddonManager() + if self._addons: + self.addons.install(self._addons) + + def set_preferences(self, preferences, filename="Preferences", **values): + pref_file = os.path.join(self.profile, filename) + + prefs = {} + if os.path.isfile(pref_file): + with open(pref_file, "r") as fh: + prefs.update(json.load(fh)) + + prefs.update(preferences) + with open(pref_file, "w") as fh: + prefstr = json.dumps(prefs) + prefstr % values # interpolate prefs with values + if six.PY2: + fh.write(unicode(prefstr)) + else: + fh.write(prefstr) + + +class ChromeProfile(ChromiumProfile): + # update this if Google Chrome requires more + # specific profiles + pass + + +profile_class = { + "chrome": ChromeProfile, + "chromium": ChromiumProfile, + "firefox": FirefoxProfile, + "thunderbird": ThunderbirdProfile, +} + + +def create_profile(app, **kwargs): + """Create a profile given an application name. + + :param app: String name of the application to create a profile for, e.g 'firefox'. + :param kwargs: Same as the arguments for the Profile class (optional). + :returns: An application specific Profile instance + :raises: NotImplementedError + """ + cls = profile_class.get(app) + + if not cls: + raise NotImplementedError( + "Profiles not supported for application '{}'".format(app) + ) + + return cls(**kwargs) diff --git a/testing/mozbase/mozprofile/mozprofile/view.py b/testing/mozbase/mozprofile/mozprofile/view.py new file mode 100644 index 0000000000..455751148b --- /dev/null +++ b/testing/mozbase/mozprofile/mozprofile/view.py @@ -0,0 +1,46 @@ +#!/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/. + + +""" +script to view mozilla profiles +""" +import optparse +import os +import sys + +import mozprofile + +__all__ = ["view_profile"] + + +def view_profile(args=sys.argv[1:]): + usage = "%prog [options] profile_path <...>" + parser = optparse.OptionParser(usage=usage, description=__doc__) + options, args = parser.parse_args(args) + if not args: + parser.print_usage() + parser.exit() + + # check existence + missing = [i for i in args if not os.path.exists(i)] + if missing: + if len(missing) > 1: + missing_string = "Profiles do not exist" + else: + missing_string = "Profile does not exist" + parser.error("%s: %s" % (missing_string, ", ".join(missing))) + + # print summary for each profile + while args: + path = args.pop(0) + profile = mozprofile.Profile(path) + print(profile.summary()) + if args: + print("-" * 4) + + +if __name__ == "__main__": + view_profile() diff --git a/testing/mozbase/mozprofile/setup.cfg b/testing/mozbase/mozprofile/setup.cfg new file mode 100644 index 0000000000..3c6e79cf31 --- /dev/null +++ b/testing/mozbase/mozprofile/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 diff --git a/testing/mozbase/mozprofile/setup.py b/testing/mozbase/mozprofile/setup.py new file mode 100644 index 0000000000..8344bd83ae --- /dev/null +++ b/testing/mozbase/mozprofile/setup.py @@ -0,0 +1,49 @@ +# 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 setuptools import setup + +PACKAGE_NAME = "mozprofile" +PACKAGE_VERSION = "2.6.1" + +deps = [ + "mozfile>=1.2", + "mozlog>=6.0", + "six>=1.13.0,<2", +] + +setup( + name=PACKAGE_NAME, + version=PACKAGE_VERSION, + description="Library to create and modify Mozilla application profiles", + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + classifiers=[ + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.5", + "Topic :: Software Development :: Libraries :: Python Modules", + ], + keywords="mozilla", + author="Mozilla Automation and Tools team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + license="MPL 2.0", + packages=["mozprofile"], + include_package_data=True, + zip_safe=False, + install_requires=deps, + extras_require={"manifest": ["manifestparser >= 0.6"]}, + tests_require=["wptserve"], + entry_points=""" + # -*- Entry points: -*- + [console_scripts] + mozprofile = mozprofile:cli + view-profile = mozprofile:view_profile + diff-profiles = mozprofile:diff_profiles + """, +) diff --git a/testing/mozbase/mozprofile/tests/addon_stubs.py b/testing/mozbase/mozprofile/tests/addon_stubs.py new file mode 100644 index 0000000000..37c6305c6a --- /dev/null +++ b/testing/mozbase/mozprofile/tests/addon_stubs.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python + +import os +import tempfile +import zipfile + +import mozfile + +here = os.path.dirname(os.path.abspath(__file__)) + +# stubs is a dict of the form {'addon id': 'install manifest content'} +stubs = { + "test-addon-1@mozilla.org": "test_addon_1.rdf", + "test-addon-2@mozilla.org": "test_addon_2.rdf", + "test-addon-3@mozilla.org": "test_addon_3.rdf", + "test-addon-4@mozilla.org": "test_addon_4.rdf", + "test-addon-invalid-no-id@mozilla.org": "test_addon_invalid_no_id.rdf", + "test-addon-invalid-version@mozilla.org": "test_addon_invalid_version.rdf", + "test-addon-invalid-no-manifest@mozilla.org": None, + "test-addon-invalid-not-wellformed@mozilla.org": "test_addon_invalid_not_wellformed.rdf", + "test-addon-unpack@mozilla.org": "test_addon_unpack.rdf", +} + + +def generate_addon(addon_id, path=None, name=None, xpi=True): + """ + Method to generate a single addon. + + :param addon_id: id of an addon to generate from the stubs dictionary + :param path: path where addon and .xpi should be generated + :param name: name for the addon folder or .xpi file + :param xpi: Flag if an XPI or folder should be generated + + Returns the file-path of the addon's .xpi file + """ + + if addon_id not in stubs: + raise IOError('Requested addon stub "%s" does not exist' % addon_id) + + # Generate directory structure for addon + try: + tmpdir = path or tempfile.mkdtemp() + addon_dir = os.path.join(tmpdir, name or addon_id) + os.mkdir(addon_dir) + except IOError: + raise IOError("Could not generate directory structure for addon stub.") + + # Write install.rdf for addon + if stubs[addon_id]: + install_rdf = os.path.join(addon_dir, "install.rdf") + with open(install_rdf, "w") as f: + manifest = os.path.join(here, "install_manifests", stubs[addon_id]) + f.write(open(manifest, "r").read()) + + if not xpi: + return addon_dir + + # Generate the .xpi for the addon + xpi_file = os.path.join(tmpdir, (name or addon_id) + ".xpi") + with zipfile.ZipFile(xpi_file, "w") as x: + x.write(install_rdf, install_rdf[len(addon_dir) :]) + + # Ensure we remove the temporary folder to not install the addon twice + mozfile.rmtree(addon_dir) + + return xpi_file diff --git a/testing/mozbase/mozprofile/tests/addons/apply-css-id-via-browser-specific-settings.xpi b/testing/mozbase/mozprofile/tests/addons/apply-css-id-via-browser-specific-settings.xpi Binary files differnew file mode 100644 index 0000000000..c9ad38f63b --- /dev/null +++ b/testing/mozbase/mozprofile/tests/addons/apply-css-id-via-browser-specific-settings.xpi diff --git a/testing/mozbase/mozprofile/tests/addons/apply-css-sans-id.xpi b/testing/mozbase/mozprofile/tests/addons/apply-css-sans-id.xpi Binary files differnew file mode 100644 index 0000000000..fa721a4f76 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/addons/apply-css-sans-id.xpi diff --git a/testing/mozbase/mozprofile/tests/addons/apply-css.xpi b/testing/mozbase/mozprofile/tests/addons/apply-css.xpi Binary files differnew file mode 100644 index 0000000000..0ed64f79ac --- /dev/null +++ b/testing/mozbase/mozprofile/tests/addons/apply-css.xpi diff --git a/testing/mozbase/mozprofile/tests/addons/empty.xpi b/testing/mozbase/mozprofile/tests/addons/empty.xpi Binary files differnew file mode 100644 index 0000000000..26f28f099d --- /dev/null +++ b/testing/mozbase/mozprofile/tests/addons/empty.xpi diff --git a/testing/mozbase/mozprofile/tests/addons/empty/install.rdf b/testing/mozbase/mozprofile/tests/addons/empty/install.rdf new file mode 100644 index 0000000000..70b9e13e44 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/addons/empty/install.rdf @@ -0,0 +1,20 @@ +<?xml version="1.0"?> +<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:em="http://www.mozilla.org/2004/em-rdf#"> + <Description about="urn:mozilla:install-manifest"> + <em:id>test-empty@quality.mozilla.org</em:id> + <em:version>0.1</em:version> + <em:name>Test Extension (empty)</em:name> + <em:creator>Mozilla QA</em:creator> + <em:homepageURL>http://quality.mozilla.org</em:homepageURL> + <em:type>2</em:type> + + <!-- Firefox --> + <em:targetApplication> + <Description> + <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id> + <em:minVersion>3.5.*</em:minVersion> + <em:maxVersion>*</em:maxVersion> + </Description> + </em:targetApplication> + </Description> +</RDF> diff --git a/testing/mozbase/mozprofile/tests/addons/invalid.xpi b/testing/mozbase/mozprofile/tests/addons/invalid.xpi Binary files differnew file mode 100644 index 0000000000..2f222c7637 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/addons/invalid.xpi diff --git a/testing/mozbase/mozprofile/tests/files/dummy-profile/.eslintrc.js b/testing/mozbase/mozprofile/tests/files/dummy-profile/.eslintrc.js new file mode 100644 index 0000000000..8d36cca287 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/files/dummy-profile/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + globals: { + user_pref: true, + }, +}; diff --git a/testing/mozbase/mozprofile/tests/files/dummy-profile/Preferences b/testing/mozbase/mozprofile/tests/files/dummy-profile/Preferences new file mode 100644 index 0000000000..697201c687 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/files/dummy-profile/Preferences @@ -0,0 +1 @@ +{"Preferences": 1} diff --git a/testing/mozbase/mozprofile/tests/files/dummy-profile/extensions/empty.xpi b/testing/mozbase/mozprofile/tests/files/dummy-profile/extensions/empty.xpi Binary files differnew file mode 100644 index 0000000000..26f28f099d --- /dev/null +++ b/testing/mozbase/mozprofile/tests/files/dummy-profile/extensions/empty.xpi diff --git a/testing/mozbase/mozprofile/tests/files/dummy-profile/prefs.js b/testing/mozbase/mozprofile/tests/files/dummy-profile/prefs.js new file mode 100644 index 0000000000..c2e18261a8 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/files/dummy-profile/prefs.js @@ -0,0 +1 @@ +user_pref("prefs.js", 1); diff --git a/testing/mozbase/mozprofile/tests/files/dummy-profile/user.js b/testing/mozbase/mozprofile/tests/files/dummy-profile/user.js new file mode 100644 index 0000000000..66752d6397 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/files/dummy-profile/user.js @@ -0,0 +1 @@ +user_pref("user.js", 1); diff --git a/testing/mozbase/mozprofile/tests/files/not_an_addon.txt b/testing/mozbase/mozprofile/tests/files/not_an_addon.txt new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/files/not_an_addon.txt diff --git a/testing/mozbase/mozprofile/tests/files/prefs_with_comments.js b/testing/mozbase/mozprofile/tests/files/prefs_with_comments.js new file mode 100644 index 0000000000..06a56f2138 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/files/prefs_with_comments.js @@ -0,0 +1,6 @@ +# A leading comment +user_pref("browser.startup.homepage", "http://planet.mozilla.org"); # A trailing comment +user_pref("zoom.minPercent", 30); +// Another leading comment +user_pref("zoom.maxPercent", 300); // Another trailing comment +user_pref("webgl.verbose", "false"); diff --git a/testing/mozbase/mozprofile/tests/files/prefs_with_interpolation.js b/testing/mozbase/mozprofile/tests/files/prefs_with_interpolation.js new file mode 100644 index 0000000000..52700df6d8 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/files/prefs_with_interpolation.js @@ -0,0 +1,5 @@ +/* globals user_pref */ +user_pref("browser.foo", "http://{server}"); +user_pref("zoom.minPercent", 30); +user_pref("webgl.verbose", "false"); +user_pref("browser.bar", "{abc}xyz"); diff --git a/testing/mozbase/mozprofile/tests/files/prefs_with_multiline.js b/testing/mozbase/mozprofile/tests/files/prefs_with_multiline.js new file mode 100644 index 0000000000..f3860f6472 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/files/prefs_with_multiline.js @@ -0,0 +1,5 @@ +/* globals user_pref */ +user_pref( + "browser.long.preference.name.that.causes.the.line.to.wrap", + "itislong" +); diff --git a/testing/mozbase/mozprofile/tests/install_manifests/test_addon_1.rdf b/testing/mozbase/mozprofile/tests/install_manifests/test_addon_1.rdf new file mode 100644 index 0000000000..839ea9fbd5 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/install_manifests/test_addon_1.rdf @@ -0,0 +1,21 @@ +<?xml version="1.0"?> +<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:em="http://www.mozilla.org/2004/em-rdf#"> + <Description about="urn:mozilla:install-manifest"> + <em:id>test-addon-1@mozilla.org</em:id> + <em:version>0.1</em:version> + <em:name>Test Add-on 1</em:name> + <em:creator>Mozilla</em:creator> + <em:homepageURL>http://mozilla.org</em:homepageURL> + <em:type>2</em:type> + + <!-- Firefox --> + <em:targetApplication> + <Description> + <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id> + <em:minVersion>3.5.*</em:minVersion> + <em:maxVersion>*</em:maxVersion> + </Description> + </em:targetApplication> + </Description> +</RDF> diff --git a/testing/mozbase/mozprofile/tests/install_manifests/test_addon_2.rdf b/testing/mozbase/mozprofile/tests/install_manifests/test_addon_2.rdf new file mode 100644 index 0000000000..8303e862fc --- /dev/null +++ b/testing/mozbase/mozprofile/tests/install_manifests/test_addon_2.rdf @@ -0,0 +1,21 @@ +<?xml version="1.0"?> +<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:em="http://www.mozilla.org/2004/em-rdf#"> + <Description about="urn:mozilla:install-manifest"> + <em:id>test-addon-2@mozilla.org</em:id> + <em:version>0.2</em:version> + <em:name>Test Add-on 2</em:name> + <em:creator>Mozilla</em:creator> + <em:homepageURL>http://mozilla.org</em:homepageURL> + <em:type>2</em:type> + + <!-- Firefox --> + <em:targetApplication> + <Description> + <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id> + <em:minVersion>3.5.*</em:minVersion> + <em:maxVersion>*</em:maxVersion> + </Description> + </em:targetApplication> + </Description> +</RDF> diff --git a/testing/mozbase/mozprofile/tests/install_manifests/test_addon_3.rdf b/testing/mozbase/mozprofile/tests/install_manifests/test_addon_3.rdf new file mode 100644 index 0000000000..5bd6d38043 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/install_manifests/test_addon_3.rdf @@ -0,0 +1,22 @@ +<?xml version="1.0"?> +<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:em="http://www.mozilla.org/2004/em-rdf#"> + <Description about="urn:mozilla:install-manifest"> + <em:id>test-addon-3@mozilla.org</em:id> + <em:version>0.1</em:version> + <em:name>Test Add-on 3</em:name> + <em:creator>Mozilla</em:creator> + <em:homepageURL>http://mozilla.org</em:homepageURL> + <em:type>2</em:type> + + <!-- Firefox --> + <em:targetApplication> + <Description> + <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id> + <em:minVersion>3.5.*</em:minVersion> + <em:maxVersion>*</em:maxVersion> + </Description> + </em:targetApplication> + </Description> +</RDF> + diff --git a/testing/mozbase/mozprofile/tests/install_manifests/test_addon_4.rdf b/testing/mozbase/mozprofile/tests/install_manifests/test_addon_4.rdf new file mode 100644 index 0000000000..e0f99d3133 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/install_manifests/test_addon_4.rdf @@ -0,0 +1,22 @@ +<?xml version="1.0"?> +<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:em="http://www.mozilla.org/2004/em-rdf#"> + <Description about="urn:mozilla:install-manifest"> + <em:id>test-addon-4@mozilla.org</em:id> + <em:version>0.1</em:version> + <em:name>Test Add-on 4</em:name> + <em:creator>Mozilla</em:creator> + <em:homepageURL>http://mozilla.org</em:homepageURL> + <em:type>2</em:type> + + <!-- Firefox --> + <em:targetApplication> + <Description> + <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id> + <em:minVersion>3.5.*</em:minVersion> + <em:maxVersion>*</em:maxVersion> + </Description> + </em:targetApplication> + </Description> +</RDF> + diff --git a/testing/mozbase/mozprofile/tests/install_manifests/test_addon_invalid_no_id.rdf b/testing/mozbase/mozprofile/tests/install_manifests/test_addon_invalid_no_id.rdf new file mode 100644 index 0000000000..23f60fece1 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/install_manifests/test_addon_invalid_no_id.rdf @@ -0,0 +1,22 @@ +<?xml version="1.0"?> +<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:em="http://www.mozilla.org/2004/em-rdf#"> + <Description about="urn:mozilla:install-manifest"> + <!-- Invalid because of a missing add-on id --> + <em:version>0.1</em:version> + <em:name>Test Invalid Extension (no id)</em:name> + <em:creator>Mozilla</em:creator> + <em:homepageURL>http://mozilla.org</em:homepageURL> + <em:type>2</em:type> + + <!-- Firefox --> + <em:targetApplication> + <Description> + <!-- Invalid target application string --> + <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id> + <em:minVersion>3.5.*</em:minVersion> + <em:maxVersion>*</em:maxVersion> + </Description> + </em:targetApplication> + </Description> +</RDF> diff --git a/testing/mozbase/mozprofile/tests/install_manifests/test_addon_invalid_not_wellformed.rdf b/testing/mozbase/mozprofile/tests/install_manifests/test_addon_invalid_not_wellformed.rdf new file mode 100644 index 0000000000..690ec406cc --- /dev/null +++ b/testing/mozbase/mozprofile/tests/install_manifests/test_addon_invalid_not_wellformed.rdf @@ -0,0 +1,23 @@ +<?xml version="1.0"?> +<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:em="http://www.mozilla.org/2004/em-rdf#"> + <Description about="urn:mozilla:install-manifest"> + <!-- Invalid because it's not well-formed --> + <em:id>test-addon-invalid-not-wellformed@mozilla.org</em:id + <em:version>0.1</em:version> + <em:name>Test Invalid Extension (no id)</em:name> + <em:creator>Mozilla</em:creator> + <em:homepageURL>http://mozilla.org</em:homepageURL> + <em:type>2</em:type> + + <!-- Firefox --> + <em:targetApplication> + <Description> + <!-- Invalid target application string --> + <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id> + <em:minVersion>3.5.*</em:minVersion> + <em:maxVersion>*</em:maxVersion> + </Description> + </em:targetApplication> + </Description> +</RDF> diff --git a/testing/mozbase/mozprofile/tests/install_manifests/test_addon_invalid_version.rdf b/testing/mozbase/mozprofile/tests/install_manifests/test_addon_invalid_version.rdf new file mode 100644 index 0000000000..c854bfcdb5 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/install_manifests/test_addon_invalid_version.rdf @@ -0,0 +1,23 @@ +<?xml version="1.0"?> +<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:em="http://www.mozilla.org/2004/em-rdf#"> + <Description about="urn:mozilla:install-manifest"> + <em:id>test-addon-invalid-version@mozilla.org</em:id> + <!-- Invalid addon version --> + <em:version>0.NOPE</em:version> + <em:name>Test Invalid Extension (invalid version)</em:name> + <em:creator>Mozilla</em:creator> + <em:homepageURL>http://mozilla.org</em:homepageURL> + <em:type>2</em:type> + + <!-- Firefox --> + <em:targetApplication> + <Description> + <!-- Invalid target application string --> + <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id> + <em:minVersion>3.5.*</em:minVersion> + <em:maxVersion>*</em:maxVersion> + </Description> + </em:targetApplication> + </Description> +</RDF> diff --git a/testing/mozbase/mozprofile/tests/install_manifests/test_addon_unpack.rdf b/testing/mozbase/mozprofile/tests/install_manifests/test_addon_unpack.rdf new file mode 100644 index 0000000000..cc85ea560f --- /dev/null +++ b/testing/mozbase/mozprofile/tests/install_manifests/test_addon_unpack.rdf @@ -0,0 +1,22 @@ +<?xml version="1.0"?> +<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:em="http://www.mozilla.org/2004/em-rdf#"> + <Description about="urn:mozilla:install-manifest"> + <em:id>test-addon-unpack@mozilla.org</em:id> + <em:version>0.1</em:version> + <em:name>Test Add-on (unpack)</em:name> + <em:creator>Mozilla</em:creator> + <em:homepageURL>http://mozilla.org</em:homepageURL> + <em:type>2</em:type> + <em:unpack>true</em:unpack> + + <!-- Firefox --> + <em:targetApplication> + <Description> + <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id> + <em:minVersion>3.5.*</em:minVersion> + <em:maxVersion>*</em:maxVersion> + </Description> + </em:targetApplication> + </Description> +</RDF> diff --git a/testing/mozbase/mozprofile/tests/manifest.toml b/testing/mozbase/mozprofile/tests/manifest.toml new file mode 100644 index 0000000000..1f5b90914a --- /dev/null +++ b/testing/mozbase/mozprofile/tests/manifest.toml @@ -0,0 +1,24 @@ +[DEFAULT] +subsuite = "mozbase" + +["test_addonid.py"] + +["test_addons.py"] + +["test_bug758250.py"] + +["test_chrome_profile.py"] + +["test_clone_cleanup.py"] + +["test_nonce.py"] + +["test_permissions.py"] + +["test_preferences.py"] + +["test_profile.py"] + +["test_profile_view.py"] + +["test_server_locations.py"] diff --git a/testing/mozbase/mozprofile/tests/test_addonid.py b/testing/mozbase/mozprofile/tests/test_addonid.py new file mode 100755 index 0000000000..4a9bef0163 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/test_addonid.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python + +import os + +import mozunit +import pytest +from mozprofile import addons + +here = os.path.dirname(os.path.abspath(__file__)) + + +"""Test finding the addon id in a variety of install.rdf styles""" + + +ADDON_ID_TESTS = [ + """ +<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:em="http://www.mozilla.org/2004/em-rdf#"> + <Description about="urn:mozilla:install-manifest"> + <em:id>winning</em:id> + <em:name>MozMill</em:name> + <em:version>2.0a</em:version> + <em:creator>Adam Christian</em:creator> + <em:description>A testing extension based on the + Windmill Testing Framework client source</em:description> + <em:unpack>true</em:unpack> + <em:targetApplication> + <!-- Firefox --> + <Description> + <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id> + <em:minVersion>3.5</em:minVersion> + <em:maxVersion>8.*</em:maxVersion> + </Description> + </em:targetApplication> + <em:targetApplication> + <!-- Thunderbird --> + <Description> + <em:id>{3550f703-e582-4d05-9a08-453d09bdfdc6}</em:id> + <em:minVersion>3.0a1pre</em:minVersion> + <em:maxVersion>3.2*</em:maxVersion> + </Description> + </em:targetApplication> + <em:targetApplication> + <!-- Sunbird --> + <Description> + <em:id>{718e30fb-e89b-41dd-9da7-e25a45638b28}</em:id> + <em:minVersion>0.6a1</em:minVersion> + <em:maxVersion>1.0pre</em:maxVersion> + </Description> + </em:targetApplication> + <em:targetApplication> + <!-- SeaMonkey --> + <Description> + <em:id>{92650c4d-4b8e-4d2a-b7eb-24ecf4f6b63a}</em:id> + <em:minVersion>2.0a1</em:minVersion> + <em:maxVersion>2.1*</em:maxVersion> + </Description> + </em:targetApplication> + <em:targetApplication> + <!-- Songbird --> + <Description> + <em:id>songbird@songbirdnest.com</em:id> + <em:minVersion>0.3pre</em:minVersion> + <em:maxVersion>1.3.0a</em:maxVersion> + </Description> + </em:targetApplication> + <em:targetApplication> + <Description> + <em:id>toolkit@mozilla.org</em:id> + <em:minVersion>1.9.1</em:minVersion> + <em:maxVersion>2.0*</em:maxVersion> + </Description> + </em:targetApplication> + </Description> +</RDF>""", + """ +<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:em="http://www.mozilla.org/2004/em-rdf#"> + <Description about="urn:mozilla:install-manifest"> + <em:targetApplication> + <!-- Firefox --> + <Description> + <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id> + <em:minVersion>3.5</em:minVersion> + <em:maxVersion>8.*</em:maxVersion> + </Description> + </em:targetApplication> + <em:id>winning</em:id> + <em:name>MozMill</em:name> + <em:version>2.0a</em:version> + <em:creator>Adam Christian</em:creator> + <em:description>A testing extension based on the + Windmill Testing Framework client source</em:description> + <em:unpack>true</em:unpack> + </Description> + </RDF>""", + """ +<RDF xmlns="http://www.mozilla.org/2004/em-rdf#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> + <rdf:Description about="urn:mozilla:install-manifest"> + <id>winning</id> + <name>foo</name> + <version>42</version> + <description>A testing extension based on the + Windmill Testing Framework client source</description> + </rdf:Description> +</RDF>""", + """ +<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:foobar="http://www.mozilla.org/2004/em-rdf#"> + <Description about="urn:mozilla:install-manifest"> + <foobar:targetApplication> + <!-- Firefox --> + <Description> + <foobar:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</foobar:id> + <foobar:minVersion>3.5</foobar:minVersion> + <foobar:maxVersion>8.*</foobar:maxVersion> + </Description> + </foobar:targetApplication> + <foobar:id>winning</foobar:id> + <foobar:name>MozMill</foobar:name> + <foobar:version>2.0a</foobar:version> + <foobar:creator>Adam Christian</foobar:creator> + <foobar:description>A testing extension based on the + Windmill Testing Framework client source</foobar:description> + <foobar:unpack>true</foobar:unpack> + </Description> + </RDF>""", +] + + +@pytest.fixture( + params=ADDON_ID_TESTS, ids=[str(i) for i in range(0, len(ADDON_ID_TESTS))] +) +def profile(request, tmpdir): + test = request.param + path = tmpdir.mkdtemp().strpath + + with open(os.path.join(path, "install.rdf"), "w") as fh: + fh.write(test) + return path + + +def test_addonID(profile): + a = addons.AddonManager(os.path.join(profile, "profile")) + addon_id = a.addon_details(profile)["id"] + assert addon_id == "winning" + + +def test_addonID_xpi(): + a = addons.AddonManager("profile") + addon = a.addon_details(os.path.join(here, "addons", "empty.xpi")) + assert addon["id"] == "test-empty@quality.mozilla.org" + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozprofile/tests/test_addons.py b/testing/mozbase/mozprofile/tests/test_addons.py new file mode 100644 index 0000000000..f6b79ce498 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/test_addons.py @@ -0,0 +1,373 @@ +#!/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/. + +import os +import zipfile + +import mozprofile +import mozunit +import pytest +from addon_stubs import generate_addon + +here = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture +def profile(): + return mozprofile.Profile() + + +@pytest.fixture +def am(profile): + return profile.addons + + +def test_install_multiple_same_source(tmpdir, am): + path = tmpdir.strpath + + # Generate installer stubs for all possible types of addons + addon_xpi = generate_addon("test-addon-1@mozilla.org", path=path) + addon_folder = generate_addon("test-addon-1@mozilla.org", path=path, xpi=False) + + # The same folder should not be installed twice + am.install([addon_folder, addon_folder]) + assert am.installed_addons == [addon_folder] + am.clean() + + # The same XPI file should not be installed twice + am.install([addon_xpi, addon_xpi]) + assert am.installed_addons == [addon_xpi] + am.clean() + + # Even if it is the same id the add-on should be installed twice, if + # specified via XPI and folder + am.install([addon_folder, addon_xpi]) + assert len(am.installed_addons) == 2 + assert addon_folder in am.installed_addons + assert addon_xpi in am.installed_addons + am.clean() + + +def test_install_webextension_from_dir(tmpdir, am): + tmpdir = tmpdir.strpath + + addon = os.path.join(here, "addons", "apply-css.xpi") + zipped = zipfile.ZipFile(addon) + try: + zipped.extractall(tmpdir) + finally: + zipped.close() + am.install(tmpdir) + assert len(am.installed_addons) == 1 + assert os.path.isdir(am.installed_addons[0]) + + +def test_install_webextension(am): + addon = os.path.join(here, "addons", "apply-css.xpi") + + am.install(addon) + assert len(am.installed_addons) == 1 + assert os.path.isfile(am.installed_addons[0]) + assert "apply-css.xpi" == os.path.basename(am.installed_addons[0]) + + details = am.addon_details(am.installed_addons[0]) + assert "test-webext@quality.mozilla.org" == details["id"] + + +def test_install_webextension_id_via_browser_specific_settings(am): + # See Bug 1572404 + addon = os.path.join( + here, "addons", "apply-css-id-via-browser-specific-settings.xpi" + ) + am.install(addon) + assert len(am.installed_addons) == 1 + assert os.path.isfile(am.installed_addons[0]) + assert "apply-css-id-via-browser-specific-settings.xpi" == os.path.basename( + am.installed_addons[0] + ) + + details = am.addon_details(am.installed_addons[0]) + assert "test-webext@quality.mozilla.org" == details["id"] + + +def test_install_webextension_sans_id(am): + addon = os.path.join(here, "addons", "apply-css-sans-id.xpi") + am.install(addon) + + assert len(am.installed_addons) == 1 + assert os.path.isfile(am.installed_addons[0]) + assert "apply-css-sans-id.xpi" == os.path.basename(am.installed_addons[0]) + + details = am.addon_details(am.installed_addons[0]) + assert "@temporary-addon" in details["id"] + + +def test_install_xpi(tmpdir, am): + tmpdir = tmpdir.strpath + + addons_to_install = [] + addons_installed = [] + + # Generate installer stubs and install them + for ext in ["test-addon-1@mozilla.org", "test-addon-2@mozilla.org"]: + temp_addon = generate_addon(ext, path=tmpdir) + addons_to_install.append(am.addon_details(temp_addon)["id"]) + am.install(temp_addon) + + # Generate a list of addons installed in the profile + addons_installed = [ + str(x[: -len(".xpi")]) + for x in os.listdir(os.path.join(am.profile, "extensions")) + ] + assert addons_to_install.sort() == addons_installed.sort() + + +def test_install_folder(tmpdir, am): + tmpdir = tmpdir.strpath + + # Generate installer stubs for all possible types of addons + addons = [] + addons.append(generate_addon("test-addon-1@mozilla.org", path=tmpdir)) + addons.append(generate_addon("test-addon-2@mozilla.org", path=tmpdir, xpi=False)) + addons.append( + generate_addon("test-addon-3@mozilla.org", path=tmpdir, name="addon-3") + ) + addons.append( + generate_addon( + "test-addon-4@mozilla.org", path=tmpdir, name="addon-4", xpi=False + ) + ) + addons.sort() + + am.install(tmpdir) + + assert am.installed_addons == addons + + +def test_install_unpack(tmpdir, am): + tmpdir = tmpdir.strpath + + # Generate installer stubs for all possible types of addons + addon_xpi = generate_addon("test-addon-unpack@mozilla.org", path=tmpdir) + addon_folder = generate_addon( + "test-addon-unpack@mozilla.org", path=tmpdir, xpi=False + ) + addon_no_unpack = generate_addon("test-addon-1@mozilla.org", path=tmpdir) + + # Test unpack flag for add-on as XPI + am.install(addon_xpi) + assert am.installed_addons == [addon_xpi] + am.clean() + + # Test unpack flag for add-on as folder + am.install(addon_folder) + assert am.installed_addons == [addon_folder] + am.clean() + + # Test forcing unpack an add-on + am.install(addon_no_unpack, unpack=True) + assert am.installed_addons == [addon_no_unpack] + am.clean() + + +def test_install_after_reset(tmpdir, profile): + tmpdir = tmpdir.strpath + am = profile.addons + + # Installing the same add-on after a reset should not cause a failure + addon = generate_addon("test-addon-1@mozilla.org", path=tmpdir, xpi=False) + + # We cannot use am because profile.reset() creates a new instance + am.install(addon) + profile.reset() + + am.install(addon) + assert am.installed_addons == [addon] + + +def test_install_backup(tmpdir, am): + tmpdir = tmpdir.strpath + + staged_path = os.path.join(am.profile, "extensions") + + # Generate installer stubs for all possible types of addons + addon_xpi = generate_addon("test-addon-1@mozilla.org", path=tmpdir) + addon_folder = generate_addon("test-addon-1@mozilla.org", path=tmpdir, xpi=False) + addon_name = generate_addon( + "test-addon-1@mozilla.org", path=tmpdir, name="test-addon-1-dupe@mozilla.org" + ) + + # Test backup of xpi files + am.install(addon_xpi) + assert am.backup_dir is None + + am.install(addon_xpi) + assert am.backup_dir is not None + assert os.listdir(am.backup_dir) == ["test-addon-1@mozilla.org.xpi"] + + am.clean() + assert os.listdir(staged_path) == ["test-addon-1@mozilla.org.xpi"] + am.clean() + + # Test backup of folders + am.install(addon_folder) + assert am.backup_dir is None + + am.install(addon_folder) + assert am.backup_dir is not None + assert os.listdir(am.backup_dir) == ["test-addon-1@mozilla.org"] + + am.clean() + assert os.listdir(staged_path) == ["test-addon-1@mozilla.org"] + am.clean() + + # Test backup of xpi files with another file name + am.install(addon_name) + assert am.backup_dir is None + + am.install(addon_xpi) + assert am.backup_dir is not None + assert os.listdir(am.backup_dir) == ["test-addon-1@mozilla.org.xpi"] + + am.clean() + assert os.listdir(staged_path) == ["test-addon-1@mozilla.org.xpi"] + am.clean() + + +def test_install_invalid_addons(tmpdir, am): + tmpdir = tmpdir.strpath + + # Generate installer stubs for all possible types of addons + addons = [] + addons.append( + generate_addon( + "test-addon-invalid-no-manifest@mozilla.org", path=tmpdir, xpi=False + ) + ) + addons.append(generate_addon("test-addon-invalid-no-id@mozilla.org", path=tmpdir)) + + am.install(tmpdir) + + assert am.installed_addons == [] + + +@pytest.mark.xfail(reason="feature not implemented as part of AddonManger") +def test_install_error(am): + """Check install raises an error with an invalid addon""" + temp_addon = generate_addon("test-addon-invalid-version@mozilla.org") + # This should raise an error here + with pytest.raises(Exception): + am.install(temp_addon) + + +def test_addon_details(tmpdir, am): + tmpdir = tmpdir.strpath + + # Generate installer stubs for a valid and invalid add-on manifest + valid_addon = generate_addon("test-addon-1@mozilla.org", path=tmpdir) + invalid_addon = generate_addon( + "test-addon-invalid-not-wellformed@mozilla.org", path=tmpdir + ) + + # Check valid add-on + details = am.addon_details(valid_addon) + assert details["id"] == "test-addon-1@mozilla.org" + assert details["name"] == "Test Add-on 1" + assert not details["unpack"] + assert details["version"] == "0.1" + + # Check invalid add-on + with pytest.raises(mozprofile.addons.AddonFormatError): + am.addon_details(invalid_addon) + + # Check invalid path + with pytest.raises(IOError): + am.addon_details("") + + # Check invalid add-on format + addon_path = os.path.join(os.path.join(here, "files"), "not_an_addon.txt") + with pytest.raises(mozprofile.addons.AddonFormatError): + am.addon_details(addon_path) + + +def test_clean_addons(am): + addon_one = generate_addon("test-addon-1@mozilla.org") + addon_two = generate_addon("test-addon-2@mozilla.org") + + am.install(addon_one) + installed_addons = [ + str(x[: -len(".xpi")]) + for x in os.listdir(os.path.join(am.profile, "extensions")) + ] + + # Create a new profile based on an existing profile + # Install an extra addon in the new profile + # Cleanup addons + duplicate_profile = mozprofile.profile.Profile(profile=am.profile, addons=addon_two) + duplicate_profile.addons.clean() + + addons_after_cleanup = [ + str(x[: -len(".xpi")]) + for x in os.listdir(os.path.join(duplicate_profile.profile, "extensions")) + ] + # New addons installed should be removed by clean_addons() + assert installed_addons == addons_after_cleanup + + +def test_noclean(tmpdir): + """test `restore=True/False` functionality""" + profile = tmpdir.mkdtemp().strpath + tmpdir = tmpdir.mkdtemp().strpath + + # empty initially + assert not bool(os.listdir(profile)) + + # make an addon + addons = [ + generate_addon("test-addon-1@mozilla.org", path=tmpdir), + os.path.join(here, "addons", "empty.xpi"), + ] + + # install it with a restore=True AddonManager + am = mozprofile.addons.AddonManager(profile, restore=True) + + for addon in addons: + am.install(addon) + + # now its there + assert os.listdir(profile) == ["extensions"] + staging_folder = os.path.join(profile, "extensions") + assert os.path.exists(staging_folder) + assert len(os.listdir(staging_folder)) == 2 + + del am + + assert os.listdir(profile) == ["extensions"] + assert os.path.exists(staging_folder) + assert os.listdir(staging_folder) == [] + + +def test_remove_addon(tmpdir, am): + tmpdir = tmpdir.strpath + + addons = [] + addons.append(generate_addon("test-addon-1@mozilla.org", path=tmpdir)) + addons.append(generate_addon("test-addon-2@mozilla.org", path=tmpdir)) + + am.install(tmpdir) + + extensions_path = os.path.join(am.profile, "extensions") + staging_path = os.path.join(extensions_path) + + for addon in am._addons: + am.remove_addon(addon) + + assert os.listdir(staging_path) == [] + assert os.listdir(extensions_path) == [] + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozprofile/tests/test_bug758250.py b/testing/mozbase/mozprofile/tests/test_bug758250.py new file mode 100755 index 0000000000..526454a476 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/test_bug758250.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python + +import os +import shutil + +import mozprofile +import mozunit + +here = os.path.dirname(os.path.abspath(__file__)) + +""" +use of --profile in mozrunner just blows away addon sources: +https://bugzilla.mozilla.org/show_bug.cgi?id=758250 +""" + + +def test_profile_addon_cleanup(tmpdir): + tmpdir = tmpdir.mkdtemp().strpath + addon = os.path.join(here, "addons", "empty") + + # sanity check: the empty addon should be here + assert os.path.exists(addon) + assert os.path.isdir(addon) + assert os.path.exists(os.path.join(addon, "install.rdf")) + + # because we are testing data loss, let's make sure we make a copy + shutil.rmtree(tmpdir) + shutil.copytree(addon, tmpdir) + assert os.path.exists(os.path.join(tmpdir, "install.rdf")) + + # make a starter profile + profile = mozprofile.FirefoxProfile() + path = profile.profile + + # make a new profile based on the old + newprofile = mozprofile.FirefoxProfile(profile=path, addons=[tmpdir]) + newprofile.cleanup() + + # the source addon *should* still exist + assert os.path.exists(tmpdir) + assert os.path.exists(os.path.join(tmpdir, "install.rdf")) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozprofile/tests/test_chrome_profile.py b/testing/mozbase/mozprofile/tests/test_chrome_profile.py new file mode 100644 index 0000000000..27c07686f8 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/test_chrome_profile.py @@ -0,0 +1,72 @@ +# 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/. + +import json +import os + +import mozunit +from mozprofile import ChromeProfile + + +def test_chrome_profile_pre_existing(tmpdir): + path = tmpdir.strpath + profile = ChromeProfile(profile=path) + assert not profile.create_new + assert os.path.isdir(profile.profile) + assert profile.profile == path + + +def test_chrome_profile_create_new(): + profile = ChromeProfile() + assert profile.create_new + assert os.path.isdir(profile.profile) + assert profile.profile.endswith("Default") + + +def test_chrome_preferences(tmpdir): + prefs = {"foo": "bar"} + profile = ChromeProfile(preferences=prefs) + prefs_file = os.path.join(profile.profile, "Preferences") + + assert os.path.isfile(prefs_file) + + with open(prefs_file) as fh: + assert json.load(fh) == prefs + + # test with existing prefs + prefs_file = tmpdir.join("Preferences").strpath + with open(prefs_file, "w") as fh: + json.dump({"num": "1"}, fh) + + profile = ChromeProfile(profile=tmpdir.strpath, preferences=prefs) + + def assert_prefs(): + with open(prefs_file) as fh: + data = json.load(fh) + + assert len(data) == 2 + assert data.get("foo") == "bar" + assert data.get("num") == "1" + + assert_prefs() + profile.reset() + assert_prefs() + + +def test_chrome_addons(): + addons = ["foo", "bar"] + profile = ChromeProfile(addons=addons) + + assert isinstance(profile.addons, list) + assert profile.addons == addons + + profile.addons.install("baz") + assert profile.addons == addons + ["baz"] + + profile.reset() + assert profile.addons == addons + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozprofile/tests/test_clone_cleanup.py b/testing/mozbase/mozprofile/tests/test_clone_cleanup.py new file mode 100644 index 0000000000..5a2e9bbd24 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/test_clone_cleanup.py @@ -0,0 +1,78 @@ +#!/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/. + +import os + +import mozfile +import mozunit +import pytest +from mozprofile.profile import Profile + +""" +test cleanup logic for the clone functionality +see https://bugzilla.mozilla.org/show_bug.cgi?id=642843 +""" + + +@pytest.fixture +def profile(tmpdir): + # make a profile with one preference + path = tmpdir.mkdtemp().strpath + profile = Profile(path, preferences={"foo": "bar"}, restore=False) + user_js = os.path.join(profile.profile, "user.js") + assert os.path.exists(user_js) + return profile + + +def test_restore_true(profile): + counter = [0] + + def _feedback(dir, content): + # Called by shutil.copytree on each visited directory. + # Used here to display info. + # + # Returns the items that should be ignored by + # shutil.copytree when copying the tree, so always returns + # an empty list. + counter[0] += 1 + return [] + + # make a clone of this profile with restore=True + clone = Profile.clone(profile.profile, restore=True, ignore=_feedback) + try: + clone.cleanup() + + # clone should be deleted + assert not os.path.exists(clone.profile) + assert counter[0] > 0 + finally: + mozfile.remove(clone.profile) + + +def test_restore_false(profile): + # make a clone of this profile with restore=False + clone = Profile.clone(profile.profile, restore=False) + try: + clone.cleanup() + + # clone should still be around on the filesystem + assert os.path.exists(clone.profile) + finally: + mozfile.remove(clone.profile) + + +def test_cleanup_on_garbage_collected(profile): + clone = Profile.clone(profile.profile) + profile_dir = clone.profile + assert os.path.exists(profile_dir) + del clone + + # clone should be deleted + assert not os.path.exists(profile_dir) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozprofile/tests/test_nonce.py b/testing/mozbase/mozprofile/tests/test_nonce.py new file mode 100755 index 0000000000..3e3b2ae2b8 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/test_nonce.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python + +""" +test nonce in prefs delimeters +see https://bugzilla.mozilla.org/show_bug.cgi?id=722804 +""" + +import os + +import mozunit +from mozprofile.prefs import Preferences +from mozprofile.profile import Profile + + +def test_nonce(tmpdir): + # make a profile with one preference + path = tmpdir.strpath + profile = Profile(path, preferences={"foo": "bar"}, restore=False) + user_js = os.path.join(profile.profile, "user.js") + assert os.path.exists(user_js) + + # ensure the preference is correct + prefs = Preferences.read_prefs(user_js) + assert dict(prefs) == {"foo": "bar"} + + del profile + + # augment the profile with a second preference + profile = Profile(path, preferences={"fleem": "baz"}, restore=True) + prefs = Preferences.read_prefs(user_js) + assert dict(prefs) == {"foo": "bar", "fleem": "baz"} + + # cleanup the profile; + # this should remove the new preferences but not the old + profile.cleanup() + prefs = Preferences.read_prefs(user_js) + assert dict(prefs) == {"foo": "bar"} + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozprofile/tests/test_permissions.py b/testing/mozbase/mozprofile/tests/test_permissions.py new file mode 100755 index 0000000000..3fe688366d --- /dev/null +++ b/testing/mozbase/mozprofile/tests/test_permissions.py @@ -0,0 +1,64 @@ +#!/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/. + +import mozunit +import pytest +from mozprofile.permissions import Permissions + +LOCATIONS = """http://mochi.test:8888 primary,privileged +http://127.0.0.1:8888 privileged +""" + + +@pytest.fixture +def locations_file(tmpdir): + locations_file = tmpdir.join("locations.txt") + locations_file.write(LOCATIONS) + return locations_file.strpath + + +@pytest.fixture +def perms(tmpdir, locations_file): + return Permissions(locations_file) + + +def test_nw_prefs(perms): + prefs, user_prefs = perms.network_prefs(False) + + assert len(user_prefs) == 0 + assert len(prefs) == 0 + + prefs, user_prefs = perms.network_prefs({"http": 8888}) + assert len(user_prefs) == 2 + assert user_prefs[0] == ("network.proxy.type", 2) + assert user_prefs[1][0] == "network.proxy.autoconfig_url" + + origins_decl = ( + "var knownOrigins = (function () { return ['http://mochi.test:8888', " + "'http://127.0.0.1:8888'].reduce" + ) + assert origins_decl in user_prefs[1][1] + + proxy_check = ( + "'http': 'PROXY mochi.test:8888'", + "'https': 'PROXY mochi.test:4443'", + "'ws': 'PROXY mochi.test:4443'", + "'wss': 'PROXY mochi.test:4443'", + ) + assert all(c in user_prefs[1][1] for c in proxy_check) + + prefs, user_prefs = perms.network_prefs({"dohServerPort": 443}) + print(user_prefs) + assert len(user_prefs) == 5 + assert user_prefs[0] == ("network.proxy.type", 0) + assert user_prefs[1] == ("network.trr.mode", 3) + assert user_prefs[2] == ("network.trr.uri", "https://foo.example.com:443/dns-query") + assert user_prefs[3] == ("network.trr.bootstrapAddr", "127.0.0.1") + assert user_prefs[4] == ("network.dns.force_use_https_rr", True) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozprofile/tests/test_preferences.py b/testing/mozbase/mozprofile/tests/test_preferences.py new file mode 100755 index 0000000000..6cc62546e3 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/test_preferences.py @@ -0,0 +1,428 @@ +#!/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/. + +import os +import shutil +import tempfile + +import mozfile +import mozunit +import pytest +from mozprofile.cli import MozProfileCLI +from mozprofile.prefs import Preferences, PreferencesReadError +from mozprofile.profile import Profile +from wptserve import server + +here = os.path.dirname(os.path.abspath(__file__)) + + +# preferences from files/prefs_with_comments.js +_prefs_with_comments = { + "browser.startup.homepage": "http://planet.mozilla.org", + "zoom.minPercent": 30, + "zoom.maxPercent": 300, + "webgl.verbose": "false", +} + + +@pytest.fixture +def run_command(): + """ + invokes mozprofile command line via the CLI factory + - args : command line arguments (equivalent of sys.argv[1:]) + """ + + def inner(*args): + # instantiate the factory + cli = MozProfileCLI(list(args)) + + # create the profile + profile = cli.profile() + + # return path to profile + return profile.profile + + return inner + + +@pytest.fixture +def compare_generated(run_command): + """ + writes out to a new profile with mozprofile command line + reads the generated preferences with prefs.py + compares the results + cleans up + """ + + def inner(prefs, commandline): + profile = run_command(*commandline) + prefs_file = os.path.join(profile, "user.js") + assert os.path.exists(prefs_file) + read = Preferences.read_prefs(prefs_file) + if isinstance(prefs, dict): + read = dict(read) + assert prefs == read + shutil.rmtree(profile) + + return inner + + +def test_basic_prefs(compare_generated): + """test setting a pref from the command line entry point""" + + _prefs = {"browser.startup.homepage": "http://planet.mozilla.org/"} + commandline = [] + for pref, value in _prefs.items(): + commandline += ["--pref", "%s:%s" % (pref, value)] + compare_generated(_prefs, commandline) + + +def test_ordered_prefs(compare_generated): + """ensure the prefs stay in the right order""" + _prefs = [ + ("browser.startup.homepage", "http://planet.mozilla.org/"), + ("zoom.minPercent", 30), + ("zoom.maxPercent", 300), + ("webgl.verbose", "false"), + ] + commandline = [] + for pref, value in _prefs: + commandline += ["--pref", "%s:%s" % (pref, value)] + _prefs = [(i, Preferences.cast(j)) for i, j in _prefs] + compare_generated(_prefs, commandline) + + +def test_ini(compare_generated): + # write the .ini file + _ini = """[DEFAULT] +browser.startup.homepage = http://planet.mozilla.org/ + +[foo] +browser.startup.homepage = http://github.com/ +""" + try: + fd, name = tempfile.mkstemp(suffix=".ini", text=True) + os.write(fd, _ini.encode()) + os.close(fd) + commandline = ["--preferences", name] + + # test the [DEFAULT] section + _prefs = {"browser.startup.homepage": "http://planet.mozilla.org/"} + compare_generated(_prefs, commandline) + + # test a specific section + _prefs = {"browser.startup.homepage": "http://github.com/"} + commandline[-1] = commandline[-1] + ":foo" + compare_generated(_prefs, commandline) + + finally: + # cleanup + os.remove(name) + + +def test_ini_keep_case(compare_generated): + """ + Read a preferences config file with a preference in camel-case style. + Check that the read preference name has not been lower-cased + """ + # write the .ini file + _ini = """[DEFAULT] +network.dns.disableIPv6 = True +""" + try: + fd, name = tempfile.mkstemp(suffix=".ini", text=True) + os.write(fd, _ini.encode()) + os.close(fd) + commandline = ["--preferences", name] + + # test the [DEFAULT] section + _prefs = {"network.dns.disableIPv6": "True"} + compare_generated(_prefs, commandline) + + finally: + # cleanup + os.remove(name) + + +def test_reset_should_remove_added_prefs(): + """Check that when we call reset the items we expect are updated""" + profile = Profile() + prefs_file = os.path.join(profile.profile, "user.js") + + # we shouldn't have any initial preferences + initial_prefs = Preferences.read_prefs(prefs_file) + assert not initial_prefs + initial_prefs = open(prefs_file).read().strip() + assert not initial_prefs + + # add some preferences + prefs1 = [("mr.t.quotes", "i aint getting on no plane!")] + profile.set_preferences(prefs1) + assert prefs1 == Preferences.read_prefs(prefs_file) + lines = open(prefs_file).read().strip().splitlines() + assert any(line.startswith("#MozRunner Prefs Start") for line in lines) + assert any(line.startswith("#MozRunner Prefs End") for line in lines) + + profile.reset() + assert prefs1 != Preferences.read_prefs(os.path.join(profile.profile, "user.js")) + + +def test_reset_should_keep_user_added_prefs(): + """Check that when we call reset the items we expect are updated""" + profile = Profile() + prefs_file = os.path.join(profile.profile, "user.js") + + # we shouldn't have any initial preferences + initial_prefs = Preferences.read_prefs(prefs_file) + assert not initial_prefs + initial_prefs = open(prefs_file).read().strip() + assert not initial_prefs + + # add some preferences + prefs1 = [("mr.t.quotes", "i aint getting on no plane!")] + profile.set_persistent_preferences(prefs1) + assert prefs1 == Preferences.read_prefs(prefs_file) + lines = open(prefs_file).read().strip().splitlines() + assert any(line.startswith("#MozRunner Prefs Start") for line in lines) + assert any(line.startswith("#MozRunner Prefs End") for line in lines) + + profile.reset() + assert prefs1 == Preferences.read_prefs(os.path.join(profile.profile, "user.js")) + + +def test_magic_markers(): + """ensure our magic markers are working""" + + profile = Profile() + prefs_file = os.path.join(profile.profile, "user.js") + + # we shouldn't have any initial preferences + initial_prefs = Preferences.read_prefs(prefs_file) + assert not initial_prefs + initial_prefs = open(prefs_file).read().strip() + assert not initial_prefs + + # add some preferences + prefs1 = [ + ("browser.startup.homepage", "http://planet.mozilla.org/"), + ("zoom.minPercent", 30), + ] + profile.set_preferences(prefs1) + assert prefs1 == Preferences.read_prefs(prefs_file) + lines = open(prefs_file).read().strip().splitlines() + assert bool([line for line in lines if line.startswith("#MozRunner Prefs Start")]) + assert bool([line for line in lines if line.startswith("#MozRunner Prefs End")]) + + # add some more preferences + prefs2 = [("zoom.maxPercent", 300), ("webgl.verbose", "false")] + profile.set_preferences(prefs2) + assert prefs1 + prefs2 == Preferences.read_prefs(prefs_file) + lines = open(prefs_file).read().strip().splitlines() + assert ( + len([line for line in lines if line.startswith("#MozRunner Prefs Start")]) == 2 + ) + assert len([line for line in lines if line.startswith("#MozRunner Prefs End")]) == 2 + + # now clean it up + profile.clean_preferences() + final_prefs = Preferences.read_prefs(prefs_file) + assert not final_prefs + lines = open(prefs_file).read().strip().splitlines() + assert "#MozRunner Prefs Start" not in lines + assert "#MozRunner Prefs End" not in lines + + +def test_preexisting_preferences(): + """ensure you don't clobber preexisting preferences""" + + # make a pretend profile + tempdir = tempfile.mkdtemp() + + try: + # make a user.js + contents = """ +user_pref("webgl.enabled_for_all_sites", true); +user_pref("webgl.force-enabled", true); +""" + user_js = os.path.join(tempdir, "user.js") + f = open(user_js, "w") + f.write(contents) + f.close() + + # make sure you can read it + prefs = Preferences.read_prefs(user_js) + original_prefs = [ + ("webgl.enabled_for_all_sites", True), + ("webgl.force-enabled", True), + ] + assert prefs == original_prefs + + # now read this as a profile + profile = Profile( + tempdir, preferences={"browser.download.dir": "/home/jhammel"} + ) + + # make sure the new pref is now there + new_prefs = original_prefs[:] + [("browser.download.dir", "/home/jhammel")] + prefs = Preferences.read_prefs(user_js) + assert prefs == new_prefs + + # clean up the added preferences + profile.cleanup() + del profile + + # make sure you have the original preferences + prefs = Preferences.read_prefs(user_js) + assert prefs == original_prefs + finally: + shutil.rmtree(tempdir) + + +def test_can_read_prefs_with_multiline_comments(): + """ + Ensure that multiple comments in the file header do not break reading + the prefs (https://bugzilla.mozilla.org/show_bug.cgi?id=1233534). + """ + user_js = tempfile.NamedTemporaryFile(suffix=".js", delete=False) + try: + with user_js: + user_js.write( + """ +# Mozilla User Preferences + +/* Do not edit this file. +* +* If you make changes to this file while the application is running, +* the changes will be overwritten when the application exits. +* +* To make a manual change to preferences, you can visit the URL about:config +*/ + +user_pref("webgl.enabled_for_all_sites", true); +user_pref("webgl.force-enabled", true); +""".encode() + ) + assert Preferences.read_prefs(user_js.name) == [ + ("webgl.enabled_for_all_sites", True), + ("webgl.force-enabled", True), + ] + finally: + mozfile.remove(user_js.name) + + +def test_json(compare_generated): + _prefs = {"browser.startup.homepage": "http://planet.mozilla.org/"} + json = '{"browser.startup.homepage": "http://planet.mozilla.org/"}' + + # just repr it...could use the json module but we don't need it here + with mozfile.NamedTemporaryFile(suffix=".json") as f: + f.write(json.encode()) + f.flush() + + commandline = ["--preferences", f.name] + compare_generated(_prefs, commandline) + + +def test_json_datatypes(): + # minPercent is at 30.1 to test if non-integer data raises an exception + json = """{"zoom.minPercent": 30.1, "zoom.maxPercent": 300}""" + + with mozfile.NamedTemporaryFile(suffix=".json") as f: + f.write(json.encode()) + f.flush() + + with pytest.raises(PreferencesReadError): + Preferences.read_json(f._path) + + +def test_prefs_write(): + """test that the Preferences.write() method correctly serializes preferences""" + + _prefs = { + "browser.startup.homepage": "http://planet.mozilla.org", + "zoom.minPercent": 30, + "zoom.maxPercent": 300, + } + + # make a Preferences manager with the testing preferences + preferences = Preferences(_prefs) + + # write them to a temporary location + path = None + read_prefs = None + try: + with mozfile.NamedTemporaryFile(suffix=".js", delete=False, mode="w+t") as f: + path = f.name + preferences.write(f, _prefs) + + # read them back and ensure we get what we put in + read_prefs = dict(Preferences.read_prefs(path)) + + finally: + # cleanup + if path and os.path.exists(path): + os.remove(path) + + assert read_prefs == _prefs + + +def test_read_prefs_with_comments(): + """test reading preferences from a prefs.js file that contains comments""" + + path = os.path.join(here, "files", "prefs_with_comments.js") + assert dict(Preferences.read_prefs(path)) == _prefs_with_comments + + +def test_read_prefs_with_interpolation(): + """test reading preferences from a prefs.js file whose values + require interpolation""" + + expected_prefs = { + "browser.foo": "http://server-name", + "zoom.minPercent": 30, + "webgl.verbose": "false", + "browser.bar": "somethingxyz", + } + values = {"server": "server-name", "abc": "something"} + path = os.path.join(here, "files", "prefs_with_interpolation.js") + read_prefs = Preferences.read_prefs(path, interpolation=values) + assert dict(read_prefs) == expected_prefs + + +def test_read_prefs_with_multiline(): + """test reading preferences from a prefs.js file that contains multiline prefs""" + + path = os.path.join(here, "files", "prefs_with_multiline.js") + assert dict(Preferences.read_prefs(path)) == { + "browser.long.preference.name.that.causes.the.line.to.wrap": "itislong" + } + + +def test_read_prefs_ttw(): + """test reading preferences through the web via wptserve""" + + # create a WebTestHttpd instance + docroot = os.path.join(here, "files") + host = "127.0.0.1" + port = 8888 + httpd = server.WebTestHttpd(host=host, port=port, doc_root=docroot) + + # create a preferences instance + prefs = Preferences() + + try: + # start server + httpd.start() + + # read preferences through the web + read = prefs.read_prefs("http://%s:%d/prefs_with_comments.js" % (host, port)) + assert dict(read) == _prefs_with_comments + finally: + httpd.stop() + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozprofile/tests/test_profile.py b/testing/mozbase/mozprofile/tests/test_profile.py new file mode 100644 index 0000000000..afbd4c365b --- /dev/null +++ b/testing/mozbase/mozprofile/tests/test_profile.py @@ -0,0 +1,111 @@ +#!/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/. + +import os + +import mozunit +import pytest +from mozprofile import ( + BaseProfile, + ChromeProfile, + ChromiumProfile, + FirefoxProfile, + Profile, + ThunderbirdProfile, + create_profile, +) +from mozprofile.prefs import Preferences + +here = os.path.abspath(os.path.dirname(__file__)) + + +def test_with_profile_should_cleanup(): + with Profile() as profile: + assert os.path.exists(profile.profile) + + # profile is cleaned + assert not os.path.exists(profile.profile) + + +def test_with_profile_should_cleanup_even_on_exception(): + with pytest.raises(ZeroDivisionError): + # pylint --py3k W1619 + with Profile() as profile: + assert os.path.exists(profile.profile) + 1 / 0 # will raise ZeroDivisionError + + # profile is cleaned + assert not os.path.exists(profile.profile) + + +@pytest.mark.parametrize( + "app,cls", + [ + ("chrome", ChromeProfile), + ("chromium", ChromiumProfile), + ("firefox", FirefoxProfile), + ("thunderbird", ThunderbirdProfile), + ("unknown", None), + ], +) +def test_create_profile(tmpdir, app, cls): + path = tmpdir.strpath + + if cls is None: + with pytest.raises(NotImplementedError): + create_profile(app) + return + + profile = create_profile(app, profile=path) + assert isinstance(profile, BaseProfile) + assert profile.__class__ == cls + assert profile.profile == path + + +@pytest.mark.parametrize( + "cls", + [ + Profile, + ChromeProfile, + ChromiumProfile, + ], +) +def test_merge_profile(cls): + profile = cls(preferences={"foo": "bar"}) + assert profile._addons == [] + assert os.path.isfile( + os.path.join(profile.profile, profile.preference_file_names[0]) + ) + + other_profile = os.path.join(here, "files", "dummy-profile") + profile.merge(other_profile) + + # make sure to add a pref file for each preference_file_names in the dummy-profile + prefs = {} + for name in profile.preference_file_names: + path = os.path.join(profile.profile, name) + assert os.path.isfile(path) + + try: + prefs.update(Preferences.read_json(path)) + except ValueError: + prefs.update(Preferences.read_prefs(path)) + + assert "foo" in prefs + assert len(prefs) == len(profile.preference_file_names) + 1 + assert all(name in prefs for name in profile.preference_file_names) + + # for Google Chrome currently we ignore webext in profile prefs + if cls == Profile: + assert len(profile._addons) == 1 + assert profile._addons[0].endswith("empty.xpi") + assert os.path.exists(profile._addons[0]) + else: + assert len(profile._addons) == 0 + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozprofile/tests/test_profile_view.py b/testing/mozbase/mozprofile/tests/test_profile_view.py new file mode 100644 index 0000000000..67ad284298 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/test_profile_view.py @@ -0,0 +1,76 @@ +#!/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/. + +import os +import sys + +import mozprofile +import mozunit +import pytest +from six import text_type + +here = os.path.dirname(os.path.abspath(__file__)) + + +def test_profileprint(tmpdir): + """Test the summary function.""" + keys = set(["Files", "Path"]) + + tmpdir = tmpdir.strpath + profile = mozprofile.FirefoxProfile(tmpdir) + parts = profile.summary(return_parts=True) + parts = dict(parts) + + assert parts["Path"] == tmpdir + assert set(parts.keys()) == keys + + +def test_str_cast(): + """Test casting to a string.""" + profile = mozprofile.Profile() + if sys.version_info[0] >= 3: + assert str(profile) == profile.summary() + else: + assert str(profile) == profile.summary().encode("utf-8") + + +@pytest.mark.skipif( + sys.version_info[0] >= 3, reason="no unicode() operator starting from python3" +) +def test_unicode_cast(): + """Test casting to a unicode string.""" + profile = mozprofile.Profile() + assert text_type(profile) == profile.summary() + + +def test_profile_diff(): + profile1 = mozprofile.Profile() + profile2 = mozprofile.Profile(preferences=dict(foo="bar")) + + # diff a profile against itself; no difference + assert mozprofile.diff(profile1, profile1) == [] + + # diff two profiles + diff = dict(mozprofile.diff(profile1, profile2)) + assert list(diff.keys()) == ["user.js"] + lines = [line.strip() for line in diff["user.js"].splitlines()] + assert "+foo: bar" in lines + + # diff a blank vs FirefoxProfile + ff_profile = mozprofile.FirefoxProfile() + diff = dict(mozprofile.diff(profile2, ff_profile)) + assert list(diff.keys()) == ["user.js"] + lines = [line.strip() for line in diff["user.js"].splitlines()] + assert "-foo: bar" in lines + ff_pref_lines = [ + "+%s: %s" % (key, value) + for key, value in mozprofile.FirefoxProfile.preferences.items() + ] + assert set(ff_pref_lines).issubset(lines) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozprofile/tests/test_server_locations.py b/testing/mozbase/mozprofile/tests/test_server_locations.py new file mode 100644 index 0000000000..7eae8ac834 --- /dev/null +++ b/testing/mozbase/mozprofile/tests/test_server_locations.py @@ -0,0 +1,117 @@ +#!/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/. + +import mozunit +import pytest +from mozprofile.permissions import ( + BadPortLocationError, + LocationsSyntaxError, + MissingPrimaryLocationError, + MultiplePrimaryLocationsError, + ServerLocations, +) + +LOCATIONS = """# This is the primary location from which tests run. +# +http://mochi.test:8888 primary,privileged + +# a few test locations +http://127.0.0.1:80 privileged +http://127.0.0.1:8888 privileged +https://test:80 privileged +http://example.org:80 privileged +http://test1.example.org privileged + +""" + +LOCATIONS_NO_PRIMARY = """http://secondary.test:80 privileged +http://tertiary.test:8888 privileged +""" + +LOCATIONS_BAD_PORT = """http://mochi.test:8888 primary,privileged +http://127.0.0.1:80 privileged +http://127.0.0.1:8888 privileged +http://test:badport privileged +http://example.org:80 privileged +""" + + +def compare_location(location, scheme, host, port, options): + assert location.scheme == scheme + assert location.host == host + assert location.port == port + assert location.options == options + + +@pytest.fixture +def create_temp_file(tmpdir): + def inner(contents): + f = tmpdir.mkdtemp().join("locations.txt") + f.write(contents) + return f.strpath + + return inner + + +def test_server_locations(create_temp_file): + # write a permissions file + f = create_temp_file(LOCATIONS) + + # read the locations + locations = ServerLocations(f) + + # ensure that they're what we expect + assert len(locations) == 6 + i = iter(locations) + compare_location(next(i), "http", "mochi.test", "8888", ["primary", "privileged"]) + compare_location(next(i), "http", "127.0.0.1", "80", ["privileged"]) + compare_location(next(i), "http", "127.0.0.1", "8888", ["privileged"]) + compare_location(next(i), "https", "test", "80", ["privileged"]) + compare_location(next(i), "http", "example.org", "80", ["privileged"]) + compare_location(next(i), "http", "test1.example.org", "8888", ["privileged"]) + + locations.add_host("mozilla.org") + assert len(locations) == 7 + compare_location(next(i), "http", "mozilla.org", "80", ["privileged"]) + + # test some errors + with pytest.raises(MultiplePrimaryLocationsError): + locations.add_host("primary.test", options="primary") + + # assert we don't throw DuplicateLocationError + locations.add_host("127.0.0.1") + + with pytest.raises(BadPortLocationError): + locations.add_host("127.0.0.1", port="abc") + + # test some errors in locations file + f = create_temp_file(LOCATIONS_NO_PRIMARY) + + exc = None + try: + ServerLocations(f) + except LocationsSyntaxError as e: + exc = e + assert exc is not None + assert exc.err.__class__ == MissingPrimaryLocationError + assert exc.lineno == 3 + + # test bad port in a locations file to ensure lineno calculated + # properly. + f = create_temp_file(LOCATIONS_BAD_PORT) + + exc = None + try: + ServerLocations(f) + except LocationsSyntaxError as e: + exc = e + assert exc is not None + assert exc.err.__class__ == BadPortLocationError + assert exc.lineno == 4 + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozproxy/MANIFEST.in b/testing/mozbase/mozproxy/MANIFEST.in new file mode 100644 index 0000000000..c3527dc755 --- /dev/null +++ b/testing/mozbase/mozproxy/MANIFEST.in @@ -0,0 +1 @@ +recursive-include mozproxy * diff --git a/testing/mozbase/mozproxy/mozproxy/__init__.py b/testing/mozbase/mozproxy/mozproxy/__init__.py new file mode 100644 index 0000000000..5f7c1284d7 --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/__init__.py @@ -0,0 +1,44 @@ +# 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/. + +import os +import sys + + +def path_join(*args): + path = os.path.join(*args) + return os.path.abspath(path) + + +mozproxy_src_dir = os.path.dirname(os.path.realpath(__file__)) +mozproxy_dir = path_join(mozproxy_src_dir, "..") +mozbase_dir = path_join(mozproxy_dir, "..") + +# needed so unit tests can find their imports +if os.environ.get("SCRIPTSPATH", None) is not None: + # in production it is env SCRIPTS_PATH + mozharness_dir = os.environ["SCRIPTSPATH"] +else: + # locally it's in source tree + mozharness_dir = path_join(mozbase_dir, "..", "mozharness") + + +def get_playback(config): + """Returns an instance of the right Playback class""" + sys.path.insert(0, mozharness_dir) + sys.path.insert(0, mozproxy_dir) + sys.path.insert(0, mozproxy_src_dir) + + from .server import get_backend + from .utils import LOG + + tool_name = config.get("playback_tool", None) + if tool_name is None: + LOG.critical("playback_tool name not found in config") + return None + try: + return get_backend(tool_name, config) + except KeyError: + LOG.critical("specified playback tool is unsupported: %s" % tool_name) + return None diff --git a/testing/mozbase/mozproxy/mozproxy/__main__.py b/testing/mozbase/mozproxy/mozproxy/__main__.py new file mode 100644 index 0000000000..f908eae35d --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/__main__.py @@ -0,0 +1,10 @@ +# 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/. + +import sys + +from mozproxy.driver import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/testing/mozbase/mozproxy/mozproxy/backends/__init__.py b/testing/mozbase/mozproxy/mozproxy/backends/__init__.py new file mode 100644 index 0000000000..6fbe8159b2 --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/__init__.py @@ -0,0 +1,3 @@ +# 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/. diff --git a/testing/mozbase/mozproxy/mozproxy/backends/base.py b/testing/mozbase/mozproxy/mozproxy/backends/base.py new file mode 100644 index 0000000000..85a60e607b --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/base.py @@ -0,0 +1,32 @@ +# 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 abc import ABCMeta, abstractmethod + +import six + + +# abstract class for all playback tools +@six.add_metaclass(ABCMeta) +class Playback(object): + def __init__(self, config): + self.config = config + self.host = None + self.port = None + + @abstractmethod + def download(self): + pass + + @abstractmethod + def setup(self): + pass + + @abstractmethod + def start(self): + pass + + @abstractmethod + def stop(self): + pass diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/__init__.py b/testing/mozbase/mozproxy/mozproxy/backends/mitm/__init__.py new file mode 100644 index 0000000000..be9b446972 --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/__init__.py @@ -0,0 +1,6 @@ +# flake8: noqa +# 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 .mitm import * diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/android.py b/testing/mozbase/mozproxy/mozproxy/backends/mitm/android.py new file mode 100644 index 0000000000..d13ba479f6 --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/android.py @@ -0,0 +1,245 @@ +"""Functions to download, install, setup, and use the mitmproxy playback tool""" +# 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/. +import glob +import os +import shutil +import subprocess +import sys +import tempfile +from subprocess import PIPE + +import mozinfo + +from mozproxy.backends.mitm.mitm import Mitmproxy +from mozproxy.utils import LOG, download_file_from_url, tooltool_download + +# path for mitmproxy certificate, generated auto after mitmdump is started +# on local machine it is 'HOME', however it is different on production machines +try: + DEFAULT_CERT_PATH = os.path.join( + os.getenv("HOME"), ".mitmproxy", "mitmproxy-ca-cert.cer" + ) +except Exception: + DEFAULT_CERT_PATH = os.path.join( + os.getenv("HOMEDRIVE"), + os.getenv("HOMEPATH"), + ".mitmproxy", + "mitmproxy-ca-cert.cer", + ) + +# On Windows, deal with mozilla-build having forward slashes in $HOME: +if os.name == "nt" and "/" in DEFAULT_CERT_PATH: + DEFAULT_CERT_PATH = DEFAULT_CERT_PATH.replace("/", "\\") + +FIREFOX_ANDROID_APPS = [ + "geckoview", + "refbrow", + "fenix", +] + + +class MitmproxyAndroid(Mitmproxy): + def setup(self): + self.download_and_install_host_utils() + self.install_mitmproxy_cert() + + def download_and_install_host_utils(self): + """ + If running locally: + 1. Will use the `certutil` tool from the local Firefox desktop build + + If running in production: + 1. Get the tooltools manifest file for downloading hostutils (contains certutil) + 2. Get the `certutil` tool by downloading hostutils using the tooltool manifest + """ + if self.config["run_local"]: + # when running locally, it is found in the Firefox desktop build (..obj../dist/bin) + self.certutil_path = os.path.join(os.environ["MOZ_HOST_BIN"], "certutil") + if not ( + os.path.isfile(self.certutil_path) + and os.access(self.certutil_path, os.X_OK) + ): + raise Exception( + "Abort: unable to execute certutil: {}".format(self.certutil_path) + ) + self.certutil_path = os.environ["MOZ_HOST_BIN"] + os.environ["LD_LIBRARY_PATH"] = self.certutil_path + else: + # must download certutil inside hostutils via tooltool; use this manifest: + # mozilla-central/testing/config/tooltool-manifests/linux64/hostutils.manifest + # after it will be found here inside the worker/bitbar container: + # /builds/worker/workspace/build/hostutils/host-utils-66.0a1.en-US.linux-x86_64 + LOG.info("downloading certutil binary (hostutils)") + + # get path to the hostutils tooltool manifest; was set earlier in + # mozharness/configs/raptor/android_hw_config.py, to the path i.e. + # mozilla-central/testing/config/tooltool-manifests/linux64/hostutils.manifest + # the bitbar container is always linux64 + if os.environ.get("GECKO_HEAD_REPOSITORY", None) is None: + raise Exception("Abort: unable to get GECKO_HEAD_REPOSITORY") + + if os.environ.get("GECKO_HEAD_REV", None) is None: + raise Exception("Abort: unable to get GECKO_HEAD_REV") + + if os.environ.get("HOSTUTILS_MANIFEST_PATH", None) is not None: + manifest_url = os.path.join( + os.environ["GECKO_HEAD_REPOSITORY"], + "raw-file", + os.environ["GECKO_HEAD_REV"], + os.environ["HOSTUTILS_MANIFEST_PATH"], + ) + else: + raise Exception("Abort: unable to get HOSTUTILS_MANIFEST_PATH!") + + # first need to download the hostutils tooltool manifest file itself + _dest = os.path.join(self.mozproxy_dir, "hostutils.manifest") + have_manifest = download_file_from_url(manifest_url, _dest) + if not have_manifest: + raise Exception("failed to download the hostutils tooltool manifest") + + # now use the manifest to download hostutils so we can get certutil + tooltool_download(_dest, self.config["run_local"], self.mozproxy_dir) + + # the production bitbar container host is always linux + self.certutil_path = glob.glob( + os.path.join(self.mozproxy_dir, "host-utils*[!z|checksum]") + )[0] + + # must add hostutils/certutil to the path + os.environ["LD_LIBRARY_PATH"] = self.certutil_path + + bin_suffix = mozinfo.info.get("bin_suffix", "") + self.certutil_path = os.path.join(self.certutil_path, "certutil" + bin_suffix) + if os.path.isfile(self.certutil_path): + LOG.info("certutil is found at: %s" % self.certutil_path) + else: + raise Exception("unable to find certutil at %s" % self.certutil_path) + + def install_mitmproxy_cert(self): + """Install the CA certificate generated by mitmproxy, into geckoview android + + 1. Create an NSS certificate database in the geckoview browser profile dir, only + if it doesn't already exist. Use this certutil command: + `certutil -N -d sql:<path to profile> --empty-password` + 2. Import the mitmproxy certificate into the database, i.e.: + `certutil -A -d sql:<path to profile> -n "some nickname" -t TC,, -a -i <path to CA.pem>` + """ + # If the app isn't in FIREFOX_ANDROID_APPS then we have to create the tempdir + # because we don't have it available in the local_profile_dir + if self.config["app"] in FIREFOX_ANDROID_APPS: + tempdir = self.config["local_profile_dir"] + else: + tempdir = tempfile.mkdtemp() + cert_db_location = "sql:%s/" % tempdir + + if not self.cert_db_exists(cert_db_location): + self.create_cert_db(cert_db_location) + + # DEFAULT_CERT_PATH has local path and name of mitmproxy cert i.e. + # /home/cltbld/.mitmproxy/mitmproxy-ca-cert.cer + self.import_certificate_in_cert_db(cert_db_location, DEFAULT_CERT_PATH) + + # cannot continue if failed to add CA cert to Firefox, need to check + if not self.is_mitmproxy_cert_installed(cert_db_location): + LOG.error("Aborting: failed to install mitmproxy CA cert into Firefox") + self.stop_mitmproxy_playback() + sys.exit() + if self.config["app"] not in FIREFOX_ANDROID_APPS: + try: + shutil.rmtree(tempdir) + except Exception: + LOG.warning("unable to remove directory: %s" % tempdir) + + def import_certificate_in_cert_db(self, cert_db_location, local_cert_path): + # import mitmproxy cert into the db + args = [ + "-A", + "-d", + cert_db_location, + "-n", + "mitmproxy-cert", + "-t", + "TC,,", + "-a", + "-i", + local_cert_path, + ] + LOG.info("importing mitmproxy cert into db using command") + self.certutil(args) + + def create_cert_db(self, cert_db_location): + # create cert db if it doesn't already exist; it may exist already + # if a previous pageload test ran in the same test suite + args = ["-d", cert_db_location, "-N", "--empty-password"] + + LOG.info("creating nss cert database") + + self.certutil(args) + + if not self.cert_db_exists(cert_db_location): + raise Exception("nss cert db creation command failed. Cert db not created.") + + def cert_db_exists(self, cert_db_location): + # check if the nss ca cert db already exists in the device profile + LOG.info( + "checking if the nss cert db already exists in the android browser profile" + ) + + args = ["-d", cert_db_location, "-L"] + cert_db_exists = self.certutil(args, raise_exception=False) + + if cert_db_exists: + LOG.info("the nss cert db exists") + return True + else: + LOG.info("nss cert db doesn't exist yet.") + return False + + def is_mitmproxy_cert_installed(self, cert_db_location): + """Verify mitmxproy CA cert was added to Firefox on android""" + LOG.info("verifying that the mitmproxy ca cert is installed on android") + + # list the certifcates that are in the nss cert db (inside the browser profile dir) + LOG.info( + "getting the list of certs in the nss cert db in the android browser profile" + ) + args = ["-d", cert_db_location, "-L"] + + cmd_output = self.certutil(args) + + if "mitmproxy-cert" in cmd_output.decode("utf-8"): + LOG.info( + "verfied the mitmproxy-cert is installed in the nss cert db on android" + ) + return True + return False + + def certutil(self, args, raise_exception=True): + cmd = [self.certutil_path] + list(args) + LOG.info("Certutil: Running command: %s" % " ".join(cmd)) + try: + cmd_proc = subprocess.Popen( + cmd, stdout=PIPE, stderr=PIPE, env=os.environ.copy() + ) + + cmd_output, errs = cmd_proc.communicate() + except subprocess.SubprocessError: + LOG.critical("could not run the certutil command") + raise + + if cmd_proc.returncode == 0: + # Debug purpose only remove if stable + LOG.info("Certutil returncode: %s" % cmd_proc.returncode) + LOG.info("Certutil output: %s" % cmd_output) + return cmd_output + else: + if raise_exception: + LOG.critical("Certutil command failed!!") + LOG.info("Certutil returncode: %s" % cmd_proc.returncode) + LOG.info("Certutil output: %s" % cmd_output) + LOG.info("Certutil error: %s" % errs) + raise Exception("Certutil command failed!!") + else: + return False diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/desktop.py b/testing/mozbase/mozproxy/mozproxy/backends/mitm/desktop.py new file mode 100644 index 0000000000..8cb9c496d2 --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/desktop.py @@ -0,0 +1,160 @@ +"""Functions to download, install, setup, and use the mitmproxy playback tool""" +# 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/. +import os +import sys + +import mozinfo + +from mozproxy.backends.mitm.mitm import Mitmproxy +from mozproxy.utils import LOG + +# to install mitmproxy certificate into Firefox and turn on/off proxy +POLICIES_CONTENT_ON = """{ + "policies": { + "Certificates": { + "Install": ["%(cert)s"] + }, + "Proxy": { + "Mode": "manual", + "HTTPProxy": "%(host)s:%(port)d", + "SSLProxy": "%(host)s:%(port)d", + "Passthrough": "%(host)s", + "Locked": true + } + } +}""" + +POLICIES_CONTENT_OFF = """{ + "policies": { + "Proxy": { + "Mode": "none", + "Locked": false + } + } +}""" + +# path for mitmproxy certificate, generated auto after mitmdump is started +# on local machine it is 'HOME', however it is different on production machines +try: + DEFAULT_CERT_PATH = os.path.join( + os.getenv("HOME"), ".mitmproxy", "mitmproxy-ca-cert.cer" + ) +except Exception: + DEFAULT_CERT_PATH = os.path.join( + os.getenv("HOMEDRIVE"), + os.getenv("HOMEPATH"), + ".mitmproxy", + "mitmproxy-ca-cert.cer", + ) + +# On Windows, deal with mozilla-build having forward slashes in $HOME: +if os.name == "nt" and "/" in DEFAULT_CERT_PATH: + DEFAULT_CERT_PATH = DEFAULT_CERT_PATH.replace("/", "\\") + + +class MitmproxyDesktop(Mitmproxy): + def setup(self): + """ + Installs certificates. + + For Firefox we need to install the generated mitmproxy CA cert. For + Chromium this is not necessary as it will be started with the + --ignore-certificate-errors cmd line arg. + """ + if not self.config["app"] == "firefox": + return + # install the generated CA certificate into Firefox desktop + self.install_mitmproxy_cert(self.browser_path) + + def install_mitmproxy_cert(self, browser_path): + """Install the CA certificate generated by mitmproxy, into Firefox + 1. Create a dir called 'distribution' in the same directory as the Firefox executable + 2. Create the policies.json file inside that folder; which points to the certificate + location, and turns on the the browser proxy settings + """ + LOG.info("Installing mitmproxy CA certificate into Firefox") + + # browser_path is the exe, we want the folder + self.policies_dir = os.path.dirname(browser_path) + # on macosx we need to remove the last folders 'MacOS' + # and the policies json needs to go in ../Content/Resources/ + if "mac" in self.config["platform"]: + self.policies_dir = os.path.join(self.policies_dir[:-6], "Resources") + # for all platforms the policies json goes in a 'distribution' dir + self.policies_dir = os.path.join(self.policies_dir, "distribution") + + self.cert_path = DEFAULT_CERT_PATH + # for windows only + if mozinfo.os == "win": + self.cert_path = self.cert_path.replace("\\", "\\\\") + + if not os.path.exists(self.policies_dir): + LOG.info("creating folder: %s" % self.policies_dir) + os.makedirs(self.policies_dir) + else: + LOG.info("folder already exists: %s" % self.policies_dir) + + self.write_policies_json( + self.policies_dir, + policies_content=POLICIES_CONTENT_ON + % {"cert": self.cert_path, "host": self.host, "port": self.port}, + ) + + # cannot continue if failed to add CA cert to Firefox, need to check + if not self.is_mitmproxy_cert_installed(): + LOG.error( + "Aborting: failed to install mitmproxy CA cert into Firefox desktop" + ) + self.stop_mitmproxy_playback() + sys.exit() + + def write_policies_json(self, location, policies_content): + policies_file = os.path.join(location, "policies.json") + LOG.info("writing: %s" % policies_file) + + with open(policies_file, "w") as fd: + fd.write(policies_content) + + def read_policies_json(self, location): + policies_file = os.path.join(location, "policies.json") + LOG.info("reading: %s" % policies_file) + + with open(policies_file, "r") as fd: + return fd.read() + + def is_mitmproxy_cert_installed(self): + """Verify mitmxproy CA cert was added to Firefox""" + try: + # read autoconfig file, confirm mitmproxy cert is in there + contents = self.read_policies_json(self.policies_dir) + LOG.info("Firefox policies file contents:") + LOG.info(contents) + if ( + POLICIES_CONTENT_ON + % {"cert": self.cert_path, "host": self.host, "port": self.port} + ) in contents: + LOG.info("Verified mitmproxy CA certificate is installed in Firefox") + else: + return False + except Exception as e: + LOG.info("failed to read Firefox policies file, exeption: %s" % e) + return False + return True + + def stop(self): + LOG.info("MitmproxyDesktop stop!!") + super(MitmproxyDesktop, self).stop() + self.turn_off_browser_proxy() + + def turn_off_browser_proxy(self): + """Turn off the browser proxy that was used for mitmproxy playback. In Firefox + we need to change the autoconfig files to revert the proxy; for Chromium the proxy + was setup on the cmd line, so nothing is required here.""" + if self.config["app"] == "firefox" and self.policies_dir is not None: + LOG.info("Turning off the browser proxy") + + self.write_policies_json( + self.policies_dir, policies_content=POLICIES_CONTENT_OFF + ) diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-4.0.4-linux64.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-4.0.4-linux64.manifest new file mode 100644 index 0000000000..24419f001d --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-4.0.4-linux64.manifest @@ -0,0 +1,10 @@ +[ + { + "size": 21805767, + "visibility": "public", + "digest": "69b314f9189b1c09e5412680869c03d07510b98b34e309f0f1b6961e5d8963f935994fe2f5ca86827f08631cbb271292a72aba2f0973b144832c2e7049a464a8", + "algorithm": "sha512", + "filename": "mitmproxy-4.0.4-linux.tar.gz", + "unpack": true + } +] diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-4.0.4-osx.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-4.0.4-osx.manifest new file mode 100644 index 0000000000..b809e5bbdb --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-4.0.4-osx.manifest @@ -0,0 +1,10 @@ +[ + { + "size": 36939029, + "visibility": "public", + "digest": "3f933b142d7afb7cd409f436bc1151cc44c6f207368644ebe9fc9015607448b2c2d52dad4e5c71595bb6aa8accf4869e534f8db0ff6144725fad8f98daf50b40", + "algorithm": "sha512", + "filename": "mitmproxy-4.0.4-osx.tar.gz", + "unpack": true + } +] diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-4.0.4-win.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-4.0.4-win.manifest new file mode 100644 index 0000000000..f090ca3a6f --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-4.0.4-win.manifest @@ -0,0 +1,10 @@ +[ + { + "size": 35903223, + "visibility": "public", + "digest": "cf5a2bd056ae655d4db0a953ec1bf80229990f449672ae9b636568540fae192a1c361c7742f1e2a8b560426a11e69955358b6a37445f37117dfcac154ef84713", + "algorithm": "sha512", + "filename": "mitmproxy-4.0.4-windows.zip", + "unpack": true + } +] diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.0.1-linux64.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.0.1-linux64.manifest new file mode 100644 index 0000000000..cfbbd667fd --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.0.1-linux64.manifest @@ -0,0 +1,10 @@ +[ + { + "size": 76345093, + "visibility": "public", + "digest": "4d653c0c74a8677e8e78cd72d109b1b54c75ef57b2e2ce980c5cfd602966c310065cf0e95c35f4fbfb1fe817f062ac6cf9cc129d72d42184b0237fb9b0bde081", + "algorithm": "sha512", + "filename": "mitmproxy-5.0.1-linux.tar.gz", + "unpack": true + } +] diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.0.1-osx.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.0.1-osx.manifest new file mode 100644 index 0000000000..2a97c84eed --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.0.1-osx.manifest @@ -0,0 +1,10 @@ +[ + { + "size": 41341221, + "visibility": "public", + "digest": "4624bc26638cd7f3ab8c8d2ee8ab44ab81018114714a2b82614883adbfad540de73a97455fb228a3003006b4d03a8a9a597d70f8b27ccf1718f78380f911bdd8", + "algorithm": "sha512", + "filename": "mitmproxy-5.0.1-osx.tar.gz", + "unpack": true + } +] diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.0.1-win.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.0.1-win.manifest new file mode 100644 index 0000000000..2d7d60715a --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.0.1-win.manifest @@ -0,0 +1,10 @@ +[ + { + "size": 37875625, + "visibility": "public", + "digest": "d66234c9ca692d03412dd194b3f47c098e8ee1b17b178034fc86e0d8ada4d4f6cd1fbcfb62b7e7016539a878b1274ef83451a0acaca7011efaacb291fa52918d", + "algorithm": "sha512", + "filename": "mitmproxy-5.0.1-windows.zip", + "unpack": true + } +] diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.1.1-linux64.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.1.1-linux64.manifest new file mode 100644 index 0000000000..3022d190bb --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.1.1-linux64.manifest @@ -0,0 +1,10 @@ +[ + { + "size": 24989250, + "visibility": "public", + "digest": "75ea9d024cc9138e5bc993c292ec65c2a40ce4e382f01c41ef865ddfac6f1553ee0e73e3ff2003234d6ecab2d3cdbbd38809cc7e62bbcabaed521a623575e2b8", + "algorithm": "sha512", + "filename": "mitmproxy-5.1.1-linux.tar.gz", + "unpack": true + } +] diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.1.1-osx.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.1.1-osx.manifest new file mode 100644 index 0000000000..a039c71d27 --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.1.1-osx.manifest @@ -0,0 +1,10 @@ +[ + { + "size": 13178683, + "visibility": "public", + "digest": "efbc90674a85165e9065e70a8f0c2d4e42688a045130546c2d5721cdb4c54637f7d2b9f4e6a6953a37f94d0edb5bf128c25d7a628246a75d4ad7ba6c884589a9", + "algorithm": "sha512", + "filename": "mitmproxy-5.1.1-osx.tar.gz", + "unpack": true + } +] diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.1.1-win.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.1.1-win.manifest new file mode 100644 index 0000000000..d0937a1bed --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.1.1-win.manifest @@ -0,0 +1,10 @@ +[ + { + "size": 18019253, + "visibility": "public", + "digest": "69470f6a58c50912072ab6a911ac3e266d5ec8c9410c8aa4bad76cd7f61bca640e748fd682379ef1523f12da2a8c7a9c67d5bbae5f6d6fa164c2c6b9765b79c1", + "algorithm": "sha512", + "filename": "mitmproxy-5.1.1-windows.zip", + "unpack": true + } +] diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-6.0.2-linux64.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-6.0.2-linux64.manifest new file mode 100644 index 0000000000..fcdb65c9b7 --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-6.0.2-linux64.manifest @@ -0,0 +1,10 @@ +[ + { + "filename": "mitmproxy-6.0.2-linux.tar.gz", + "size": 73691215, + "algorithm": "sha512", + "digest": "e7cbcdee1eda9f7c9ea9b6c811930a7b21403f52b24a1f14030a69a4ee62dd6bf9fa5a0a9f8acc44830803da27b856f0900a09fd560b32384ab2c0e78a1d08ad", + "visibility": "public", + "unpack": true + } +] diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-6.0.2-osx.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-6.0.2-osx.manifest new file mode 100644 index 0000000000..5e48ba3405 --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-6.0.2-osx.manifest @@ -0,0 +1,10 @@ +[ + { + "filename": "mitmproxy-6.0.2-osx.tar.gz", + "size": 40867811, + "algorithm": "sha512", + "digest": "f162e89073eb73f7cfe458d54157e31e9e6bb7ae42c262e4070d23949888630015c896de8870958e83ad9360fce6758f01813ce581cb1a3c1a8b436109d2f28d", + "visibility": "public", + "unpack": true + } +] diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-6.0.2-win.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-6.0.2-win.manifest new file mode 100644 index 0000000000..e5060cbf28 --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-6.0.2-win.manifest @@ -0,0 +1,10 @@ +[ + { + "filename": "mitmproxy-6.0.2-windows.zip", + "size": 36482912, + "algorithm": "sha512", + "digest": "242701b5090fe71526ac887843ca08a674a5b1261c21f9f8cb86d143b16b4bc50fca80b016b140a4c0fd2c6ec5819aee1e145a57b000a293fe290ba1b21bac9f", + "visibility": "public", + "unpack": true + } +] diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-7.0.4-linux64.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-7.0.4-linux64.manifest new file mode 100644 index 0000000000..5d1b28d7eb --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-7.0.4-linux64.manifest @@ -0,0 +1,10 @@ +[ + { + "filename": "mitmproxy-7.0.4-linux.tar.gz", + "size": 76230418, + "algorithm": "sha512", + "digest": "fe572230ecb512dc4a162804bd36b6535ea9f85ac8f40ebb076fe0a6d4eb03c3fed1a85bc6f56de964218059961bec46ddf502d7da025c2744e66d0acd597852", + "visibility": "public", + "unpack": true + } +] diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-7.0.4-osx.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-7.0.4-osx.manifest new file mode 100644 index 0000000000..5ddb6ad4e5 --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-7.0.4-osx.manifest @@ -0,0 +1,10 @@ +[ + { + "filename": "mitmproxy-7.0.4-osx.tar.gz", + "size": 42334827, + "algorithm": "sha512", + "digest": "d9302399b9c6ac65bea0913aa0830d46c3b09e7c1ddc4bbfd375387b58f5fefaa72550656ae936137ad4ed841a327e889a0f4ee72e548427665615097d20d467", + "visibility": "public", + "unpack": true + } +] diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-7.0.4-win.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-7.0.4-win.manifest new file mode 100644 index 0000000000..5497ea14c2 --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-7.0.4-win.manifest @@ -0,0 +1,10 @@ +[ + { + "filename": "mitmproxy-7.0.4-windows.zip", + "size": 55150536, + "algorithm": "sha512", + "digest": "fb89dc5f0781f56741d4e288bef62f7c9b301d96a9258fef7ea467d51b1533f2caa13fe9ac382eaf69770d40399336dca004506f7d55db86afd053e7432e85ae", + "visibility": "public", + "unpack": true + } +] diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-8.1.1-linux64.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-8.1.1-linux64.manifest new file mode 100644 index 0000000000..721cc4fc37 --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-8.1.1-linux64.manifest @@ -0,0 +1,10 @@ +[ + { + "filename": "mitmproxy-8.1.1-linux.tar.gz", + "size": 90353267, + "algorithm": "sha512", + "digest": "d6d99dca35dc5797549e58f872067ba6a1bff9903db880eabb9993a224e35fba26a001c0665ce7a9983f1b2212aa9c83646a58e6000c5415aea731fa1fe7f46d", + "unpack": true, + "visibility": "public" + } +] diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-8.1.1-osx.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-8.1.1-osx.manifest new file mode 100644 index 0000000000..91558159a3 --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-8.1.1-osx.manifest @@ -0,0 +1,10 @@ +[ + { + "filename": "mitmproxy-8.1.1-osx.tar.gz", + "size": 45230764, + "algorithm": "sha512", + "digest": "5ce3893a295c211a05d106f434fa6876dc9d32777c5d6bf1c04aff6ca530fe2dae2140db28b974917c92a4d42892591cf734f6c564ad8ff9ed6093e5d90ee737", + "unpack": true, + "visibility": "public" + } +] diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-8.1.1-win.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-8.1.1-win.manifest new file mode 100644 index 0000000000..84dfaea58e --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-8.1.1-win.manifest @@ -0,0 +1,10 @@ +[ + { + "filename": "mitmproxy-8.1.1-windows.zip", + "size": 58309424, + "algorithm": "sha512", + "digest": "9f3e73cc95573c85c3ed9b43f25dee9f07d16ee937a8aaad10a6c445584f3d7e3592bdf312b048baae1b30dc83e1f391ad692b5c35d959423b1e861e85576eab", + "unpack": true, + "visibility": "public" + } +] diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/mitm.py b/testing/mozbase/mozproxy/mozproxy/backends/mitm/mitm.py new file mode 100644 index 0000000000..5ef19282df --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/mitm.py @@ -0,0 +1,454 @@ +# 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/. +import json +import os +import signal +import socket +import sys +import time + +import mozinfo +import six +from mozprocess import ProcessHandler + +from mozproxy.backends.base import Playback +from mozproxy.recordings import RecordingFile +from mozproxy.utils import ( + LOG, + download_file_from_url, + get_available_port, + tooltool_download, + transform_platform, +) + +here = os.path.dirname(__file__) +mitm_folder = os.path.dirname(os.path.realpath(__file__)) + +# maximal allowed runtime of a mitmproxy command +MITMDUMP_COMMAND_TIMEOUT = 30 + + +class Mitmproxy(Playback): + def __init__(self, config): + self.config = config + + self.host = ( + "127.0.0.1" if "localhost" in self.config["host"] else self.config["host"] + ) + self.port = None + self.mitmproxy_proc = None + self.mitmdump_path = None + self.record_mode = config.get("record", False) + self.recording = None + self.playback_files = [] + + self.browser_path = "" + if config.get("binary", None): + self.browser_path = os.path.normpath(config.get("binary")) + + self.policies_dir = None + self.ignore_mitmdump_exit_failure = config.get( + "ignore_mitmdump_exit_failure", False + ) + + if self.record_mode: + if "recording_file" not in self.config: + LOG.error( + "recording_file value was not provided. Proxy service wont' start " + ) + raise Exception("Please provide a playback_files list.") + + if not isinstance(self.config.get("recording_file"), six.string_types): + LOG.error("recording_file argument type is not str!") + raise Exception("recording_file argument type invalid!") + + if not os.path.splitext(self.config.get("recording_file"))[1] == ".zip": + LOG.error( + "Recording file type (%s) should be a zip. " + "Please provide a valid file type!" + % self.config.get("recording_file") + ) + raise Exception("Recording file type should be a zip") + + if os.path.exists(self.config.get("recording_file")): + LOG.error( + "Recording file (%s) already exists." + "Please provide a valid file path!" + % self.config.get("recording_file") + ) + raise Exception("Recording file already exists.") + + if self.config.get("playback_files", False): + LOG.error("Record mode is True and playback_files where provided!") + raise Exception("playback_files specified during record!") + + if self.config.get("playback_version") is None: + LOG.error( + "mitmproxy was not provided with a 'playback_version' " + "Please provide a valid playback version" + ) + raise Exception("playback_version not specified!") + + # mozproxy_dir is where we will download all mitmproxy required files + # when running locally it comes from obj_path via mozharness/mach + if self.config.get("obj_path") is not None: + self.mozproxy_dir = self.config.get("obj_path") + else: + # in production it is ../tasks/task_N/build/, in production that dir + # is not available as an envvar, however MOZ_UPLOAD_DIR is set as + # ../tasks/task_N/build/blobber_upload_dir so take that and go up 1 level + self.mozproxy_dir = os.path.dirname( + os.path.dirname(os.environ["MOZ_UPLOAD_DIR"]) + ) + + self.mozproxy_dir = os.path.join(self.mozproxy_dir, "testing", "mozproxy") + self.upload_dir = os.environ.get("MOZ_UPLOAD_DIR", self.mozproxy_dir) + + LOG.info( + "mozproxy_dir used for mitmproxy downloads and exe files: %s" + % self.mozproxy_dir + ) + # setting up the MOZPROXY_DIR env variable so custom scripts know + # where to get the data + os.environ["MOZPROXY_DIR"] = self.mozproxy_dir + + LOG.info("Playback tool: %s" % self.config["playback_tool"]) + LOG.info("Playback tool version: %s" % self.config["playback_version"]) + + def download_mitm_bin(self): + # Download and setup mitm binaries + + manifest = os.path.join( + here, + "manifests", + "mitmproxy-rel-bin-%s-{platform}.manifest" + % self.config["playback_version"], + ) + transformed_manifest = transform_platform(manifest, self.config["platform"]) + + # generate the mitmdump_path + self.mitmdump_path = os.path.normpath( + os.path.join( + self.mozproxy_dir, + "mitmdump-%s" % self.config["playback_version"], + "mitmdump", + ) + ) + + # Check if mitmproxy bin exists + if os.path.exists(self.mitmdump_path): + LOG.info("mitmproxy binary already exists. Skipping download") + else: + # Download and unpack mitmproxy binary + download_path = os.path.dirname(self.mitmdump_path) + LOG.info("create mitmproxy %s dir" % self.config["playback_version"]) + if not os.path.exists(download_path): + os.makedirs(download_path) + + LOG.info("downloading mitmproxy binary") + tooltool_download( + transformed_manifest, self.config["run_local"], download_path + ) + + def download_manifest_file(self, manifest_path): + # Manifest File + # we use one pageset for all platforms + LOG.info("downloading mitmproxy pageset") + + tooltool_download(manifest_path, self.config["run_local"], self.mozproxy_dir) + + with open(manifest_path) as manifest_file: + manifest = json.load(manifest_file) + for file in manifest: + zip_path = os.path.join(self.mozproxy_dir, file["filename"]) + LOG.info("Adding %s to recording list" % zip_path) + self.playback_files.append(RecordingFile(zip_path)) + + def download_playback_files(self): + # Detect type of file from playback_files and download accordingly + if "playback_files" not in self.config: + LOG.error( + "playback_files value was not provided. Proxy service wont' start " + ) + raise Exception("Please provide a playback_files list.") + + if not isinstance(self.config["playback_files"], list): + LOG.error("playback_files should be a list") + raise Exception("playback_files should be a list") + + for playback_file in self.config["playback_files"]: + if playback_file.startswith("https://") and "mozilla.com" in playback_file: + # URL provided + dest = os.path.join(self.mozproxy_dir, os.path.basename(playback_file)) + download_file_from_url(playback_file, self.mozproxy_dir, extract=False) + # Add Downloaded file to playback_files list + LOG.info("Adding %s to recording list" % dest) + self.playback_files.append(RecordingFile(dest)) + continue + + if not os.path.exists(playback_file): + LOG.error( + "Zip or manifest file path (%s) does not exist. Please provide a valid path!" + % playback_file + ) + raise Exception("Zip or manifest file path does not exist") + + if os.path.splitext(playback_file)[1] == ".zip": + # zip file path provided + LOG.info("Adding %s to recording list" % playback_file) + self.playback_files.append(RecordingFile(playback_file)) + elif os.path.splitext(playback_file)[1] == ".manifest": + # manifest file path provided + self.download_manifest_file(playback_file) + + def download(self): + """Download and unpack mitmproxy binary and pageset using tooltool""" + if not os.path.exists(self.mozproxy_dir): + os.makedirs(self.mozproxy_dir) + + self.download_mitm_bin() + + if self.record_mode: + self.recording = RecordingFile(self.config["recording_file"]) + else: + self.download_playback_files() + + def stop(self): + LOG.info("Mitmproxy stop!!") + self.stop_mitmproxy_playback() + if self.record_mode: + LOG.info("Record mode ON. Generating zip file ") + self.recording.generate_zip_file() + + def wait(self, timeout=1): + """Wait until the mitmproxy process has terminated.""" + # We wait using this method to allow Windows to respond to the Ctrl+Break + # signal so that we can exit cleanly from the command-line driver. + while True: + returncode = self.mitmproxy_proc.wait(timeout) + if returncode is not None: + return returncode + + def start(self): + # go ahead and download and setup mitmproxy + self.download() + + # mitmproxy must be started before setup, so that the CA cert is available + self.start_mitmproxy(self.mitmdump_path, self.browser_path) + + # In case the setup fails, we want to stop the process before raising. + try: + self.setup() + except Exception: + try: + self.stop() + except Exception: + LOG.error("MitmProxy failed to STOP.", exc_info=True) + LOG.error("Setup of MitmProxy failed.", exc_info=True) + raise + + def start_mitmproxy(self, mitmdump_path, browser_path): + """Startup mitmproxy and replay the specified flow file""" + if self.mitmproxy_proc is not None: + raise Exception("Proxy already started.") + self.port = get_available_port() + + LOG.info("mitmdump path: %s" % mitmdump_path) + LOG.info("browser path: %s" % browser_path) + + # mitmproxy needs some DLL's that are a part of Firefox itself, so add to path + env = os.environ.copy() + env["PATH"] = os.path.dirname(browser_path) + os.pathsep + env["PATH"] + command = [mitmdump_path] + + if self.config.get("verbose", False): + # Generate mitmproxy verbose logs + command.extend(["-v"]) + # add proxy host and port options + command.extend(["--listen-host", self.host, "--listen-port", str(self.port)]) + + # record mode + if self.record_mode: + # generate recording script paths + + command.extend( + [ + "--save-stream-file", + os.path.normpath(self.recording.recording_path), + "--set", + "websocket=false", + ] + ) + if "inject_deterministic" in self.config.keys(): + command.extend( + [ + "--scripts", + os.path.join(mitm_folder, "scripts", "inject-deterministic.py"), + ] + ) + self.recording.set_metadata( + "proxy_version", self.config["playback_version"] + ) + else: + # playback mode + if len(self.playback_files) > 0: + if self.config["playback_version"] == "8.1.1": + command.extend( + [ + "--set", + "websocket=false", + "--set", + "connection_strategy=lazy", + "--set", + "alt_server_replay_nopop=true", + "--set", + "alt_server_replay_kill_extra=true", + "--set", + "alt_server_replay_order_reversed=true", + "--set", + "alt_server_replay={}".format( + ",".join( + [ + os.path.normpath(playback_file.recording_path) + for playback_file in self.playback_files + ] + ) + ), + "--scripts", + os.path.normpath( + os.path.join( + mitm_folder, "scripts", "alt-serverplayback.py" + ) + ), + ] + ) + elif self.config["playback_version"] in [ + "4.0.4", + "5.1.1", + "6.0.2", + ]: + command.extend( + [ + "--set", + "upstream_cert=false", + "--set", + "upload_dir=" + os.path.normpath(self.upload_dir), + "--set", + "websocket=false", + "--set", + "server_replay_files={}".format( + ",".join( + [ + os.path.normpath(playback_file.recording_path) + for playback_file in self.playback_files + ] + ) + ), + "--scripts", + os.path.normpath( + os.path.join( + mitm_folder, "scripts", "alternate-server-replay.py" + ) + ), + ] + ) + else: + raise Exception("Mitmproxy version is unknown!") + + else: + raise Exception( + "Mitmproxy can't start playback! Playback settings missing." + ) + + # mitmproxy needs some DLL's that are a part of Firefox itself, so add to path + env = os.environ.copy() + if not os.path.dirname(self.browser_path) in env["PATH"]: + env["PATH"] = os.path.dirname(self.browser_path) + os.pathsep + env["PATH"] + + LOG.info("Starting mitmproxy playback using env path: %s" % env["PATH"]) + LOG.info("Starting mitmproxy playback using command: %s" % " ".join(command)) + # to turn off mitmproxy log output, use these params for Popen: + # Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + self.mitmproxy_proc = ProcessHandler( + command, + logfile=os.path.join(self.upload_dir, "mitmproxy.log"), + env=env, + storeOutput=False, + ) + self.mitmproxy_proc.run() + end_time = time.time() + MITMDUMP_COMMAND_TIMEOUT + + ready = False + while time.time() < end_time: + ready = self.check_proxy(host=self.host, port=self.port) + if ready: + LOG.info( + "Mitmproxy playback successfully started on %s:%d as pid %d" + % (self.host, self.port, self.mitmproxy_proc.pid) + ) + return + time.sleep(0.25) + + # cannot continue as we won't be able to playback the pages + LOG.error("Aborting: Mitmproxy process did not startup") + self.stop_mitmproxy_playback() + sys.exit(1) # XXX why do we need to do that? a raise is not enough? + + def stop_mitmproxy_playback(self): + """Stop the mitproxy server playback""" + if self.mitmproxy_proc is None or self.mitmproxy_proc.poll() is not None: + return + LOG.info( + "Stopping mitmproxy playback, killing process %d" % self.mitmproxy_proc.pid + ) + # On Windows, mozprocess brutally kills mitmproxy with TerminateJobObject + # The process has no chance to gracefully shutdown. + # Here, we send the process a break event to give it a chance to wrapup. + # See the signal handler in the alternate-server-replay-4.0.4.py script + if mozinfo.os == "win": + LOG.info("Sending CTRL_BREAK_EVENT to mitmproxy") + os.kill(self.mitmproxy_proc.pid, signal.CTRL_BREAK_EVENT) + time.sleep(2) + + exit_code = self.mitmproxy_proc.kill() + self.mitmproxy_proc = None + + if exit_code != 0: + if exit_code is None: + LOG.error("Failed to kill the mitmproxy playback process") + return + + if mozinfo.os == "win": + from mozprocess.winprocess import ( # noqa + ERROR_CONTROL_C_EXIT, + ERROR_CONTROL_C_EXIT_DECIMAL, + ) + + if exit_code in [ERROR_CONTROL_C_EXIT, ERROR_CONTROL_C_EXIT_DECIMAL]: + LOG.info( + "Successfully killed the mitmproxy playback process" + " with exit code %d" % exit_code + ) + return + log_func = LOG.error + if self.ignore_mitmdump_exit_failure: + log_func = LOG.info + log_func("Mitmproxy exited with error code %d" % exit_code) + else: + LOG.info("Successfully killed the mitmproxy playback process") + + def check_proxy(self, host, port): + """Check that mitmproxy process is working by doing a socket call using the proxy settings + :param host: Host of the proxy server + :param port: Port of the proxy server + :return: True if the proxy service is working + """ + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.connect((host, port)) + s.shutdown(socket.SHUT_RDWR) + s.close() + return True + except socket.error: + return False diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/mitmproxy_requirements.txt b/testing/mozbase/mozproxy/mozproxy/backends/mitm/mitmproxy_requirements.txt new file mode 100644 index 0000000000..7d9842b7d6 --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/mitmproxy_requirements.txt @@ -0,0 +1,35 @@ +argh==0.26.2 +asn1crypto==0.22.0 +blinker==1.4 +pycparser==2.17 +cffi==1.10.0 +brotlipy==0.6.0 +certifi==2017.4.17 +click==6.7 +construct==2.8.12 +cryptography==1.8.2 +cssutils==1.0.2 +EditorConfig==0.12.1 +h2==2.6.2 +hpack==3.0.0 +html2text==2016.9.19 +hyperframe==4.0.2 +idna==2.5 +jsbeautifier==1.6.12 +kaitaistruct==0.6 +mitmproxy==4.0.4 +packaging==16.8 +passlib==1.7.1 +pathtools==0.1.2 +pyasn1==0.2.3 +pyOpenSSL==16.2.0 +pyparsing==2.2.0 +pyperclip==1.5.27 +PyYAML==3.12 +requests==2.13.0 +ruamel.yaml==0.13.14 +six==1.13.0 +sortedcontainers==1.5.7 +tornado==4.4.3 +urwid==1.3.1 +watchdog==0.8.3 diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/__init__.py b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/__init__.py new file mode 100644 index 0000000000..0499f3a93f --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/__init__.py @@ -0,0 +1,4 @@ +#!/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/. diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/alt-serverplayback.py b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/alt-serverplayback.py new file mode 100644 index 0000000000..8d47b43e5b --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/alt-serverplayback.py @@ -0,0 +1,264 @@ +# 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/. + +# This file was copied from +# https://github.com/mitmproxy/mitmproxy/blob/v7.0.4/mitmproxy/addons/serverplayback.py +# and modified by Kimberly Sereduck + +# Altered features: +# * request function returns 404 rather than killing the flow which results in test failure +# * added option to play flows in reverse order: alt_server_replay_order_reversed +# * TO DO: remove the replay packages that don't have any content in their response package, +# see Bug 1739418: https://bugzilla.mozilla.org/show_bug.cgi?id=1739418 + +import hashlib +import traceback +import urllib +from collections.abc import Hashable, Sequence +from typing import Any, Optional + +import mitmproxy.types +from mitmproxy import command, ctx, exceptions, flow, hooks, http, io + + +class AltServerPlayback: + flowmap: dict[Hashable, list[http.HTTPFlow]] + configured: bool + + def __init__(self): + self.flowmap = {} + self.configured = False + + def load(self, loader): + loader.add_option( + "alt_server_replay_kill_extra", + bool, + False, + "Kill extra requests during replay.", + ) + loader.add_option( + "alt_server_replay_nopop", + bool, + False, + """ + Don't remove flows from server replay state after use. This makes it + possible to replay same response multiple times. + """, + ) + loader.add_option( + "alt_server_replay_refresh", + bool, + True, + """ + Refresh server replay responses by adjusting date, expires and + last-modified headers, as well as adjusting cookie expiration. + """, + ) + loader.add_option( + "alt_server_replay_use_headers", + Sequence[str], + [], + "Request headers to be considered during replay.", + ) + loader.add_option( + "alt_server_replay", + Sequence[str], + [], + "Replay server responses from a saved file.", + ) + loader.add_option( + "alt_server_replay_ignore_content", + bool, + False, + "Ignore request's content while searching for a saved flow to replay.", + ) + loader.add_option( + "alt_server_replay_ignore_params", + Sequence[str], + [], + """ + Request's parameters to be ignored while searching for a saved flow + to replay. + """, + ) + loader.add_option( + "alt_server_replay_ignore_payload_params", + Sequence[str], + [], + """ + Request's payload parameters (application/x-www-form-urlencoded or + multipart/form-data) to be ignored while searching for a saved flow + to replay. + """, + ) + loader.add_option( + "alt_server_replay_ignore_host", + bool, + False, + """ + Ignore request's destination host while searching for a saved flow + to replay. + """, + ) + loader.add_option( + "alt_server_replay_ignore_port", + bool, + False, + """ + Ignore request's destination port while searching for a saved flow + to replay. + """, + ) + loader.add_option( + "alt_server_replay_order_reversed", + bool, + False, + """ + Reverse the order of flows when replaying. + """, + ) + + @command.command("replay.server") + def load_flows(self, flows: Sequence[flow.Flow]) -> None: + """ + Replay server responses from flows. + """ + self.flowmap = {} + if ctx.options.alt_server_replay_order_reversed: + flows.reverse() + for f in flows: + if isinstance(f, http.HTTPFlow): + lst = self.flowmap.setdefault(self._hash(f), []) + lst.append(f) + ctx.master.addons.trigger(hooks.UpdateHook([])) + + @command.command("replay.server.file") + def load_file(self, path: mitmproxy.types.Path) -> None: + try: + flows = io.read_flows_from_paths([path]) + except exceptions.FlowReadException as e: + raise exceptions.CommandError(str(e)) + self.load_flows(flows) + + @command.command("replay.server.stop") + def clear(self) -> None: + """ + Stop server replay. + """ + self.flowmap = {} + ctx.master.addons.trigger(hooks.UpdateHook([])) + + @command.command("replay.server.count") + def count(self) -> int: + return sum([len(i) for i in self.flowmap.values()]) + + def _hash(self, flow: http.HTTPFlow) -> Hashable: + """ + Calculates a loose hash of the flow request. + """ + r = flow.request + _, _, path, _, query, _ = urllib.parse.urlparse(r.url) + queriesArray = urllib.parse.parse_qsl(query, keep_blank_values=True) + + key: list[Any] = [str(r.scheme), str(r.method), str(path)] + if not ctx.options.alt_server_replay_ignore_content: + if ctx.options.alt_server_replay_ignore_payload_params and r.multipart_form: + key.extend( + (k, v) + for k, v in r.multipart_form.items(multi=True) + if k.decode(errors="replace") + not in ctx.options.alt_server_replay_ignore_payload_params + ) + elif ( + ctx.options.alt_server_replay_ignore_payload_params + and r.urlencoded_form + ): + key.extend( + (k, v) + for k, v in r.urlencoded_form.items(multi=True) + if k not in ctx.options.alt_server_replay_ignore_payload_params + ) + else: + key.append(str(r.raw_content)) + + if not ctx.options.alt_server_replay_ignore_host: + key.append(r.pretty_host) + if not ctx.options.alt_server_replay_ignore_port: + key.append(r.port) + + filtered = [] + ignore_params = ctx.options.alt_server_replay_ignore_params or [] + for p in queriesArray: + if p[0] not in ignore_params: + filtered.append(p) + for p in filtered: + key.append(p[0]) + key.append(p[1]) + + if ctx.options.alt_server_replay_use_headers: + headers = [] + for i in ctx.options.alt_server_replay_use_headers: + v = r.headers.get(i) + headers.append((i, v)) + key.append(headers) + return hashlib.sha256(repr(key).encode("utf8", "surrogateescape")).digest() + + def next_flow(self, flow: http.HTTPFlow) -> Optional[http.HTTPFlow]: + """ + Returns the next flow object, or None if no matching flow was + found. + """ + hash = self._hash(flow) + if hash in self.flowmap: + if ctx.options.alt_server_replay_nopop: + return next( + (flow for flow in self.flowmap[hash] if flow.response), None + ) + else: + ret = self.flowmap[hash].pop(0) + while not ret.response: + if self.flowmap[hash]: + ret = self.flowmap[hash].pop(0) + else: + del self.flowmap[hash] + return None + if not self.flowmap[hash]: + del self.flowmap[hash] + return ret + else: + return None + + def configure(self, updated): + if not self.configured and ctx.options.alt_server_replay: + self.configured = True + try: + flows = io.read_flows_from_paths( + ctx.options.alt_server_replay[0].split(",") + ) + except exceptions.FlowReadException: + raise exceptions.OptionsError(str(traceback.print_exc())) + self.load_flows(flows) + + def request(self, f: http.HTTPFlow) -> None: + if self.flowmap: + rflow = self.next_flow(f) + if rflow: + assert rflow.response + response = rflow.response.copy() + if ctx.options.alt_server_replay_refresh: + response.refresh() + f.response = response + f.is_replay = "response" + elif ctx.options.alt_server_replay_kill_extra: + ctx.log.warn( + "server_playback: killed non-replay request {}".format( + f.request.url + ) + ) + f.response = http.Response.make( + 404, b"", {"content-type": "text/plain"} + ) + + +addons = [AltServerPlayback()] diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/alternate-server-replay.py b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/alternate-server-replay.py new file mode 100644 index 0000000000..9c74fc59bd --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/alternate-server-replay.py @@ -0,0 +1,316 @@ +# 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/. + +# This file was copied from mitmproxy/mitmproxy/addons/serverplayback.py release tag 4.0.4 +# and modified by Florin Strugariu + +# Altered features: +# * returns 404 rather than dropping the whole HTTP/2 connection on the floor +# * remove the replay packages that don't have any content in their response package +import hashlib +import json +import os +import signal +import time +import typing +from collections import defaultdict + +from mitmproxy import ctx, exceptions, http, io + +# PATCHING AREA - ALLOWS HTTP/2 WITH NO CERT SNIFFING +from mitmproxy.proxy.protocol import tls +from mitmproxy.proxy.protocol.http2 import Http2Layer, SafeH2Connection +from six.moves import urllib + +_PROTO = {} + + +@property +def _alpn(self): + proto = _PROTO.get(self.server_sni) + if proto is None: + return self.server_conn.get_alpn_proto_negotiated() + if proto.startswith("HTTP/2"): + return b"h2" + elif proto.startswith("HTTP/1"): + return b"h1" + return b"" + + +tls.TlsLayer.alpn_for_client_connection = _alpn + + +def _server_conn(self): + if not self.server_conn.connected() and self.server_conn not in self.connections: + # we can't use ctx.log in this layer + print("Ignored CONNECT call on upstream server") + return + if self.server_conn.connected(): + import h2.config + + config = h2.config.H2Configuration( + client_side=True, + header_encoding=False, + validate_outbound_headers=False, + validate_inbound_headers=False, + ) + self.connections[self.server_conn] = SafeH2Connection( + self.server_conn, config=config + ) + self.connections[self.server_conn].initiate_connection() + self.server_conn.send(self.connections[self.server_conn].data_to_send()) + + +Http2Layer._initiate_server_conn = _server_conn + + +def _remote_settings_changed(self, event, other_conn): + if other_conn not in self.connections: + # we can't use ctx.log in this layer + print("Ignored remote settings upstream") + return True + new_settings = dict( + [(key, cs.new_value) for (key, cs) in event.changed_settings.items()] + ) + self.connections[other_conn].safe_update_settings(new_settings) + return True + + +Http2Layer._handle_remote_settings_changed = _remote_settings_changed +# END OF PATCHING + + +class AlternateServerPlayback: + def __init__(self): + ctx.master.addons.remove(ctx.master.addons.get("serverplayback")) + self.flowmap = {} + self.configured = False + self.netlocs = defaultdict(lambda: defaultdict(int)) + self.calls = [] + self._done = False + self._replayed = 0 + self._not_replayed = 0 + self._recordings_used = 0 + self.mitm_version = ctx.mitmproxy.version.VERSION + + ctx.log.info("MitmProxy version: %s" % self.mitm_version) + + def load(self, loader): + loader.add_option( + "server_replay_files", + typing.Sequence[str], + [], + "Replay server responses from a saved file.", + ) + loader.add_option( + "upload_dir", + str, + "", + "Upload directory", + ) + + def load_flows(self, flows): + """ + Replay server responses from flows. + """ + for i in flows: + if i.type == "websocket": + # Mitmproxy can't replay WebSocket packages. + ctx.log.info( + "Recorded response is a WebSocketFlow. Removing from recording list as" + " WebSockets are disabled" + ) + elif i.response: + hash = self._hash(i) + if i.response.content is None and self.flowmap.get(hash, False): + # To avoid 'Cannot assemble flow with missing content' we check + # if the correct request has no content and hashed request already exists + # if the hashed request already has content + # then we do not add the new one end keep the existing one + + if not self.flowmap.get(hash)["flow"].response.content is None: + ctx.log.info( + "Duplicate recorded request found with content missing. " + "Removing current request as it has no data. %s" + % i.request.url + ) + continue + + f = self.flowmap.setdefault(hash, {"flow": None, "reply_count": 0}) + # overwrite with new flow if already hashed + f["flow"] = i + + else: + ctx.log.info( + "Recorded request %s has no response. Removing from recording list" + % i.request.url + ) + ctx.master.addons.trigger("update", []) + + def load_files(self, paths): + try: + if "," in paths[0]: + paths = paths[0].split(",") + for path in paths: + ctx.log.info("Loading flows from %s" % path) + if not os.path.exists(path): + raise Exception("File does not exist!") + try: + flows = io.read_flows_from_paths([path]) + except exceptions.FlowReadException as e: + raise exceptions.CommandError(str(e)) + self.load_flows(flows) + proto = os.path.join(os.path.dirname(path), "metadata.json") + if os.path.exists(proto): + ctx.log.info("Loading proto info from %s" % proto) + with open(proto) as f: + recording_info = json.loads(f.read()) + if recording_info.get("http_protocol", False): + ctx.log.info( + "Replaying file {} recorded on {}".format( + path, recording_info["recording_date"] + ) + ) + _PROTO.update(recording_info["http_protocol"]) + else: + ctx.log.warn( + "Replaying file {} has no http_protocol info.".format(proto) + ) + except Exception as e: + ctx.log.error("Could not load recording file! Stopping playback process!") + ctx.log.error(str(e)) + ctx.master.shutdown() + + def _hash(self, flow): + """ + Calculates a loose hash of the flow request. + """ + r = flow.request + + # unquote url + # See Bug 1509835 + _, _, path, _, query, _ = urllib.parse.urlparse(urllib.parse.unquote(r.url)) + queriesArray = urllib.parse.parse_qsl(query, keep_blank_values=True) + + key = [str(r.port), str(r.scheme), str(r.method), str(path)] + key.append(str(r.raw_content)) + key.append(r.host) + + for p in queriesArray: + key.append(p[0]) + key.append(p[1]) + + return hashlib.sha256(repr(key).encode("utf8", "surrogateescape")).digest() + + def next_flow(self, request): + """ + Returns the next flow object, or None if no matching flow was + found. + """ + hsh = self._hash(request) + if hsh in self.flowmap: + if self.flowmap[hsh]["reply_count"] == 0: + self._recordings_used += 1 + self.flowmap[hsh]["reply_count"] += 1 + # return the most recently added flow with this hash + return self.flowmap[hsh]["flow"] + + def configure(self, updated): + if not self.configured and ctx.options.server_replay_files: + self.configured = True + self.load_files(ctx.options.server_replay_files) + + def done(self): + if self._done or not ctx.options.upload_dir: + return + + replay_confidence = float(self._replayed) / ( + self._replayed + self._not_replayed + ) + recording_proportion_used = ( + 0 + if self._recordings_used == 0 + else float(self._recordings_used) / len(self.flowmap) + ) + stats = { + "totals": dict(self.netlocs), + "calls": self.calls, + "replayed": self._replayed, + "not-replayed": self._not_replayed, + "replay-confidence": int(replay_confidence * 100), + "recording-proportion-used": int(recording_proportion_used * 100), + } + file_name = ( + "mitm_netlocs_%s.json" + % os.path.splitext(os.path.basename(ctx.options.server_replay_files[0]))[0] + ) + path = os.path.normpath(os.path.join(ctx.options.upload_dir, file_name)) + try: + with open(path, "w") as f: + f.write(json.dumps(stats, indent=2, sort_keys=True)) + finally: + self._done = True + + def request(self, f): + if self.flowmap: + try: + rflow = self.next_flow(f) + if rflow: + response = rflow.response.copy() + response.is_replay = True + # Refresh server replay responses by adjusting date, expires and + # last-modified headers, as well as adjusting cookie expiration. + response.refresh() + + f.response = response + self._replayed += 1 + else: + # returns 404 rather than dropping the whole HTTP/2 connection + ctx.log.warn( + "server_playback: killed non-replay request {}".format( + f.request.url + ) + ) + f.response = http.HTTPResponse.make( + 404, b"", {"content-type": "text/plain"} + ) + self._not_replayed += 1 + + # collecting stats only if we can dump them (see .done()) + if ctx.options.upload_dir: + parsed_url = urllib.parse.urlparse( + urllib.parse.unquote(f.request.url) + ) + self.netlocs[parsed_url.netloc][f.response.status_code] += 1 + self.calls.append( + { + "time": str(time.time()), + "url": f.request.url, + "response_status": f.response.status_code, + } + ) + except Exception as e: + ctx.log.error("Could not generate response! Stopping playback process!") + ctx.log.info(e) + ctx.master.shutdown() + + else: + ctx.log.error("Playback library is empty! Stopping playback process!") + ctx.master.shutdown() + return + + +playback = AlternateServerPlayback() + +if hasattr(signal, "SIGBREAK"): + # allows the addon to dump the stats even if mitmproxy + # does not call done() like on windows termination + # for this, the parent process sends CTRL_BREAK_EVENT which + # is received as an SIGBREAK event + def _shutdown(sig, frame): + ctx.master.shutdown() + + signal.signal(signal.SIGBREAK, _shutdown) + +addons = [playback] diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/catapult/LICENSE b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/catapult/LICENSE new file mode 100644 index 0000000000..c992fe483c --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/catapult/LICENSE @@ -0,0 +1,27 @@ +Copyright 2015 The Chromium Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of catapult nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/catapult/deterministic.js b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/catapult/deterministic.js new file mode 100644 index 0000000000..d2e818ba00 --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/catapult/deterministic.js @@ -0,0 +1,71 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + + +'use strict'; + +(function () { + var random_count = 0; + var random_count_threshold = 25; + var random_seed = 0.462; + Math.random = function() { + random_count++; + if (random_count > random_count_threshold){ + random_seed += 0.1; + random_count = 1; + } + return (random_seed % 1); + }; + if (typeof(crypto) == 'object' && + typeof(crypto.getRandomValues) == 'function') { + crypto.getRandomValues = function(arr) { + var scale = Math.pow(256, arr.BYTES_PER_ELEMENT); + for (var i = 0; i < arr.length; i++) { + arr[i] = Math.floor(Math.random() * scale); + } + return arr; + }; + } +})(); +(function () { + var date_count = 0; + var date_count_threshold = 25; + var orig_date = Date; + // Time since epoch in milliseconds. This is replaced by script injector with + // the date when the recording is done. + var time_seed = REPLACE_LOAD_TIMESTAMP; + Date = function() { + if (this instanceof Date) { + date_count++; + if (date_count > date_count_threshold){ + time_seed += 50; + date_count = 1; + } + switch (arguments.length) { + case 0: return new orig_date(time_seed); + case 1: return new orig_date(arguments[0]); + default: return new orig_date(arguments[0], arguments[1], + arguments.length >= 3 ? arguments[2] : 1, + arguments.length >= 4 ? arguments[3] : 0, + arguments.length >= 5 ? arguments[4] : 0, + arguments.length >= 6 ? arguments[5] : 0, + arguments.length >= 7 ? arguments[6] : 0); + } + } + return new Date().toString(); + }; + Date.__proto__ = orig_date; + Date.prototype = orig_date.prototype; + Date.prototype.constructor = Date; + orig_date.now = function() { + return new Date().getTime(); + }; + orig_date.prototype.getTimezoneOffset = function() { + var dst2010Start = 1268560800000; + var dst2010End = 1289120400000; + if (this.getTime() >= dst2010Start && this.getTime() < dst2010End) + return 420; + return 480; + }; +})(); diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/http_protocol_extractor.py b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/http_protocol_extractor.py new file mode 100644 index 0000000000..839b0e0adb --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/http_protocol_extractor.py @@ -0,0 +1,83 @@ +# 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/. +import datetime +import hashlib +import json +import os +import urllib +from urllib import parse + + +class HttpProtocolExtractor: + def get_ctx(self): + from mitmproxy import ctx + + return ctx + + def load(self, loader): + self.ctx = self.get_ctx() + + self.request_protocol = {} + self.hashes = [] + self.request_count = 0 + + self.ctx.log.info("Init Http Protocol extractor JS") + + def _hash(self, flow): + """ + Calculates a loose hash of the flow request. + """ + r = flow.request + + # unquote url + # See Bug 1509835 + _, _, path, _, query, _ = urllib.parse.urlparse(parse.unquote(r.url)) + queriesArray = urllib.parse.parse_qsl(query, keep_blank_values=True) + + key = [str(r.port), str(r.scheme), str(r.method), str(path)] + key.append(str(r.raw_content)) + key.append(r.host) + + for p in queriesArray: + key.append(p[0]) + key.append(p[1]) + + return hashlib.sha256(repr(key).encode("utf8", "surrogateescape")).digest() + + def response(self, flow): + self.request_count += 1 + hash = self._hash(flow) + if hash not in self.hashes: + self.hashes.append(hash) + + if flow.type == "websocket": + self.ctx.log.info("Response is a WebSocketFlow. Bug 1559117") + else: + self.ctx.log.info( + "Response using protocol: %s" % flow.response.data.http_version + ) + self.request_protocol[ + urllib.parse.urlparse(flow.request.url).netloc + ] = flow.response.data.http_version.decode("utf-8") + + def done(self): + output_json = {} + + output_json["recording_date"] = str(datetime.datetime.now()) + output_json["http_protocol"] = self.request_protocol + output_json["recorded_requests"] = self.request_count + output_json["recorded_requests_unique"] = len(self.hashes) + + recording_file_name = self.ctx.options.save_stream_file + + json_file_name = os.path.join( + os.path.dirname(recording_file_name), + "%s.json" % os.path.basename(recording_file_name).split(".")[0], + ) + print("Saving response protocol data to %s" % json_file_name) + with open(json_file_name, "w") as file: + file.write(json.dumps(output_json)) + + +addons = [HttpProtocolExtractor()] diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/inject-deterministic.py b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/inject-deterministic.py new file mode 100644 index 0000000000..7134643d6a --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/inject-deterministic.py @@ -0,0 +1,206 @@ +# 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/. +import base64 +import hashlib +import re +import time +from os import path + +from mitmproxy import ctx + + +class AddDeterministic: + def __init__(self): + self.millis = int(round(time.time() * 1000)) + ctx.log.info("Init Deterministic JS") + + def load(self, loader): + ctx.log.info("Load Deterministic JS") + + def get_csp_directives(self, test_header, headers): + csp = headers.get(test_header, "") + return [d.strip() for d in csp.split(";")] + + def get_csp_script_sources(self, test_header, headers): + sources = [] + for directive in self.get_csp_directives(test_header, headers): + if directive.startswith("script-src "): + sources = directive.split()[1:] + return sources + + def get_nonce_from_headers(self, test_header, headers): + """ + get_nonce_from_headers returns the nonce token from a + Content-Security-Policy (CSP) header's script source directive. + + Note: + For more background information on CSP and nonce, please refer to + https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ + Content-Security-Policy/script-src + https://developers.google.com/web/fundamentals/security/csp/ + """ + + for source in self.get_csp_script_sources(test_header, headers) or []: + if source.startswith("'nonce-"): + return source.partition("'nonce-")[-1][:-1] + + def get_script_with_nonce(self, script, nonce=None): + """ + Given a nonce, get_script_with_nonce returns the injected script text with the nonce. + + If nonce None, get_script_with_nonce returns the script block + without attaching a nonce attribute. + + Note: + Some responses may specify a nonce inside their Content-Security-Policy, + script-src directive. + The script injector needs to set the injected script's nonce attribute to + open execute permission for the injected script. + """ + + if nonce: + return '<script nonce="{}">{}</script>'.format(nonce, script) + return "<script>{}</script>".format(script) + + def update_csp_script_src(self, test_header, headers, sha256): + """ + Update the CSP script directives with appropriate information + + Without this permissions a page with a + restrictive CSP will not execute injected scripts. + + Note: + For more background information on CSP, please refer to + https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ + Content-Security-Policy/script-src + https://developers.google.com/web/fundamentals/security/csp/ + """ + + sources = self.get_csp_script_sources(test_header, headers) + add_unsafe = True + + for token in sources: + if token == "'unsafe-inline'": + add_unsafe = False + ctx.log.info("Contains unsafe-inline") + elif token.startswith("'sha"): + sources.append("'sha256-{}'".format(sha256)) + add_unsafe = False + ctx.log.info("Add sha hash directive") + break + + if add_unsafe: + ctx.log.info("Add unsafe") + sources.append("'unsafe-inline'") + + return "script-src {}".format(" ".join(sources)) + + def get_new_csp_header(self, test_header, headers, updated_csp_script): + """ + get_new_csp_header generates a new header object containing + the updated elements from new_csp_script_directives + """ + + if updated_csp_script: + directives = self.get_csp_directives(test_header, headers) + for index, directive in enumerate(directives): + if directive.startswith("script-src "): + directives[index] = updated_csp_script + + ctx.log.info("Original Header %s \n" % headers["Content-Security-Policy"]) + headers["Content-Security-Policy"] = "; ".join(directives) + ctx.log.info("Updated Header %s \n" % headers["Content-Security-Policy"]) + + return headers + + def response(self, flow): + # pylint: disable=W1633 + if "content-type" in flow.response.headers: + if "text/html" in flow.response.headers["content-type"]: + ctx.log.info( + "Working on {}".format(flow.response.headers["content-type"]) + ) + + flow.response.decode() + html = flow.response.text + + with open( + path.join(path.dirname(__file__), "catapult/deterministic.js"), "r" + ) as jsfile: + js = jsfile.read().replace( + "REPLACE_LOAD_TIMESTAMP", str(self.millis) + ) + + if js not in html: + script_index = re.search("(?i).*?<head.*?>", html) + if script_index is None: + script_index = re.search("(?i).*?<html.*?>", html) + if script_index is None: + script_index = re.search("(?i).*?<!doctype html>", html) + if script_index is None: + ctx.log.info( + "No start tags found in request {}. Skip injecting".format( + flow.request.url + ) + ) + return + script_index = script_index.end() + + nonce = None + for test_header in [ + "Content-Security-Policy", + "Content-Security-Policy-Report-Only", + ]: + if flow.response.headers.get(test_header, False): + nonce = self.get_nonce_from_headers( + test_header, flow.response.headers + ) + ctx.log.info("nonce : %s" % nonce) + + if ( + self.get_csp_script_sources( + test_header, flow.response.headers + ) + and not nonce + ): + # generate sha256 for the script + hash_object = hashlib.sha256(js.encode("utf-8")) + script_sha256 = base64.b64encode( + hash_object.digest() + ).decode("utf-8") + + # generate the new response headers + updated_script_sources = self.update_csp_script_src( + test_header, + flow.response.headers, + script_sha256, + ) + flow.response.headers = self.get_new_csp_header( + test_header, + flow.response.headers, + updated_script_sources, + ) + + # generate new html file + new_html = ( + html[:script_index] + + self.get_script_with_nonce(js, nonce) + + html[script_index:] + ) + flow.response.text = new_html + + ctx.log.info( + "In request {} injected deterministic JS".format( + flow.request.url + ) + ) + else: + ctx.log.info( + "Script already injected in request {}".format( + flow.request.url + ) + ) + + +addons = [AddDeterministic()] diff --git a/testing/mozbase/mozproxy/mozproxy/driver.py b/testing/mozbase/mozproxy/mozproxy/driver.py new file mode 100644 index 0000000000..a525b3a740 --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/driver.py @@ -0,0 +1,171 @@ +# 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/. +import argparse +import os +import signal +import sys + +import mozinfo +import mozlog.commandline + +from . import get_playback +from .utils import LOG, TOOLTOOL_PATHS + +EXIT_SUCCESS = 0 +EXIT_EARLY_TERMINATE = 3 +EXIT_EXCEPTION = 4 + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--mode", + default="playback", + choices=["record", "playback"], + help="Proxy server mode. Use `playback` to replay from the provided file(s). " + "Use `record` to generate a new recording at the path specified by `--file`. " + "playback - replay from provided file. " + "record - generate a new recording at the specified path.", + ) + parser.add_argument( + "file", + nargs="+", + help="The playback files to replay, or the file that a recording will be saved to. " + "For playback, it can be any combination of the following: zip file, manifest file, " + "or a URL to zip/manifest file. " + "For recording, it's a zip fle.", + ) + parser.add_argument( + "--host", default="localhost", help="The host to use for the proxy server." + ) + parser.add_argument( + "--tool", + default="mitmproxy", + help="The playback tool to use (default: %(default)s).", + ) + parser.add_argument( + "--tool-version", + default="8.1.1", + help="The playback tool version to use (default: %(default)s)", + ) + parser.add_argument( + "--app", default="firefox", help="The app being tested (default: %(default)s)." + ) + parser.add_argument( + "--binary", + required=True, + help=("The path to the binary being tested (typically firefox)."), + ) + parser.add_argument( + "--topsrcdir", + required=True, + help="The top of the source directory for this project.", + ) + parser.add_argument( + "--objdir", required=True, help="The object directory for this build." + ) + parser.add_argument( + "--profiledir", default=None, help="Path to the profile directory." + ) + parser.add_argument( + "--local", + action="store_true", + help="Run this locally (i.e. not in production).", + ) + parser.add_argument( + "--verbose", + action="store_true", + default=False, + help="Run this locally (i.e. not in production).", + ) + parser.add_argument( + "--deterministic", + action="store_true", + default=False, + help="Enable or disable inject_deterministic script when recording.", + ) + + mozlog.commandline.add_logging_group(parser) + + args = parser.parse_args() + mozlog.commandline.setup_logging("mozproxy", args, {"raw": sys.stdout}) + + TOOLTOOL_PATHS.append( + os.path.join( + args.topsrcdir, "python", "mozbuild", "mozbuild", "action", "tooltool.py" + ) + ) + + if hasattr(signal, "SIGBREAK"): + # Terminating on windows is slightly different than other platforms. + # On POSIX, we just let Python's default SIGINT handler raise a + # KeyboardInterrupt. This doesn't work on Windows, so instead we wait + # for a Ctrl+Break event and raise our own KeyboardInterrupt. + def handle_sigbreak(sig, frame): + raise KeyboardInterrupt() + + signal.signal(signal.SIGBREAK, handle_sigbreak) + + try: + if args.mode == "playback": + if len(args.file) == 0: + raise Exception("Please provide at least one recording file!") + + # Playback mode + proxy_service = get_playback( + { + "run_local": args.local, + "host": args.host, + "binary": args.binary, + "obj_path": args.objdir, + "platform": mozinfo.os, + "playback_tool": args.tool, + "playback_version": args.tool_version, + "playback_files": args.file, + "app": args.app, + "local_profile_dir": args.profiledir, + "verbose": args.verbose, + } + ) + if args.mode == "record": + # Record mode + if len(args.file) > 1: + raise Exception("Please provide only one recording file!") + + LOG.info("Recording will be saved to: %s" % args.file) + proxy_service = get_playback( + { + "run_local": args.local, + "host": args.host, + "binary": args.binary, + "obj_path": args.objdir, + "platform": mozinfo.os, + "playback_tool": args.tool, + "playback_version": args.tool_version, + "record": True, + "recording_file": args.file[0], + "app": args.app, + "local_profile_dir": args.profiledir, + "verbose": args.verbose, + "inject_deterministic": args.deterministic, + } + ) + LOG.info("Proxy settings %s" % proxy_service) + proxy_service.start() + LOG.info("Proxy running on port %d" % proxy_service.port) + # Wait for a keyboard interrupt from the caller so we know when to + # terminate. + proxy_service.wait() + return EXIT_EARLY_TERMINATE + except KeyboardInterrupt: + LOG.info("Terminating mozproxy") + proxy_service.stop() + return EXIT_SUCCESS + except Exception as e: + LOG.error(str(e), exc_info=True) + return EXIT_EXCEPTION + + +if __name__ == "__main__": + main() diff --git a/testing/mozbase/mozproxy/mozproxy/recordings.py b/testing/mozbase/mozproxy/mozproxy/recordings.py new file mode 100644 index 0000000000..e00c16c99d --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/recordings.py @@ -0,0 +1,163 @@ +# 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/. +import json +import os +import shutil +from datetime import datetime +from shutil import copyfile +from zipfile import ZipFile + +from .utils import LOG + + +class RecordingFile: + def __init__(self, path_to_zip_file): + self._recording_zip_path = path_to_zip_file + + self._base_name = os.path.splitext(os.path.basename(self._recording_zip_path))[ + 0 + ] + if not os.path.splitext(path_to_zip_file)[1] == ".zip": + LOG.error( + "Wrong file type! The provided recording should be a zip file. %s" + % path_to_zip_file + ) + raise Exception( + "Wrong file type! The provided recording should be a zip file." + ) + + # create a temp dir + self._mozproxy_dir = os.environ["MOZPROXY_DIR"] + self._tempdir = os.path.join(self._mozproxy_dir, self._base_name) + + if os.path.exists(self._tempdir): + LOG.info( + "The recording dir already exists! Resetting the existing dir and data." + ) + shutil.rmtree(self._tempdir) + os.mkdir(self._tempdir) + + self._metadata_path = self._get_temp_path("metadata.json") + self._recording = self._get_temp_path("dump.mp") + + if os.path.exists(path_to_zip_file): + with ZipFile(path_to_zip_file, "r") as zipObj: + # Extract all the contents of zip file in different directory + zipObj.extractall(self._tempdir) + + if not os.path.exists(self._recording): + self._convert_to_new_format() + + if not os.path.exists(self._metadata_path): + LOG.error("metadata file is missing!") + raise Exception("metadata file is missing!") + + with open(self._metadata_path) as json_file: + self._metadata = json.load(json_file) + self.validate_recording() + LOG.info( + "Loaded recoording generated on %s" % self.metadata("recording_date") + ) + else: + LOG.info("Recording file does not exists!!! Generating base structure") + self._metadata = {"content": [], "recording_date": str(datetime.now())} + + def _convert_to_new_format(self): + # Convert zip recording to new format + + LOG.info("Convert zip recording to new format") + + for tmp_file in os.listdir(self._tempdir): + if tmp_file.endswith(".mp"): + LOG.info("Renaming %s to dump.mp file" % tmp_file) + os.rename(self._get_temp_path(tmp_file), self._get_temp_path("dump.mp")) + elif tmp_file.endswith(".json"): + if tmp_file.startswith("mitm_netlocs_"): + LOG.info("Renaming %s to netlocs.json file" % tmp_file) + os.rename( + self._get_temp_path("%s.json" % os.path.splitext(tmp_file)[0]), + self._get_temp_path("netlocs.json"), + ) + else: + LOG.info("Renaming %s to metadata.json file" % tmp_file) + os.rename( + self._get_temp_path("%s.json" % os.path.splitext(tmp_file)[0]), + self._get_temp_path("metadata.json"), + ) + elif tmp_file.endswith(".png"): + LOG.info("Renaming %s to screenshot.png file" % tmp_file) + os.rename( + self._get_temp_path("%s.png" % os.path.splitext(tmp_file)[0]), + self._get_temp_path("screenshot.png"), + ) + + def _get_temp_path(self, file_name): + return os.path.join(self._tempdir, file_name) + + def validate_recording(self): + # Validates that minimum zip file content exists + if not os.path.exists(self._recording): + LOG.error("Recording file is missing!") + raise Exception("Recording file is missing!") + + if not os.path.exists(self._metadata_path): + LOG.error("Metadata file is missing!") + raise Exception("Metadata file is missing!") + + if "content" in self._metadata: + # check that all extra files specified in the recording are present + for content_file in self._metadata["content"]: + if not os.path.exists(self._get_temp_path(content_file)): + LOG.error("File %s does not exist!!" % content_file) + raise Exception("Recording file is missing!") + else: + LOG.info("Old file type! Not confirming content!") + + def metadata(self, name): + # Return metadata value + return self._metadata[name] + + def set_metadata(self, entry, value): + # Set metadata value + self._metadata[entry] = value + + @property + def recording_path(self): + # Returns the path of the recoring.mp file included in the zip + return self._recording + + def get_file(self, file_name): + # Returns the path to a specified file included in the recording zip + return self._get_temp_path(file_name) + + def add_file(self, path): + # Adds file to Zip + if os.path.exists(path): + copyfile(path, self._tempdir) + self._metadata["content"].append(os.path.basename(path)) + else: + LOG.error("Target file %s does not exist!!" % path) + raise Exception("File does not exist!!!") + + def update_metadata(self): + # Update metadata with information generated by HttpProtocolExtractor plugin + # This data is geerated after closing the MitMPtoxy process + + dump_file = self._get_temp_path("dump.json") + if os.path.exists(dump_file): + with open(dump_file) as dump: + dump_info = json.load(dump) + self._metadata.update(dump_info) + + def generate_zip_file(self): + self.update_metadata() + with open(self._get_temp_path(self._metadata_path), "w") as metadata_file: + json.dump(self._metadata, metadata_file, sort_keys=True, indent=4) + + with ZipFile(self._recording_zip_path, "w") as zf: + zf.write(self._metadata_path, "metadata.json") + zf.write(self.recording_path, "dump.mp") + for file in self._metadata["content"]: + zf.write(self._get_temp_path(file), file) + LOG.info("Generated new recording file at: %s" % self._recording_zip_path) diff --git a/testing/mozbase/mozproxy/mozproxy/server.py b/testing/mozbase/mozproxy/mozproxy/server.py new file mode 100644 index 0000000000..ff6975c1a6 --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/server.py @@ -0,0 +1,16 @@ +# 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 mozproxy.backends.mitm.android import MitmproxyAndroid +from mozproxy.backends.mitm.desktop import MitmproxyDesktop + +_BACKENDS = {"mitmproxy": MitmproxyDesktop, "mitmproxy-android": MitmproxyAndroid} + + +def get_backend(name, *args, **kw): + """Returns the class that implements the backend. + + Raises KeyError in case the backend does not exists. + """ + return _BACKENDS[name](*args, **kw) diff --git a/testing/mozbase/mozproxy/mozproxy/utils.py b/testing/mozbase/mozproxy/mozproxy/utils.py new file mode 100644 index 0000000000..8ee91dc71b --- /dev/null +++ b/testing/mozbase/mozproxy/mozproxy/utils.py @@ -0,0 +1,249 @@ +"""Utility functions for Raptor""" +# 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/. + +import bz2 +import gzip +import os +import signal +import socket +import sys +import time +from subprocess import PIPE, Popen + +from redo import retriable, retry +from six.moves.urllib.request import urlretrieve + +try: + import zstandard +except ImportError: + zstandard = None +try: + import lzma +except ImportError: + lzma = None + +from mozlog import get_proxy_logger + +from mozproxy import mozbase_dir, mozharness_dir + +LOG = get_proxy_logger(component="mozproxy") + +# running locally via mach +TOOLTOOL_PATHS = [ + os.path.join(mozharness_dir, "external_tools", "tooltool.py"), + os.path.join( + mozbase_dir, + "..", + "..", + "python", + "mozbuild", + "mozbuild", + "action", + "tooltool.py", + ), +] + +if "MOZ_UPLOAD_DIR" in os.environ: + TOOLTOOL_PATHS.append( + os.path.join( + os.environ["MOZ_UPLOAD_DIR"], + "..", + "..", + "mozharness", + "external_tools", + "tooltool.py", + ) + ) + + +def transform_platform(str_to_transform, config_platform, config_processor=None): + """Transform platform name i.e. 'mitmproxy-rel-bin-{platform}.manifest' + + transforms to 'mitmproxy-rel-bin-osx.manifest'. + Also transform '{x64}' if needed for 64 bit / win 10 + """ + if "{platform}" not in str_to_transform and "{x64}" not in str_to_transform: + return str_to_transform + + if "win" in config_platform: + platform_id = "win" + elif config_platform == "mac": + platform_id = "osx" + else: + platform_id = "linux64" + + if "{platform}" in str_to_transform: + str_to_transform = str_to_transform.replace("{platform}", platform_id) + + if "{x64}" in str_to_transform and config_processor is not None: + if "x86_64" in config_processor: + str_to_transform = str_to_transform.replace("{x64}", "_x64") + else: + str_to_transform = str_to_transform.replace("{x64}", "") + + return str_to_transform + + +@retriable(sleeptime=2) +def tooltool_download(manifest, run_local, raptor_dir): + """Download a file from tooltool using the provided tooltool manifest""" + + tooltool_path = None + + for path in TOOLTOOL_PATHS: + if os.path.exists(path): + tooltool_path = path + break + if tooltool_path is None: + raise Exception("Could not find tooltool path!") + + if run_local: + command = [sys.executable, tooltool_path, "fetch", "-o", "-m", manifest] + else: + # Attempt to determine the tooltool cache path: + # - TOOLTOOLCACHE is used by Raptor tests + # - TOOLTOOL_CACHE is automatically set for taskcluster jobs + # - fallback to a hardcoded path + _cache = next( + x + for x in ( + os.environ.get("TOOLTOOLCACHE"), + os.environ.get("TOOLTOOL_CACHE"), + "/builds/tooltool_cache", + ) + if x is not None + ) + + command = [ + sys.executable, + tooltool_path, + "fetch", + "-o", + "-m", + manifest, + "-c", + _cache, + ] + + try: + proc = Popen(command, cwd=raptor_dir, text=True) + if proc.wait() != 0: + raise Exception("Command failed") + except Exception as e: + LOG.critical( + "Error while downloading {} from tooltool:{}".format(manifest, str(e)) + ) + if proc.poll() is None: + proc.kill(signal.SIGTERM) + raise + + +def archive_type(path): + filename, extension = os.path.splitext(path) + filename, extension2 = os.path.splitext(filename) + if extension2 != "": + extension = extension2 + if extension == ".tar": + return "tar" + elif extension == ".zip": + return "zip" + return None + + +def extract_archive(path, dest_dir, typ): + """Extract an archive to a destination directory.""" + + # Resolve paths to absolute variants. + path = os.path.abspath(path) + dest_dir = os.path.abspath(dest_dir) + suffix = os.path.splitext(path)[-1] + + # We pipe input to the decompressor program so that we can apply + # custom decompressors that the program may not know about. + if typ == "tar": + if suffix == ".bz2": + ifh = bz2.open(str(path), "rb") + elif suffix == ".gz": + ifh = gzip.open(str(path), "rb") + elif suffix == ".xz": + if not lzma: + raise ValueError("lzma Python package not available") + ifh = lzma.open(str(path), "rb") + elif suffix == ".zst": + if not zstandard: + raise ValueError("zstandard Python package not available") + dctx = zstandard.ZstdDecompressor() + ifh = dctx.stream_reader(path.open("rb")) + elif suffix == ".tar": + ifh = path.open("rb") + else: + raise ValueError("unknown archive format for tar file: %s" % path) + args = ["tar", "xf", "-"] + pipe_stdin = True + elif typ == "zip": + # unzip from stdin has wonky behavior. We don't use a pipe for it. + ifh = open(os.devnull, "rb") + args = ["unzip", "-o", str(path)] + pipe_stdin = False + else: + raise ValueError("unknown archive format: %s" % path) + + LOG.info("Extracting %s to %s using %r" % (path, dest_dir, args)) + t0 = time.time() + with ifh: + p = Popen(args, cwd=str(dest_dir), bufsize=0, stdin=PIPE) + while True: + if not pipe_stdin: + break + chunk = ifh.read(131072) + if not chunk: + break + p.stdin.write(chunk) + # make sure we wait for the command to finish + p.communicate() + + if p.returncode: + raise Exception("%r exited %d" % (args, p.returncode)) + LOG.info("%s extracted in %.3fs" % (path, time.time() - t0)) + + +def download_file_from_url(url, local_dest, extract=False): + """Receive a file in a URL and download it, i.e. for the hostutils tooltool manifest + the url received would be formatted like this: + config/tooltool-manifests/linux64/hostutils.manifest""" + if os.path.exists(local_dest): + LOG.info("file already exists at: %s" % local_dest) + if not extract: + return True + else: + LOG.info("downloading: %s to %s" % (url, local_dest)) + try: + retry(urlretrieve, args=(url, local_dest), attempts=3, sleeptime=5) + except Exception: + LOG.error("Failed to download file: %s" % local_dest, exc_info=True) + if os.path.exists(local_dest): + # delete partial downloaded file + os.remove(local_dest) + return False + + if not extract: + return os.path.exists(local_dest) + + typ = archive_type(local_dest) + if typ is None: + LOG.info("Not able to determine archive type for: %s" % local_dest) + return False + + extract_archive(local_dest, os.path.dirname(local_dest), typ) + return True + + +def get_available_port(): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("", 0)) + s.listen(1) + port = s.getsockname()[1] + s.close() + return port diff --git a/testing/mozbase/mozproxy/setup.py b/testing/mozbase/mozproxy/setup.py new file mode 100644 index 0000000000..25fefaa51b --- /dev/null +++ b/testing/mozbase/mozproxy/setup.py @@ -0,0 +1,37 @@ +# 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 setuptools import setup + +PACKAGE_NAME = "mozproxy" +PACKAGE_VERSION = "1.0" + +# dependencies +deps = ["redo", "mozinfo", "mozlog >= 6.0"] + +setup( + name=PACKAGE_NAME, + version=PACKAGE_VERSION, + description="Proxy for playback", + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + classifiers=[ + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.5", + ], + # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + keywords="mozilla", + author="Mozilla Automation and Tools team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + license="MPL", + packages=["mozproxy"], + install_requires=deps, + entry_points={ + "console_scripts": [ + "mozproxy=mozproxy.driver:main", + ], + }, + include_package_data=True, + zip_safe=False, +) diff --git a/testing/mozbase/mozproxy/tests/__init__.py b/testing/mozbase/mozproxy/tests/__init__.py new file mode 100644 index 0000000000..792d600548 --- /dev/null +++ b/testing/mozbase/mozproxy/tests/__init__.py @@ -0,0 +1 @@ +# diff --git a/testing/mozbase/mozproxy/tests/archive.tar.gz b/testing/mozbase/mozproxy/tests/archive.tar.gz Binary files differnew file mode 100644 index 0000000000..b4f9461b09 --- /dev/null +++ b/testing/mozbase/mozproxy/tests/archive.tar.gz diff --git a/testing/mozbase/mozproxy/tests/example.dump b/testing/mozbase/mozproxy/tests/example.dump Binary files differnew file mode 100644 index 0000000000..aee6249ac7 --- /dev/null +++ b/testing/mozbase/mozproxy/tests/example.dump diff --git a/testing/mozbase/mozproxy/tests/files/mitm5-linux-firefox-amazon.manifest b/testing/mozbase/mozproxy/tests/files/mitm5-linux-firefox-amazon.manifest new file mode 100644 index 0000000000..0060b25393 --- /dev/null +++ b/testing/mozbase/mozproxy/tests/files/mitm5-linux-firefox-amazon.manifest @@ -0,0 +1,10 @@ +[ + { + "algorithm": "sha512", + "digest": "d801dc23873ef5fac668aa58fa948f5de0d9f3ccc53d6773fb5a137515bd04e72cc8c0c7975c6e1fc19c72b3d721effb5432fce78b0ca6f3a90f2d6467ee5b68", + "filename": "mitm5-linux-firefox-amazon.zip", + "size": 6588776, + "unpack": true, + "visibility": "public" + } +] diff --git a/testing/mozbase/mozproxy/tests/files/mitm5-linux-firefox-amazon.zip b/testing/mozbase/mozproxy/tests/files/mitm5-linux-firefox-amazon.zip Binary files differnew file mode 100644 index 0000000000..8c724762d3 --- /dev/null +++ b/testing/mozbase/mozproxy/tests/files/mitm5-linux-firefox-amazon.zip diff --git a/testing/mozbase/mozproxy/tests/files/recording.zip b/testing/mozbase/mozproxy/tests/files/recording.zip Binary files differnew file mode 100644 index 0000000000..7cea81a5e6 --- /dev/null +++ b/testing/mozbase/mozproxy/tests/files/recording.zip diff --git a/testing/mozbase/mozproxy/tests/firefox b/testing/mozbase/mozproxy/tests/firefox new file mode 100644 index 0000000000..4a938f06f7 --- /dev/null +++ b/testing/mozbase/mozproxy/tests/firefox @@ -0,0 +1 @@ +# I am firefox diff --git a/testing/mozbase/mozproxy/tests/manifest.toml b/testing/mozbase/mozproxy/tests/manifest.toml new file mode 100644 index 0000000000..f0a081dee4 --- /dev/null +++ b/testing/mozbase/mozproxy/tests/manifest.toml @@ -0,0 +1,16 @@ +[DEFAULT] +subsuite = "mozbase" + +["test_command_line.py"] +run-if = ["python == 3"] # The mozproxy command line interface is designed to run on Python 3. + +["test_mitm_addons.py"] +run-if = ["python == 3"] # The mitm addons are designed to run on Python 3. + +["test_proxy.py"] + +["test_recording.py"] + +["test_recordings.py"] + +["test_utils.py"] diff --git a/testing/mozbase/mozproxy/tests/paypal.mp b/testing/mozbase/mozproxy/tests/paypal.mp new file mode 100644 index 0000000000..8e48bd50de --- /dev/null +++ b/testing/mozbase/mozproxy/tests/paypal.mp @@ -0,0 +1 @@ +# fake recorded playback diff --git a/testing/mozbase/mozproxy/tests/support.py b/testing/mozbase/mozproxy/tests/support.py new file mode 100644 index 0000000000..a3367852ad --- /dev/null +++ b/testing/mozbase/mozproxy/tests/support.py @@ -0,0 +1,14 @@ +import contextlib +import shutil +import tempfile + + +# This helper can be replaced by pytest tmpdir fixture +# once Bug 1536029 lands (@mock.patch disturbs pytest fixtures) +@contextlib.contextmanager +def tempdir(): + dest_dir = tempfile.mkdtemp() + try: + yield dest_dir + finally: + shutil.rmtree(dest_dir, ignore_errors=True) diff --git a/testing/mozbase/mozproxy/tests/test_command_line.py b/testing/mozbase/mozproxy/tests/test_command_line.py new file mode 100644 index 0000000000..e3f1ccd060 --- /dev/null +++ b/testing/mozbase/mozproxy/tests/test_command_line.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python +import json +import os +import re +import signal +import subprocess +import sys +import threading +import time + +import mozunit +from buildconfig import topobjdir, topsrcdir + +here = os.path.dirname(__file__) + +if os.name == "nt": + PROCESS_CREATION_FLAGS = subprocess.CREATE_NEW_PROCESS_GROUP +else: + PROCESS_CREATION_FLAGS = 0 + + +# This is copied from <python/mozperftest/mozperftest/utils.py>. It's copied +# instead of imported since mozperfest is Python 3, and this file is +# (currently) Python 2. +def _install_package(virtualenv_manager, package): + from pip._internal.req.constructors import install_req_from_line + + req = install_req_from_line(package) + req.check_if_exists(use_user_site=False) + # already installed, check if it's in our venv + if req.satisfied_by is not None: + venv_site_lib = os.path.abspath( + os.path.join(virtualenv_manager.bin_path, "..", "lib") + ) + site_packages = os.path.abspath(req.satisfied_by.location) + if site_packages.startswith(venv_site_lib): + # already installed in this venv, we can skip + return + + subprocess.check_call( + [ + virtualenv_manager.python_path, + "-m", + "pip", + "install", + package, + ] + ) + + +def _kill_mozproxy(pid): + kill_signal = getattr(signal, "CTRL_BREAK_EVENT", signal.SIGINT) + os.kill(pid, kill_signal) + + +class OutputHandler(object): + def __init__(self): + self.port = None + self.port_event = threading.Event() + + def __call__(self, line): + line = line.rstrip(b"\r\n") + if not line.strip(): + return + line = line.decode("utf-8", errors="replace") + # Print the output we received so we have useful logs if a test fails. + print(line) + + try: + data = json.loads(line) + except ValueError: + return + + if isinstance(data, dict) and "action" in data: + # Retrieve the port number for the proxy server from the logs of + # our subprocess. + m = re.match(r"Proxy running on port (\d+)", data.get("message", "")) + if m: + self.port = m.group(1) + self.port_event.set() + + def finished(self): + self.port_event.set() + + +def test_help(): + p = subprocess.run([sys.executable, "-m", "mozproxy", "--help"]) + assert p.returncode == 0 + + +def test_run_record_no_files(): + output_handler = OutputHandler() + p = subprocess.Popen( + [ + sys.executable, + "-m", + "mozproxy", + "--local", + "--mode=record", + "--binary=firefox", + "--topsrcdir=" + topsrcdir, + "--objdir=" + topobjdir, + ], + creationflags=PROCESS_CREATION_FLAGS, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=False, + ) + + for line in p.stdout: + output_handler(line) + if output_handler.port_event.is_set(): + break + output_handler.finished() + # The first time we run mozproxy, we need to fetch mitmproxy, which can + # take a while... + assert output_handler.port_event.wait(120) is True + # Give mitmproxy a bit of time to start up so we can verify that it's + # actually running before we kill mozproxy. + time.sleep(5) + _kill_mozproxy(p.pid) + + # Assert process raises error + assert p.wait(10) == 2 + assert output_handler.port is None + + +def test_run_record_multiple_files(): + output_handler = OutputHandler() + p = subprocess.Popen( + [ + sys.executable, + "-m", + "mozproxy", + "--local", + "--mode=record", + "--binary=firefox", + "--topsrcdir=" + topsrcdir, + "--objdir=" + topobjdir, + os.path.join(here, "files", "new_record.zip"), + os.path.join(here, "files", "new_record2.zip"), + ], + creationflags=PROCESS_CREATION_FLAGS, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=False, + ) + + for line in p.stdout: + output_handler(line) + if output_handler.port_event.is_set(): + break + output_handler.finished() + # The first time we run mozproxy, we need to fetch mitmproxy, which can + # take a while... + assert output_handler.port_event.wait(120) is True + # Give mitmproxy a bit of time to start up so we can verify that it's + # actually running before we kill mozproxy. + time.sleep(5) + _kill_mozproxy(p.pid) + + assert p.wait(10) == 4 + assert output_handler.port is None + + +def test_run_record(): + output_handler = OutputHandler() + p = subprocess.Popen( + [ + sys.executable, + "-m", + "mozproxy", + "--local", + "--mode=record", + "--binary=firefox", + "--topsrcdir=" + topsrcdir, + "--objdir=" + topobjdir, + os.path.join(here, "files", "record.zip"), + ], + creationflags=PROCESS_CREATION_FLAGS, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=False, + ) + + for line in p.stdout: + output_handler(line) + if output_handler.port_event.is_set(): + break + output_handler.finished() + try: + # The first time we run mozproxy, we need to fetch mitmproxy, which can + # take a while... + assert output_handler.port_event.wait(120) is True + # Give mitmproxy a bit of time to start up so we can verify that it's + # actually running before we kill mozproxy. + time.sleep(5) + _kill_mozproxy(p.pid) + + assert p.wait(10) == 0 + assert output_handler.port is not None + finally: + os.remove(os.path.join(here, "files", "record.zip")) + + +def test_run_playback(): + output_handler = OutputHandler() + p = subprocess.Popen( + [ + sys.executable, + "-m", + "mozproxy", + "--local", + "--binary=firefox", + "--topsrcdir=" + topsrcdir, + "--objdir=" + topobjdir, + os.path.join(here, "files", "mitm5-linux-firefox-amazon.zip"), + ], + creationflags=PROCESS_CREATION_FLAGS, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=False, + ) + + for line in p.stdout: + output_handler(line) + if output_handler.port_event.is_set(): + break + output_handler.finished() + # The first time we run mozproxy, we need to fetch mitmproxy, which can + # take a while... + assert output_handler.port_event.wait(120) is True + # Give mitmproxy a bit of time to start up so we can verify that it's + # actually running before we kill mozproxy. + time.sleep(5) + _kill_mozproxy(p.pid) + + assert p.wait(10) == 0 + assert output_handler.port is not None + + +def test_failure(): + output_handler = OutputHandler() + p = subprocess.Popen( + [ + sys.executable, + "-m", + "mozproxy", + "--local", + # Exclude some options here to trigger a command-line error. + os.path.join(here, "files", "mitm5-linux-firefox-amazon.zip"), + ], + creationflags=PROCESS_CREATION_FLAGS, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=False, + ) + for line in p.stdout: + output_handler(line) + if output_handler.port_event.is_set(): + break + output_handler.finished() + assert output_handler.port_event.wait(10) is True + assert p.wait(10) == 2 + assert output_handler.port is None + + +if __name__ == "__main__": + mozunit.main(runwith="pytest") diff --git a/testing/mozbase/mozproxy/tests/test_mitm_addons.py b/testing/mozbase/mozproxy/tests/test_mitm_addons.py new file mode 100644 index 0000000000..ed3805ef9d --- /dev/null +++ b/testing/mozbase/mozproxy/tests/test_mitm_addons.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python +import json +import os +from unittest import mock + +import mozunit + +here = os.path.dirname(__file__) +os.environ["MOZPROXY_DIR"] = os.path.join(here, "files") + +protocol = { + "http_protocol": {"aax-us-iad.amazon.com": "HTTP/1.1"}, + "recorded_requests": 4, + "recorded_requests_unique": 1, +} + + +@mock.patch( + "mozproxy.backends.mitm.scripts.http_protocol_extractor.HttpProtocolExtractor.get_ctx" +) +def test_http_protocol_generate_json_file(ctx_mock): + ctx_mock.return_value.options.save_stream_file = os.path.join( + os.environ["MOZPROXY_DIR"], "http_protocol_recording_done.mp" + ) + + from mozproxy.backends.mitm.scripts.http_protocol_extractor import ( + HttpProtocolExtractor, + ) + + test_http_protocol = HttpProtocolExtractor() + test_http_protocol.ctx = test_http_protocol.get_ctx() + + # test data + test_http_protocol.request_protocol = protocol["http_protocol"] + test_http_protocol.hashes = ["Hash string"] + test_http_protocol.request_count = protocol["recorded_requests"] + + test_http_protocol.done() + + json_path = os.path.join( + os.environ["MOZPROXY_DIR"], "http_protocol_recording_done.json" + ) + assert os.path.exists(json_path) + with open(json_path) as json_file: + output_data = json.load(json_file) + + assert output_data["recorded_requests"] == protocol["recorded_requests"] + assert ( + output_data["recorded_requests_unique"] + == protocol["recorded_requests_unique"] + ) + assert output_data["http_protocol"] == protocol["http_protocol"] + + +@mock.patch( + "mozproxy.backends.mitm.scripts.http_protocol_extractor.HttpProtocolExtractor.get_ctx" +) +def test_http_protocol_response(ctx_mock): + ctx_mock.return_value.options.save_stream_file = os.path.join( + os.environ["MOZPROXY_DIR"], "http_protocol_recording_done.mp" + ) + + from mozproxy.backends.mitm.scripts.http_protocol_extractor import ( + HttpProtocolExtractor, + ) + + test_http_protocol = HttpProtocolExtractor() + test_http_protocol.ctx = test_http_protocol.get_ctx() + + # test data + flow = mock.MagicMock() + flow.type = "http" + flow.request.url = "https://www.google.com/complete/search" + flow.request.port = 33 + flow.response.data.http_version = b"HTTP/1.1" + + test_http_protocol.request_protocol = {} + test_http_protocol.hashes = [] + test_http_protocol.request_count = 0 + + test_http_protocol.response(flow) + + assert test_http_protocol.request_count == 1 + assert len(test_http_protocol.hashes) == 1 + assert test_http_protocol.request_protocol["www.google.com"] == "HTTP/1.1" + + +if __name__ == "__main__": + mozunit.main(runwith="pytest") diff --git a/testing/mozbase/mozproxy/tests/test_proxy.py b/testing/mozbase/mozproxy/tests/test_proxy.py new file mode 100644 index 0000000000..c5d21fd43c --- /dev/null +++ b/testing/mozbase/mozproxy/tests/test_proxy.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python +import os +from unittest import mock + +import mozinfo +import mozunit +import requests +from mozproxy import get_playback +from support import tempdir + +here = os.path.dirname(__file__) + + +class Process: + def __init__(self, *args, **kw): + pass + + def run(self): + print("I am running something") + + def poll(self): + return None + + def communicate(self): + return (["mock stdout"], ["mock stderr"]) + + def wait(self): + return 0 + + def kill(self, sig=9): + pass + + proc = object() + pid = 1234 + stderr = stdout = [] + returncode = 0 + + +_RETRY = 0 + + +class ProcessWithRetry(Process): + def __init__(self, *args, **kw): + Process.__init__(self, *args, **kw) + + def wait(self): + global _RETRY + _RETRY += 1 + if _RETRY >= 2: + _RETRY = 0 + return 0 + return -1 + + +def kill(pid, signal): + if pid == 1234: + return + return os.kill(pid, signal) + + +def get_status_code(url, playback): + response = requests.get( + url=url, proxies={"http": "http://%s:%s/" % (playback.host, playback.port)} + ) + return response.status_code + + +def cleanup(): + # some tests create this file as a side-effect + policies_file = os.path.join("distribution", "policies.json") + try: + if os.path.exists(policies_file): + os.remove(policies_file) + except PermissionError: + pass + + +def test_mitm_check_proxy(*args): + # test setup + pageset_name = os.path.join(here, "files", "mitm5-linux-firefox-amazon.manifest") + + config = { + "playback_tool": "mitmproxy", + "playback_files": [os.path.join(here, "files", pageset_name)], + "playback_version": "8.1.1", + "platform": mozinfo.os, + "run_local": "MOZ_AUTOMATION" not in os.environ, + "binary": "firefox", + "app": "firefox", + "host": "127.0.0.1", + } + + with tempdir() as obj_path: + config["obj_path"] = obj_path + playback = get_playback(config) + assert playback is not None + + try: + playback.start() + + url = "https://m.media-amazon.com/images/G/01/csm/showads.v2.js" + assert get_status_code(url, playback) == 200 + + url = "http://mozproxy/checkProxy" + assert get_status_code(url, playback) == 404 + finally: + playback.stop() + cleanup() + + +@mock.patch("mozproxy.backends.mitm.Mitmproxy.check_proxy") +@mock.patch("mozproxy.backends.mitm.mitm.ProcessHandler", new=Process) +@mock.patch("mozproxy.utils.Popen", new=Process) +@mock.patch("os.kill", new=kill) +def test_mitm(*args): + pageset_name = os.path.join(here, "files", "mitm5-linux-firefox-amazon.manifest") + + config = { + "playback_tool": "mitmproxy", + "playback_files": [pageset_name], + "playback_version": "8.1.1", + "platform": mozinfo.os, + "run_local": True, + "binary": "firefox", + "app": "firefox", + "host": "example.com", + } + + with tempdir() as obj_path: + config["obj_path"] = obj_path + playback = get_playback(config) + assert playback is not None + try: + playback.start() + finally: + playback.stop() + cleanup() + + +@mock.patch("mozproxy.backends.mitm.Mitmproxy.check_proxy") +@mock.patch("mozproxy.backends.mitm.mitm.ProcessHandler", new=Process) +@mock.patch("mozproxy.utils.Popen", new=Process) +@mock.patch("os.kill", new=kill) +def test_playback_setup_failed(*args): + class SetupFailed(Exception): + pass + + def setup(*args, **kw): + def _s(self): + raise SetupFailed("Failed") + + return _s + + pageset_name = os.path.join(here, "files", "mitm5-linux-firefox-amazon.manifest") + + config = { + "playback_tool": "mitmproxy", + "playback_files": [pageset_name], + "playback_version": "4.0.4", + "platform": mozinfo.os, + "run_local": True, + "binary": "firefox", + "app": "firefox", + "host": "example.com", + } + + prefix = "mozproxy.backends.mitm.desktop.MitmproxyDesktop." + + with tempdir() as obj_path: + config["obj_path"] = obj_path + with mock.patch(prefix + "setup", new_callable=setup): + with mock.patch(prefix + "stop_mitmproxy_playback") as p: + try: + pb = get_playback(config) + pb.start() + except SetupFailed: + assert p.call_count == 1 + except Exception: + raise + + +@mock.patch("mozproxy.backends.mitm.Mitmproxy.check_proxy") +@mock.patch("mozproxy.backends.mitm.mitm.ProcessHandler", new=ProcessWithRetry) +@mock.patch("mozproxy.utils.Popen", new=ProcessWithRetry) +@mock.patch("os.kill", new=kill) +def test_mitm_with_retry(*args): + pageset_name = os.path.join(here, "files", "mitm5-linux-firefox-amazon.manifest") + + config = { + "playback_tool": "mitmproxy", + "playback_files": [pageset_name], + "playback_version": "8.1.1", + "platform": mozinfo.os, + "run_local": True, + "binary": "firefox", + "app": "firefox", + "host": "example.com", + } + + with tempdir() as obj_path: + config["obj_path"] = obj_path + playback = get_playback(config) + assert playback is not None + try: + playback.start() + finally: + playback.stop() + cleanup() + + +if __name__ == "__main__": + mozunit.main(runwith="pytest") diff --git a/testing/mozbase/mozproxy/tests/test_recording.py b/testing/mozbase/mozproxy/tests/test_recording.py new file mode 100644 index 0000000000..632b007148 --- /dev/null +++ b/testing/mozbase/mozproxy/tests/test_recording.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python +import datetime +import os +from builtins import Exception + +import mozinfo +import mozunit +import requests +from mozproxy import get_playback +from support import tempdir + +here = os.path.dirname(__file__) +os.environ["MOZPROXY_DIR"] = os.path.join(here, "files") + + +def get_status_code(url, playback): + response = requests.get( + url=url, proxies={"http": "http://%s:%s/" % (playback.host, playback.port)} + ) + return response.status_code + + +def test_record_and_replay(*args): + # test setup + + basename = "recording" + suffix = datetime.datetime.now().strftime("%y%m%d_%H%M%S") + filename = "_".join([basename, suffix]) + recording_file = os.path.join(here, "files", ".".join([filename, "zip"])) + + # Record part + config = { + "playback_tool": "mitmproxy", + "recording_file": recording_file, + "playback_version": "8.1.1", + "platform": mozinfo.os, + "run_local": "MOZ_AUTOMATION" not in os.environ, + "binary": "firefox", + "app": "firefox", + "host": "127.0.0.1", + "record": True, + } + + with tempdir() as obj_path: + config["obj_path"] = obj_path + record = get_playback(config) + assert record is not None + + try: + record.start() + + url = "https://m.media-amazon.com/images/G/01/csm/showads.v2.js" + assert get_status_code(url, record) == 200 + finally: + record.stop() + + # playback part + config["record"] = False + config["recording_file"] = None + config["playback_files"] = [recording_file] + playback = get_playback(config) + assert playback is not None + try: + playback.start() + + url = "https://m.media-amazon.com/images/G/01/csm/showads.v2.js" + assert get_status_code(url, playback) == 200 + finally: + playback.stop() + + # Cleanup + try: + os.remove(recording_file) + os.remove(os.path.join("distribution", "policies.json")) + except Exception: + pass + + +if __name__ == "__main__": + mozunit.main(runwith="pytest") diff --git a/testing/mozbase/mozproxy/tests/test_recordings.py b/testing/mozbase/mozproxy/tests/test_recordings.py new file mode 100644 index 0000000000..9373907cea --- /dev/null +++ b/testing/mozbase/mozproxy/tests/test_recordings.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python +import os + +import mozunit +from mozproxy.recordings import RecordingFile + +here = os.path.dirname(__file__) +os.environ["MOZPROXY_DIR"] = os.path.join(here, "files") + + +def test_recording_generation(*args): + test_file = os.path.join(here, "files", "new_file.zip") + file = RecordingFile(test_file) + with open(file.recording_path, "w") as recording: + recording.write("This is a recording") + + file.set_metadata("test_file", True) + file.generate_zip_file() + + assert os.path.exists(test_file) + os.remove(test_file) + os.remove(file.recording_path) + os.remove(file._metadata_path) + + +def test_recording_content(*args): + test_file = os.path.join(here, "files", "recording.zip") + file = RecordingFile(test_file) + + assert file.metadata("test_file") is True + assert os.path.exists(file.recording_path) + os.remove(file.recording_path) + os.remove(file._metadata_path) + + +if __name__ == "__main__": + mozunit.main(runwith="pytest") diff --git a/testing/mozbase/mozproxy/tests/test_utils.py b/testing/mozbase/mozproxy/tests/test_utils.py new file mode 100644 index 0000000000..7cd661dee2 --- /dev/null +++ b/testing/mozbase/mozproxy/tests/test_utils.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python +import os +import shutil +from unittest import mock + +import mozunit +from mozproxy.utils import download_file_from_url +from support import tempdir + +here = os.path.dirname(__file__) + + +def urlretrieve(*args, **kw): + def _urlretrieve(url, local_dest): + # simply copy over our tarball + shutil.copyfile(os.path.join(here, "archive.tar.gz"), local_dest) + return local_dest, {} + + return _urlretrieve + + +@mock.patch("mozproxy.utils.urlretrieve", new_callable=urlretrieve) +def test_download_file(*args): + with tempdir() as dest_dir: + dest = os.path.join(dest_dir, "archive.tar.gz") + download_file_from_url("http://example.com/archive.tar.gz", dest, extract=True) + # archive.tar.gz contains hey.txt, if it worked we should see it + assert os.path.exists(os.path.join(dest_dir, "hey.txt")) + + +if __name__ == "__main__": + mozunit.main(runwith="pytest") diff --git a/testing/mozbase/mozrunner/mozrunner/__init__.py b/testing/mozbase/mozrunner/mozrunner/__init__.py new file mode 100644 index 0000000000..13cb5e428f --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/__init__.py @@ -0,0 +1,12 @@ +# flake8: noqa +# 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/. + +import mozrunner.base +import mozrunner.devices +import mozrunner.utils + +from .cli import * +from .errors import * +from .runners import * diff --git a/testing/mozbase/mozrunner/mozrunner/application.py b/testing/mozbase/mozrunner/mozrunner/application.py new file mode 100644 index 0000000000..bbafa9ff5a --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/application.py @@ -0,0 +1,156 @@ +# 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/. + +import os +import posixpath +from abc import ABCMeta, abstractmethod +from shutil import which + +import six +from mozdevice import ADBDeviceFactory +from mozprofile import ( + ChromeProfile, + ChromiumProfile, + FirefoxProfile, + Profile, + ThunderbirdProfile, +) + +here = os.path.abspath(os.path.dirname(__file__)) + + +def get_app_context(appname): + context_map = { + "chrome": ChromeContext, + "chromium": ChromiumContext, + "default": DefaultContext, + "fennec": FennecContext, + "firefox": FirefoxContext, + "thunderbird": ThunderbirdContext, + } + if appname not in context_map: + raise KeyError("Application '%s' not supported!" % appname) + return context_map[appname] + + +class DefaultContext(object): + profile_class = Profile + + +@six.add_metaclass(ABCMeta) +class RemoteContext(object): + device = None + _remote_profile = None + _adb = None + profile_class = Profile + _bindir = None + remote_test_root = "" + remote_process = None + + @property + def bindir(self): + if self._bindir is None: + paths = [which("emulator")] + paths = [p for p in paths if p is not None if os.path.isfile(p)] + if not paths: + self._bindir = "" + else: + self._bindir = os.path.dirname(paths[0]) + return self._bindir + + @property + def adb(self): + if not self._adb: + paths = [ + os.environ.get("ADB"), + os.environ.get("ADB_PATH"), + self.which("adb"), + ] + paths = [p for p in paths if p is not None if os.path.isfile(p)] + if not paths: + raise OSError( + "Could not find the adb binary, make sure it is on your" + "path or set the $ADB_PATH environment variable." + ) + self._adb = paths[0] + return self._adb + + @property + def remote_profile(self): + if not self._remote_profile: + self._remote_profile = posixpath.join(self.remote_test_root, "profile") + return self._remote_profile + + def which(self, binary): + paths = os.environ.get("PATH", {}).split(os.pathsep) + if self.bindir is not None and os.path.abspath(self.bindir) not in paths: + paths.insert(0, os.path.abspath(self.bindir)) + os.environ["PATH"] = os.pathsep.join(paths) + + return which(binary) + + @abstractmethod + def stop_application(self): + """Run (device manager) command to stop application.""" + pass + + +devices = {} + + +class FennecContext(RemoteContext): + _remote_profiles_ini = None + _remote_test_root = None + + def __init__(self, app=None, adb_path=None, avd_home=None, device_serial=None): + self._adb = adb_path + self.avd_home = avd_home + self.remote_process = app + self.device_serial = device_serial + self.device = self.get_device(self.adb, device_serial) + + def get_device(self, adb_path, device_serial): + # Create a mozdevice.ADBDevice object for the specified device_serial + # and cache it for future use. If the same device_serial is subsequently + # requested, retrieve it from the cache to avoid costly re-initialization. + global devices + if device_serial in devices: + device = devices[device_serial] + else: + device = ADBDeviceFactory(adb=adb_path, device=device_serial) + devices[device_serial] = device + return device + + def stop_application(self): + self.device.stop_application(self.remote_process) + + @property + def remote_test_root(self): + if not self._remote_test_root: + self._remote_test_root = self.device.test_root + return self._remote_test_root + + @property + def remote_profiles_ini(self): + if not self._remote_profiles_ini: + self._remote_profiles_ini = posixpath.join( + "/data", "data", self.remote_process, "files", "mozilla", "profiles.ini" + ) + return self._remote_profiles_ini + + +class FirefoxContext(object): + profile_class = FirefoxProfile + + +class ThunderbirdContext(object): + profile_class = ThunderbirdProfile + + +class ChromeContext(object): + profile_class = ChromeProfile + + +class ChromiumContext(object): + profile_class = ChromiumProfile diff --git a/testing/mozbase/mozrunner/mozrunner/base/__init__.py b/testing/mozbase/mozrunner/mozrunner/base/__init__.py new file mode 100644 index 0000000000..373dde1807 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/base/__init__.py @@ -0,0 +1,8 @@ +# 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/. + +# flake8: noqa +from .browser import BlinkRuntimeRunner, GeckoRuntimeRunner +from .device import DeviceRunner, FennecRunner +from .runner import BaseRunner diff --git a/testing/mozbase/mozrunner/mozrunner/base/browser.py b/testing/mozbase/mozrunner/mozrunner/base/browser.py new file mode 100644 index 0000000000..0d7b88adc5 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/base/browser.py @@ -0,0 +1,122 @@ +# 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/. + +import copy +import os +import sys + +import mozinfo + +from ..application import DefaultContext, FirefoxContext +from .runner import BaseRunner + + +class GeckoRuntimeRunner(BaseRunner): + """ + The base runner class used for local gecko runtime binaries, + such as Firefox and Thunderbird. + """ + + def __init__(self, binary, cmdargs=None, **runner_args): + self.show_crash_reporter = runner_args.pop("show_crash_reporter", False) + BaseRunner.__init__(self, **runner_args) + + self.binary = binary + self.cmdargs = copy.copy(cmdargs) or [] + + if ( + mozinfo.isWin + and ( + isinstance(self.app_ctx, FirefoxContext) + or isinstance(self.app_ctx, DefaultContext) + ) + and "--wait-for-browser" not in self.cmdargs + ): + # The launcher process is present in this configuration. Always + # pass this flag so that we can wait for the browser to complete + # its execution. + self.cmdargs.append("--wait-for-browser") + + # allows you to run an instance of Firefox separately from any other instances + self.env["MOZ_NO_REMOTE"] = "1" + + # Disable crash reporting dialogs that interfere with debugging + self.env["GNOME_DISABLE_CRASH_DIALOG"] = "1" + self.env["XRE_NO_WINDOWS_CRASH_DIALOG"] = "1" + + # set the library path if needed on linux + if sys.platform == "linux2" and self.binary.endswith("-bin"): + dirname = os.path.dirname(self.binary) + if os.environ.get("LD_LIBRARY_PATH", None): + self.env["LD_LIBRARY_PATH"] = "%s:%s" % ( + os.environ["LD_LIBRARY_PATH"], + dirname, + ) + else: + self.env["LD_LIBRARY_PATH"] = dirname + + @property + def command(self): + command = [self.binary, "-profile", self.profile.profile] + + _cmdargs = [i for i in self.cmdargs if i != "-foreground"] + if len(_cmdargs) != len(self.cmdargs): + # foreground should be last; see + # https://bugzilla.mozilla.org/show_bug.cgi?id=625614 + self.cmdargs = _cmdargs + self.cmdargs.append("-foreground") + if mozinfo.isMac and "-foreground" not in self.cmdargs: + # runner should specify '-foreground' on Mac; see + # https://bugzilla.mozilla.org/show_bug.cgi?id=916512 + self.cmdargs.append("-foreground") + + # Bug 775416 - Ensure that binary options are passed in first + command[1:1] = self.cmdargs + return command + + def start(self, *args, **kwargs): + # ensure the profile exists + if not self.profile.exists(): + self.profile.reset() + assert self.profile.exists(), ( + "%s : failure to reset profile" % self.__class__.__name__ + ) + + has_debugger = "debug_args" in kwargs and kwargs["debug_args"] + if has_debugger: + self.env["MOZ_CRASHREPORTER_DISABLE"] = "1" + else: + if not self.show_crash_reporter: + # hide the crash reporter window + self.env["MOZ_CRASHREPORTER_NO_REPORT"] = "1" + self.env["MOZ_CRASHREPORTER"] = "1" + + BaseRunner.start(self, *args, **kwargs) + + +class BlinkRuntimeRunner(BaseRunner): + """A base runner class for running apps like Google Chrome or Chromium.""" + + def __init__(self, binary, cmdargs=None, **runner_args): + super(BlinkRuntimeRunner, self).__init__(**runner_args) + self.binary = binary + self.cmdargs = cmdargs or [] + + data_dir, name = os.path.split(self.profile.profile) + profile_args = [ + "--user-data-dir={}".format(data_dir), + "--profile-directory={}".format(name), + "--no-first-run", + ] + self.cmdargs.extend(profile_args) + + @property + def command(self): + cmd = self.cmdargs[:] + if self.profile.addons: + cmd.append("--load-extension={}".format(",".join(self.profile.addons))) + return [self.binary] + cmd + + def check_for_crashes(self, *args, **kwargs): + raise NotImplementedError diff --git a/testing/mozbase/mozrunner/mozrunner/base/device.py b/testing/mozbase/mozrunner/mozrunner/base/device.py new file mode 100644 index 0000000000..bf3c5965ff --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/base/device.py @@ -0,0 +1,199 @@ +# 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/. + +import codecs +import datetime +import re +import signal +import sys +import tempfile +import time + +import mozfile +import six + +from ..devices import BaseEmulator +from .runner import BaseRunner + + +class DeviceRunner(BaseRunner): + """ + The base runner class used for running gecko on + remote devices (or emulators). + """ + + env = { + "MOZ_CRASHREPORTER": "1", + "MOZ_CRASHREPORTER_NO_REPORT": "1", + "MOZ_CRASHREPORTER_SHUTDOWN": "1", + "MOZ_HIDE_RESULTS_TABLE": "1", + "MOZ_IN_AUTOMATION": "1", + "MOZ_LOG": "signaling:3,mtransport:4,DataChannel:4,jsep:4", + "R_LOG_LEVEL": "6", + "R_LOG_DESTINATION": "stderr", + "R_LOG_VERBOSE": "1", + } + + def __init__(self, device_class, device_args=None, **kwargs): + process_log = tempfile.NamedTemporaryFile(suffix="pidlog") + # the env will be passed to the device, it is not a *real* env + self._device_env = dict(DeviceRunner.env) + self._device_env["MOZ_PROCESS_LOG"] = process_log.name + # be sure we do not pass env to the parent class ctor + env = kwargs.pop("env", None) + if env: + self._device_env.update(env) + + if six.PY2: + stdout = codecs.getwriter("utf-8")(sys.stdout) + else: + stdout = codecs.getwriter("utf-8")(sys.stdout.buffer) + process_args = { + "stream": stdout, + "processOutputLine": self.on_output, + "onFinish": self.on_finish, + "onTimeout": self.on_timeout, + } + process_args.update(kwargs.get("process_args") or {}) + + kwargs["process_args"] = process_args + BaseRunner.__init__(self, **kwargs) + + device_args = device_args or {} + self.device = device_class(**device_args) + + @property + def command(self): + # command built by mozdevice -- see start() below + return None + + def start(self, *args, **kwargs): + if isinstance(self.device, BaseEmulator) and not self.device.connected: + self.device.start() + self.device.connect() + self.device.setup_profile(self.profile) + + app = self.app_ctx.remote_process + self.device.run_as_package = app + args = ["-no-remote", "-profile", self.app_ctx.remote_profile] + args.extend(self.cmdargs) + env = self._device_env + url = None + if "geckoview" in app: + activity = "TestRunnerActivity" + self.app_ctx.device.launch_activity( + app, activity, e10s=True, moz_env=env, extra_args=args, url=url + ) + else: + self.app_ctx.device.launch_fennec( + app, moz_env=env, extra_args=args, url=url + ) + + timeout = 10 # seconds + end_time = datetime.datetime.now() + datetime.timedelta(seconds=timeout) + while not self.is_running() and datetime.datetime.now() < end_time: + time.sleep(0.5) + if not self.is_running(): + print( + "timed out waiting for '%s' process to start" + % self.app_ctx.remote_process + ) + + def stop(self, sig=None): + if not sig and self.is_running(): + self.app_ctx.stop_application() + + if self.is_running(): + timeout = 10 + + self.app_ctx.device.pkill(self.app_ctx.remote_process, sig=sig) + if self.wait(timeout) is None and sig is not None: + print( + "timed out waiting for '{}' process to exit, trying " + "without signal {}".format(self.app_ctx.remote_process, sig) + ) + + # need to call adb stop otherwise the system will attempt to + # restart the process + self.app_ctx.stop_application() + if self.wait(timeout) is None: + print( + "timed out waiting for '{}' process to exit".format( + self.app_ctx.remote_process + ) + ) + + @property + def returncode(self): + """The returncode of the remote process. + + A value of None indicates the process is still running. Otherwise 0 is + returned, because there is no known way yet to retrieve the real exit code. + """ + if self.app_ctx.device.process_exist(self.app_ctx.remote_process): + return None + + return 0 + + def wait(self, timeout=None): + """Wait for the remote process to exit. + + :param timeout: if not None, will return after timeout seconds. + + :returns: the process return code or None if timeout was reached + and the process is still running. + """ + end_time = None + if timeout is not None: + end_time = datetime.datetime.now() + datetime.timedelta(seconds=timeout) + + while self.is_running(): + if end_time is not None and datetime.datetime.now() > end_time: + break + time.sleep(0.5) + + return self.returncode + + def on_output(self, line): + match = re.findall(r"TEST-START \| ([^\s]*)", line) + if match: + self.last_test = match[-1] + + def on_timeout(self): + self.stop(sig=signal.SIGABRT) + msg = "DeviceRunner TEST-UNEXPECTED-FAIL | %s | application timed out after %s seconds" + if self.timeout: + timeout = self.timeout + else: + timeout = self.output_timeout + msg = "%s with no output" % msg + + print(msg % (self.last_test, timeout)) + self.check_for_crashes() + + def on_finish(self): + self.check_for_crashes() + + def check_for_crashes(self, dump_save_path=None, test_name=None, **kwargs): + test_name = test_name or self.last_test + dump_dir = self.device.pull_minidumps() + crashed = BaseRunner.check_for_crashes( + self, + dump_directory=dump_dir, + dump_save_path=dump_save_path, + test_name=test_name, + **kwargs + ) + mozfile.remove(dump_dir) + return crashed + + def cleanup(self, *args, **kwargs): + BaseRunner.cleanup(self, *args, **kwargs) + self.device.cleanup() + + +class FennecRunner(DeviceRunner): + def __init__(self, cmdargs=None, **kwargs): + super(FennecRunner, self).__init__(**kwargs) + self.cmdargs = cmdargs or [] diff --git a/testing/mozbase/mozrunner/mozrunner/base/runner.py b/testing/mozbase/mozrunner/mozrunner/base/runner.py new file mode 100644 index 0000000000..e90813f918 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/base/runner.py @@ -0,0 +1,278 @@ +#!/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/. + +import os +import subprocess +import sys +import traceback +from abc import ABCMeta, abstractproperty + +import six +from mozlog import get_default_logger +from mozprocess import ProcessHandler +from six import ensure_str, string_types + +try: + import mozcrash +except ImportError: + mozcrash = None +from six import reraise + +from ..application import DefaultContext +from ..errors import RunnerNotStartedError + + +@six.add_metaclass(ABCMeta) +class BaseRunner(object): + """ + The base runner class for all mozrunner objects, both local and remote. + """ + + last_test = "mozrunner-startup" + process_handler = None + timeout = None + output_timeout = None + + def __init__( + self, + app_ctx=None, + profile=None, + clean_profile=True, + env=None, + process_class=None, + process_args=None, + symbols_path=None, + dump_save_path=None, + addons=None, + explicit_cleanup=False, + ): + self.app_ctx = app_ctx or DefaultContext() + + if isinstance(profile, string_types): + self.profile = self.app_ctx.profile_class(profile=profile, addons=addons) + else: + self.profile = profile or self.app_ctx.profile_class( + **getattr(self.app_ctx, "profile_args", {}) + ) + + self.logger = get_default_logger() + + # process environment + if env is None: + self.env = os.environ.copy() + else: + self.env = env.copy() + + self.clean_profile = clean_profile + self.process_class = process_class or ProcessHandler + self.process_args = process_args or {} + self.symbols_path = symbols_path + self.dump_save_path = dump_save_path + + self.crashed = 0 + self.explicit_cleanup = explicit_cleanup + + def __del__(self): + if not self.explicit_cleanup: + # If we're relying on the gc for cleanup do the same with the profile + self.cleanup(keep_profile=True) + + @abstractproperty + def command(self): + """Returns the command list to run.""" + pass + + @property + def returncode(self): + """ + The returncode of the process_handler. A value of None + indicates the process is still running. A negative + value indicates the process was killed with the + specified signal. + + :raises: RunnerNotStartedError + """ + if self.process_handler: + return self.process_handler.poll() + else: + raise RunnerNotStartedError("returncode accessed before runner started") + + def start( + self, debug_args=None, interactive=False, timeout=None, outputTimeout=None + ): + """ + Run self.command in the proper environment. + + :param debug_args: arguments for a debugger + :param interactive: uses subprocess.Popen directly + :param timeout: see process_handler.run() + :param outputTimeout: see process_handler.run() + :returns: the process id + + :raises: RunnerNotStartedError + """ + self.timeout = timeout + self.output_timeout = outputTimeout + cmd = self.command + + # ensure the runner is stopped + self.stop() + + # attach a debugger, if specified + if debug_args: + cmd = list(debug_args) + cmd + + if self.logger: + self.logger.info("Application command: %s" % " ".join(cmd)) + + str_env = {} + for k in self.env: + v = self.env[k] + str_env[ensure_str(k)] = ensure_str(v) + + if interactive: + self.process_handler = subprocess.Popen(cmd, env=str_env) + # TODO: other arguments + else: + # this run uses the managed processhandler + try: + process = self.process_class(cmd, env=str_env, **self.process_args) + process.run(self.timeout, self.output_timeout) + + self.process_handler = process + except Exception as e: + reraise( + RunnerNotStartedError, + RunnerNotStartedError("Failed to start the process: {}".format(e)), + sys.exc_info()[2], + ) + + self.crashed = 0 + return self.process_handler.pid + + def wait(self, timeout=None): + """ + Wait for the process to exit. + + :param timeout: if not None, will return after timeout seconds. + Timeout is ignored if interactive was set to True. + :returns: the process return code if process exited normally, + -<signal> if process was killed (Unix only), + None if timeout was reached and the process is still running. + :raises: RunnerNotStartedError + """ + if self.is_running(): + # The interactive mode uses directly a Popen process instance. It's + # wait() method doesn't have any parameters. So handle it separately. + if isinstance(self.process_handler, subprocess.Popen): + self.process_handler.wait() + else: + self.process_handler.wait(timeout) + + elif not self.process_handler: + raise RunnerNotStartedError("Wait() called before process started") + + return self.returncode + + def is_running(self): + """ + Checks if the process is running. + + :returns: True if the process is active + """ + return self.returncode is None + + def stop(self, sig=None, timeout=None): + """ + Kill the process. + + :param sig: Signal used to kill the process, defaults to SIGKILL + (has no effect on Windows). + :param timeout: Maximum time to wait for the processs to exit + (has no effect on Windows). + :returns: the process return code if process was already stopped, + -<signal> if process was killed (Unix only) + :raises: RunnerNotStartedError + """ + try: + if not self.is_running(): + return self.returncode + except RunnerNotStartedError: + return + + # The interactive mode uses directly a Popen process instance. It's + # kill() method doesn't have any parameters. So handle it separately. + if isinstance(self.process_handler, subprocess.Popen): + self.process_handler.kill() + else: + self.process_handler.kill(sig=sig, timeout=timeout) + + return self.returncode + + def reset(self): + """ + Reset the runner to its default state. + """ + self.stop() + self.process_handler = None + + def check_for_crashes( + self, dump_directory=None, dump_save_path=None, test_name=None, quiet=False + ): + """Check for possible crashes and output the stack traces. + + :param dump_directory: Directory to search for minidump files + :param dump_save_path: Directory to save the minidump files to + :param test_name: Name to use in the crash output + :param quiet: If `True` don't print the PROCESS-CRASH message to stdout + + :returns: Number of crashes which have been detected since the last invocation + """ + crash_count = 0 + + if not dump_directory: + dump_directory = os.path.join(self.profile.profile, "minidumps") + + if not dump_save_path: + dump_save_path = self.dump_save_path + + if not test_name: + test_name = "runner.py" + + try: + if self.logger: + if mozcrash: + crash_count = mozcrash.log_crashes( + self.logger, + dump_directory, + self.symbols_path, + dump_save_path=dump_save_path, + test=test_name, + ) + else: + self.logger.warning("Can not log crashes without mozcrash") + else: + if mozcrash: + crash_count = mozcrash.check_for_crashes( + dump_directory, + self.symbols_path, + dump_save_path=dump_save_path, + test_name=test_name, + quiet=quiet, + ) + + self.crashed += crash_count + except Exception: + traceback.print_exc() + + return crash_count + + def cleanup(self, keep_profile=False): + """ + Cleanup all runner state + """ + self.stop() + if not keep_profile: + self.profile.cleanup() diff --git a/testing/mozbase/mozrunner/mozrunner/cli.py b/testing/mozbase/mozrunner/mozrunner/cli.py new file mode 100644 index 0000000000..941734de5e --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/cli.py @@ -0,0 +1,181 @@ +# 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/. + +import os +import sys + +from mozprofile import MozProfileCLI + +from .application import get_app_context +from .runners import runners +from .utils import findInPath + +# Map of debugging programs to information about them +# from http://mxr.mozilla.org/mozilla-central/source/build/automationutils.py#59 +DEBUGGERS = { + "gdb": { + "interactive": True, + "args": ["-q", "--args"], + }, + "valgrind": {"interactive": False, "args": ["--leak-check=full"]}, +} + + +def debugger_arguments(debugger, arguments=None, interactive=None): + """Finds debugger arguments from debugger given and defaults + + :param debugger: name or path to debugger + :param arguments: arguments for the debugger, or None to use defaults + :param interactive: whether the debugger should run in interactive mode + + """ + # find debugger executable if not a file + executable = debugger + if not os.path.exists(executable): + executable = findInPath(debugger) + if executable is None: + raise Exception("Path to '%s' not found" % debugger) + + # if debugger not in dictionary of knowns return defaults + dirname, debugger = os.path.split(debugger) + if debugger not in DEBUGGERS: + return ([executable] + (arguments or []), bool(interactive)) + + # otherwise use the dictionary values for arguments unless specified + if arguments is None: + arguments = DEBUGGERS[debugger].get("args", []) + if interactive is None: + interactive = DEBUGGERS[debugger].get("interactive", False) + return ([executable] + arguments, interactive) + + +class CLI(MozProfileCLI): + """Command line interface""" + + module = "mozrunner" + + def __init__(self, args=sys.argv[1:]): + MozProfileCLI.__init__(self, args=args) + + # choose appropriate runner and profile classes + app = self.options.app + try: + self.runner_class = runners[app] + self.profile_class = get_app_context(app).profile_class + except KeyError: + self.parser.error( + 'Application "%s" unknown (should be one of "%s")' + % (app, ", ".join(runners.keys())) + ) + + def add_options(self, parser): + """add options to the parser""" + parser.description = ( + "Reliable start/stop/configuration of Mozilla" + " Applications (Firefox, Thunderbird, etc.)" + ) + + # add profile options + MozProfileCLI.add_options(self, parser) + + # add runner options + parser.add_option( + "-b", + "--binary", + dest="binary", + help="Binary path.", + metavar=None, + default=None, + ) + parser.add_option( + "--app", + dest="app", + default="firefox", + help="Application to use [DEFAULT: %default]", + ) + parser.add_option( + "--app-arg", + dest="appArgs", + default=[], + action="append", + help="provides an argument to the test application", + ) + parser.add_option( + "--debugger", + dest="debugger", + help="run under a debugger, e.g. gdb or valgrind", + ) + parser.add_option( + "--debugger-args", + dest="debugger_args", + action="store", + help="arguments to the debugger", + ) + parser.add_option( + "--interactive", + dest="interactive", + action="store_true", + help="run the program interactively", + ) + + # methods for running + + def command_args(self): + """additional arguments for the mozilla application""" + # pylint --py3k: W1636 + return list(map(os.path.expanduser, self.options.appArgs)) + + def runner_args(self): + """arguments to instantiate the runner class""" + return dict(cmdargs=self.command_args(), binary=self.options.binary) + + def create_runner(self): + profile = self.profile_class(**self.profile_args()) + return self.runner_class(profile=profile, **self.runner_args()) + + def run(self): + runner = self.create_runner() + self.start(runner) + runner.cleanup() + + def debugger_arguments(self): + """Get the debugger arguments + + returns a 2-tuple of debugger arguments: + (debugger_arguments, interactive) + + """ + debug_args = self.options.debugger_args + if debug_args is not None: + debug_args = debug_args.split() + interactive = self.options.interactive + if self.options.debugger: + debug_args, interactive = debugger_arguments( + self.options.debugger, debug_args, interactive + ) + return debug_args, interactive + + def start(self, runner): + """Starts the runner and waits for the application to exit + + It can also happen via a keyboard interrupt. It should be + overwritten to provide custom running of the runner instance. + + """ + # attach a debugger if specified + debug_args, interactive = self.debugger_arguments() + runner.start(debug_args=debug_args, interactive=interactive) + print("Starting: " + " ".join(runner.command)) + try: + runner.wait() + except KeyboardInterrupt: + runner.stop() + + +def cli(args=sys.argv[1:]): + CLI(args).run() + + +if __name__ == "__main__": + cli() diff --git a/testing/mozbase/mozrunner/mozrunner/devices/__init__.py b/testing/mozbase/mozrunner/mozrunner/devices/__init__.py new file mode 100644 index 0000000000..7a4ec02e19 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/devices/__init__.py @@ -0,0 +1,17 @@ +# 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 mozrunner.devices import emulator_battery, emulator_geo, emulator_screen + +from .base import Device +from .emulator import BaseEmulator, EmulatorAVD + +__all__ = [ + "BaseEmulator", + "EmulatorAVD", + "Device", + "emulator_battery", + "emulator_geo", + "emulator_screen", +] diff --git a/testing/mozbase/mozrunner/mozrunner/devices/android_device.py b/testing/mozbase/mozrunner/mozrunner/devices/android_device.py new file mode 100644 index 0000000000..45565349f5 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/devices/android_device.py @@ -0,0 +1,1062 @@ +# 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/. + +import glob +import os +import platform +import posixpath +import re +import shutil +import signal +import subprocess +import sys +import telnetlib +import time +from distutils.spawn import find_executable +from enum import Enum + +import six +from mozdevice import ADBDeviceFactory, ADBHost +from six.moves import input, urllib + +MOZBUILD_PATH = os.environ.get( + "MOZBUILD_STATE_PATH", os.path.expanduser(os.path.join("~", ".mozbuild")) +) + +EMULATOR_HOME_DIR = os.path.join(MOZBUILD_PATH, "android-device") + +EMULATOR_AUTH_FILE = os.path.join( + os.path.expanduser("~"), ".emulator_console_auth_token" +) + +TOOLTOOL_PATH = "python/mozbuild/mozbuild/action/tooltool.py" + +TRY_URL = "https://hg.mozilla.org/try/raw-file/default" + +MANIFEST_PATH = "testing/config/tooltool-manifests" + +SHORT_TIMEOUT = 10 + +verbose_logging = False + +LLDB_SERVER_INSTALL_COMMANDS_SCRIPT = """ +umask 0002 + +mkdir -p {lldb_bin_dir} + +cp /data/local/tmp/lldb-server {lldb_bin_dir} +chmod +x {lldb_bin_dir}/lldb-server + +chmod 0775 {lldb_dir} +""".lstrip() + +LLDB_SERVER_START_COMMANDS_SCRIPT = """ +umask 0002 + +export LLDB_DEBUGSERVER_LOG_FILE={lldb_log_file} +export LLDB_SERVER_LOG_CHANNELS="{lldb_log_channels}" +export LLDB_DEBUGSERVER_DOMAINSOCKET_DIR={socket_dir} + +rm -rf {lldb_tmp_dir} +mkdir {lldb_tmp_dir} +export TMPDIR={lldb_tmp_dir} + +rm -rf {lldb_log_dir} +mkdir {lldb_log_dir} + +touch {lldb_log_file} +touch {platform_log_file} + +cd {lldb_tmp_dir} +{lldb_bin_dir}/lldb-server platform --server --listen {listener_scheme}://{socket_file} \\ + --log-file "{platform_log_file}" --log-channels "{lldb_log_channels}" \\ + < /dev/null > {platform_stdout_log_file} 2>&1 & +""".lstrip() + + +class InstallIntent(Enum): + YES = 1 + NO = 2 + + +class AvdInfo(object): + """ + Simple class to contain an AVD description. + """ + + def __init__(self, description, name, extra_args, x86): + self.description = description + self.name = name + self.extra_args = extra_args + self.x86 = x86 + + +""" + A dictionary to map an AVD type to a description of that type of AVD. + + There is one entry for each type of AVD used in Mozilla automated tests + and the parameters for each reflect those used in mozharness. +""" +AVD_DICT = { + "arm": AvdInfo( + "Android arm", + "mozemulator-armeabi-v7a", + [ + "-skip-adb-auth", + "-verbose", + "-show-kernel", + "-ranchu", + "-selinux", + "permissive", + "-memory", + "3072", + "-cores", + "4", + "-skin", + "800x1280", + "-gpu", + "on", + "-no-snapstorage", + "-no-snapshot", + "-prop", + "ro.test_harness=true", + ], + False, + ), + "arm64": AvdInfo( + "Android arm64", + "mozemulator-arm64", + [ + "-skip-adb-auth", + "-verbose", + "-show-kernel", + "-ranchu", + "-selinux", + "permissive", + "-memory", + "3072", + "-cores", + "4", + "-skin", + "800x1280", + "-gpu", + "on", + "-no-snapstorage", + "-no-snapshot", + "-prop", + "ro.test_harness=true", + ], + False, + ), + "x86_64": AvdInfo( + "Android x86_64", + "mozemulator-x86_64", + [ + "-skip-adb-auth", + "-verbose", + "-show-kernel", + "-ranchu", + "-selinux", + "permissive", + "-memory", + "3072", + "-cores", + "4", + "-skin", + "800x1280", + "-prop", + "ro.test_harness=true", + "-no-snapstorage", + "-no-snapshot", + ], + True, + ), +} + + +def _get_device(substs, device_serial=None): + adb_path = _find_sdk_exe(substs, "adb", False) + if not adb_path: + adb_path = "adb" + device = ADBDeviceFactory( + adb=adb_path, verbose=verbose_logging, device=device_serial + ) + return device + + +def _install_host_utils(build_obj): + _log_info("Installing host utilities...") + installed = False + host_platform = _get_host_platform() + if host_platform: + path = os.path.join(build_obj.topsrcdir, MANIFEST_PATH) + path = os.path.join(path, host_platform, "hostutils.manifest") + _get_tooltool_manifest( + build_obj.substs, path, EMULATOR_HOME_DIR, "releng.manifest" + ) + _tooltool_fetch(build_obj.substs) + xre_path = glob.glob(os.path.join(EMULATOR_HOME_DIR, "host-utils*")) + for path in xre_path: + if os.path.isdir(path) and os.path.isfile( + os.path.join(path, _get_xpcshell_name()) + ): + os.environ["MOZ_HOST_BIN"] = path + installed = True + elif os.path.isfile(path): + os.remove(path) + if not installed: + _log_warning("Unable to install host utilities.") + else: + _log_warning( + "Unable to install host utilities -- your platform is not supported!" + ) + + +def _get_xpcshell_name(): + """ + Returns the xpcshell binary's name as a string (dependent on operating system). + """ + xpcshell_binary = "xpcshell" + if os.name == "nt": + xpcshell_binary = "xpcshell.exe" + return xpcshell_binary + + +def _maybe_update_host_utils(build_obj): + """ + Compare the installed host-utils to the version name in the manifest; + if the installed version is older, offer to update. + """ + + # Determine existing/installed version + existing_path = None + xre_paths = glob.glob(os.path.join(EMULATOR_HOME_DIR, "host-utils*")) + for path in xre_paths: + if os.path.isdir(path) and os.path.isfile( + os.path.join(path, _get_xpcshell_name()) + ): + existing_path = path + break + if existing_path is None: + # if not installed, no need to upgrade (new version will be installed) + return + existing_version = os.path.basename(existing_path) + + # Determine manifest version + manifest_version = None + host_platform = _get_host_platform() + if host_platform: + # Extract tooltool file name from manifest, something like: + # "filename": "host-utils-58.0a1.en-US-linux-x86_64.tar.gz", + path = os.path.join(build_obj.topsrcdir, MANIFEST_PATH) + manifest_path = os.path.join(path, host_platform, "hostutils.manifest") + with open(manifest_path, "r") as f: + for line in f.readlines(): + m = re.search('.*"(host-utils-.*)"', line) + if m: + manifest_version = m.group(1) + break + + # Compare, prompt, update + if existing_version and manifest_version: + hu_version_regex = "host-utils-([\d\.]*)" + manifest_version = float(re.search(hu_version_regex, manifest_version).group(1)) + existing_version = float(re.search(hu_version_regex, existing_version).group(1)) + if existing_version < manifest_version: + _log_info("Your host utilities are out of date!") + _log_info( + "You have %s installed, but %s is available" + % (existing_version, manifest_version) + ) + response = input("Update host utilities? (Y/n) ").strip() + if response.lower().startswith("y") or response == "": + parts = os.path.split(existing_path) + backup_dir = "_backup-" + parts[1] + backup_path = os.path.join(parts[0], backup_dir) + shutil.move(existing_path, backup_path) + _install_host_utils(build_obj) + + +def verify_android_device( + build_obj, + install=InstallIntent.NO, + xre=False, + debugger=False, + network=False, + verbose=False, + app=None, + device_serial=None, + aab=False, +): + """ + Determine if any Android device is connected via adb. + If no device is found, prompt to start an emulator. + If a device is found or an emulator started and 'install' is + specified, also check whether Firefox is installed on the + device; if not, prompt to install Firefox. + If 'xre' is specified, also check with MOZ_HOST_BIN is set + to a valid xre/host-utils directory; if not, prompt to set + one up. + If 'debugger' is specified, also check that lldb-server is installed; + if it is not found, set it up. + If 'network' is specified, also check that the device has basic + network connectivity. + Returns True if the emulator was started or another device was + already connected. + """ + if "MOZ_DISABLE_ADB_INSTALL" in os.environ: + install = InstallIntent.NO + _log_info( + "Found MOZ_DISABLE_ADB_INSTALL in environment, skipping android app" + "installation" + ) + device_verified = False + emulator = AndroidEmulator("*", substs=build_obj.substs, verbose=verbose) + adb_path = _find_sdk_exe(build_obj.substs, "adb", False) + if not adb_path: + adb_path = "adb" + adbhost = ADBHost(adb=adb_path, verbose=verbose, timeout=SHORT_TIMEOUT) + devices = adbhost.devices(timeout=SHORT_TIMEOUT) + if "device" in [d["state"] for d in devices]: + device_verified = True + elif emulator.is_available(): + response = input( + "No Android devices connected. Start an emulator? (Y/n) " + ).strip() + if response.lower().startswith("y") or response == "": + if not emulator.check_avd(): + _log_info("Android AVD not found, please run |mach bootstrap|") + return + _log_info( + "Starting emulator running %s..." % emulator.get_avd_description() + ) + emulator.start() + emulator.wait_for_start() + device_verified = True + + if device_verified and "DEVICE_SERIAL" not in os.environ: + devices = adbhost.devices(timeout=SHORT_TIMEOUT) + for d in devices: + if d["state"] == "device": + os.environ["DEVICE_SERIAL"] = d["device_serial"] + break + + if device_verified and install != InstallIntent.NO: + # Determine if test app is installed on the device; if not, + # prompt to install. This feature allows a test command to + # launch an emulator, install the test app, and proceed with testing + # in one operation. It is also a basic safeguard against other + # cases where testing is requested but test app installation has + # been forgotten. + # If a test app is installed, there is no way to determine whether + # the current build is installed, and certainly no way to + # determine if the installed build is the desired build. + # Installing every time (without prompting) is problematic because: + # - it prevents testing against other builds (downloaded apk) + # - installation may take a couple of minutes. + if not app: + app = "org.mozilla.geckoview.test_runner" + device = _get_device(build_obj.substs, device_serial) + response = "" + installed = device.is_app_installed(app) + + if not installed: + _log_info("It looks like %s is not installed on this device." % app) + if "fennec" in app or "firefox" in app: + if installed: + device.uninstall_app(app) + _log_info("Installing Firefox...") + build_obj._run_make(directory=".", target="install", ensure_exit_code=False) + elif app == "org.mozilla.geckoview.test": + if installed: + device.uninstall_app(app) + _log_info("Installing geckoview AndroidTest...") + build_obj._mach_context.commands.dispatch( + "android", + build_obj._mach_context, + subcommand="install-geckoview-test", + args=[], + ) + elif app == "org.mozilla.geckoview.test_runner": + if installed: + device.uninstall_app(app) + _log_info("Installing geckoview test_runner...") + sub = ( + "install-geckoview-test_runner-aab" + if aab + else "install-geckoview-test_runner" + ) + build_obj._mach_context.commands.dispatch( + "android", build_obj._mach_context, subcommand=sub, args=[] + ) + elif app == "org.mozilla.geckoview_example": + if installed: + device.uninstall_app(app) + _log_info("Installing geckoview_example...") + sub = ( + "install-geckoview_example-aab" if aab else "install-geckoview_example" + ) + build_obj._mach_context.commands.dispatch( + "android", build_obj._mach_context, subcommand=sub, args=[] + ) + elif not installed: + response = input( + "It looks like %s is not installed on this device,\n" + "but I don't know how to install it.\n" + "Install it now, then hit Enter " % app + ) + + device.run_as_package = app + + if device_verified and xre: + # Check whether MOZ_HOST_BIN has been set to a valid xre; if not, + # prompt to install one. + xre_path = os.environ.get("MOZ_HOST_BIN") + err = None + if not xre_path: + err = ( + "environment variable MOZ_HOST_BIN is not set to a directory " + "containing host xpcshell" + ) + elif not os.path.isdir(xre_path): + err = "$MOZ_HOST_BIN does not specify a directory" + elif not os.path.isfile(os.path.join(xre_path, _get_xpcshell_name())): + err = "$MOZ_HOST_BIN/xpcshell does not exist" + if err: + _maybe_update_host_utils(build_obj) + xre_path = glob.glob(os.path.join(EMULATOR_HOME_DIR, "host-utils*")) + for path in xre_path: + if os.path.isdir(path) and os.path.isfile( + os.path.join(path, _get_xpcshell_name()) + ): + os.environ["MOZ_HOST_BIN"] = path + err = None + break + if err: + _log_info("Host utilities not found: %s" % err) + response = input("Download and setup your host utilities? (Y/n) ").strip() + if response.lower().startswith("y") or response == "": + _install_host_utils(build_obj) + + if device_verified and network: + # Optionally check the network: If on a device that does not look like + # an emulator, verify that the device IP address can be obtained + # and check that this host can ping the device. + serial = device_serial or os.environ.get("DEVICE_SERIAL") + if not serial or ("emulator" not in serial): + device = _get_device(build_obj.substs, serial) + device.run_as_package = app + try: + addr = device.get_ip_address() + if not addr: + _log_warning("unable to get Android device's IP address!") + _log_warning( + "tests may fail without network connectivity to the device!" + ) + else: + _log_info("Android device's IP address: %s" % addr) + response = subprocess.check_output(["ping", "-c", "1", addr]) + _log_debug(response) + except Exception as e: + _log_warning( + "unable to verify network connection to device: %s" % str(e) + ) + _log_warning( + "tests may fail without network connectivity to the device!" + ) + else: + _log_debug("network check skipped on emulator") + + if debugger: + _setup_or_run_lldb_server(app, build_obj.substs, device_serial, setup=True) + + return device_verified + + +def run_lldb_server(app, substs, device_serial): + return _setup_or_run_lldb_server(app, substs, device_serial, setup=False) + + +def _setup_or_run_lldb_server(app, substs, device_serial, setup=True): + device = _get_device(substs, device_serial) + + # Don't use enable_run_as here, as this will not give you what you + # want if we have root access on the device. + pkg_dir = device.shell_output("run-as %s pwd" % app) + if not pkg_dir or pkg_dir == "/": + pkg_dir = "/data/data/%s" % app + _log_warning( + "Unable to resolve data directory for package %s, falling back to hardcoded path" + % app + ) + + pkg_lldb_dir = posixpath.join(pkg_dir, "lldb") + pkg_lldb_bin_dir = posixpath.join(pkg_lldb_dir, "bin") + pkg_lldb_server = posixpath.join(pkg_lldb_bin_dir, "lldb-server") + + if setup: + # Check whether lldb-server is already there + if device.shell_bool("test -x %s" % pkg_lldb_server, enable_run_as=True): + _log_info( + "Found lldb-server binary, terminating any running server processes..." + ) + # lldb-server is already present. Kill any running server. + device.shell("pkill -f lldb-server", enable_run_as=True) + else: + _log_info("lldb-server not found, installing...") + + # We need to do an install + try: + server_path_local = substs["ANDROID_LLDB_SERVER"] + except KeyError: + _log_info( + "ANDROID_LLDB_SERVER is not configured correctly; " + "please re-configure your build." + ) + return + + device.push(server_path_local, "/data/local/tmp") + + install_cmds = LLDB_SERVER_INSTALL_COMMANDS_SCRIPT.format( + lldb_bin_dir=pkg_lldb_bin_dir, lldb_dir=pkg_lldb_dir + ) + + install_cmds = [l for l in install_cmds.splitlines() if l] + + _log_debug( + "Running the following installation commands:\n%r" % (install_cmds,) + ) + + device.batch_execute(install_cmds, enable_run_as=True) + return + + pkg_lldb_sock_file = posixpath.join(pkg_dir, "platform-%d.sock" % int(time.time())) + + pkg_lldb_log_dir = posixpath.join(pkg_lldb_dir, "log") + pkg_lldb_tmp_dir = posixpath.join(pkg_lldb_dir, "tmp") + + pkg_lldb_log_file = posixpath.join(pkg_lldb_log_dir, "lldb-server.log") + pkg_platform_log_file = posixpath.join(pkg_lldb_log_dir, "platform.log") + pkg_platform_stdout_log_file = posixpath.join( + pkg_lldb_log_dir, "platform-stdout.log" + ) + + listener_scheme = "unix-abstract" + log_channels = "lldb process:gdb-remote packets" + + start_cmds = LLDB_SERVER_START_COMMANDS_SCRIPT.format( + lldb_bin_dir=pkg_lldb_bin_dir, + lldb_log_file=pkg_lldb_log_file, + lldb_log_channels=log_channels, + socket_dir=pkg_dir, + lldb_tmp_dir=pkg_lldb_tmp_dir, + lldb_log_dir=pkg_lldb_log_dir, + platform_log_file=pkg_platform_log_file, + listener_scheme=listener_scheme, + platform_stdout_log_file=pkg_platform_stdout_log_file, + socket_file=pkg_lldb_sock_file, + ) + + start_cmds = [l for l in start_cmds.splitlines() if l] + + _log_debug("Running the following start commands:\n%r" % (start_cmds,)) + + device.batch_execute(start_cmds, enable_run_as=True) + + return pkg_lldb_sock_file + + +def get_adb_path(build_obj): + return _find_sdk_exe(build_obj.substs, "adb", False) + + +def grant_runtime_permissions(build_obj, app, device_serial=None): + """ + Grant required runtime permissions to the specified app + (eg. org.mozilla.geckoview.test_runner). + """ + device = _get_device(build_obj.substs, device_serial) + device.run_as_package = app + device.grant_runtime_permissions(app) + + +class AndroidEmulator(object): + """ + Support running the Android emulator with an AVD from Mozilla + test automation. + + Example usage: + emulator = AndroidEmulator() + if not emulator.is_running() and emulator.is_available(): + if not emulator.check_avd(): + print("Android Emulator AVD not found, please run |mach bootstrap|") + emulator.start() + emulator.wait_for_start() + emulator.wait() + """ + + def __init__(self, avd_type=None, verbose=False, substs=None, device_serial=None): + global verbose_logging + self.emulator_log = None + self.emulator_path = "emulator" + verbose_logging = verbose + self.substs = substs + self.avd_type = self._get_avd_type(avd_type) + self.avd_info = AVD_DICT[self.avd_type] + self.gpu = True + self.restarted = False + self.device_serial = device_serial + self.avd_path = os.path.join( + EMULATOR_HOME_DIR, "avd", "%s.avd" % self.avd_info.name + ) + _log_debug("Running on %s" % platform.platform()) + _log_debug("Emulator created with type %s" % self.avd_type) + + def __del__(self): + if self.emulator_log: + self.emulator_log.close() + + def is_running(self): + """ + Returns True if the Android emulator is running. + """ + adb_path = _find_sdk_exe(self.substs, "adb", False) + if not adb_path: + adb_path = "adb" + adbhost = ADBHost(adb=adb_path, verbose=verbose_logging, timeout=SHORT_TIMEOUT) + devs = adbhost.devices(timeout=SHORT_TIMEOUT) + devs = [(d["device_serial"], d["state"]) for d in devs] + _log_debug(devs) + if ("emulator-5554", "device") in devs: + return True + return False + + def is_available(self): + """ + Returns True if an emulator executable is found. + """ + found = False + emulator_path = _find_sdk_exe(self.substs, "emulator", True) + if emulator_path: + self.emulator_path = emulator_path + found = True + return found + + def check_avd(self): + """ + Determine if the AVD is already installed locally. + + Returns True if the AVD is installed. + """ + if os.path.exists(self.avd_path): + _log_debug("AVD found at %s" % self.avd_path) + return True + _log_warning("Could not find AVD at %s" % self.avd_path) + return False + + def start(self, gpu_arg=None): + """ + Launch the emulator. + """ + if self.avd_info.x86 and "linux" in _get_host_platform(): + _verify_kvm(self.substs) + if os.path.exists(EMULATOR_AUTH_FILE): + os.remove(EMULATOR_AUTH_FILE) + _log_debug("deleted %s" % EMULATOR_AUTH_FILE) + self._update_avd_paths() + # create an empty auth file to disable emulator authentication + auth_file = open(EMULATOR_AUTH_FILE, "w") + auth_file.close() + + env = os.environ + env["ANDROID_EMULATOR_HOME"] = EMULATOR_HOME_DIR + env["ANDROID_AVD_HOME"] = os.path.join(EMULATOR_HOME_DIR, "avd") + command = [self.emulator_path, "-avd", self.avd_info.name] + override = os.environ.get("MOZ_EMULATOR_COMMAND_ARGS") + if override: + command += override.split() + _log_debug("Found MOZ_EMULATOR_COMMAND_ARGS in env: %s" % override) + else: + if gpu_arg: + command += ["-gpu", gpu_arg] + # Clear self.gpu to avoid our restart-without-gpu feature: if a specific + # gpu setting is requested, try to use that, and nothing else. + self.gpu = False + elif self.gpu: + command += ["-gpu", "on"] + if self.avd_info.extra_args: + # -enable-kvm option is not valid on OSX and Windows + if ( + _get_host_platform() in ("macosx64", "win32") + and "-enable-kvm" in self.avd_info.extra_args + ): + self.avd_info.extra_args.remove("-enable-kvm") + command += self.avd_info.extra_args + log_path = os.path.join(EMULATOR_HOME_DIR, "emulator.log") + self.emulator_log = open(log_path, "w+") + _log_debug("Starting the emulator with this command: %s" % " ".join(command)) + _log_debug("Emulator output will be written to '%s'" % log_path) + self.proc = subprocess.Popen( + command, + env=env, + stdin=subprocess.PIPE, + stdout=self.emulator_log, + stderr=self.emulator_log, + ) + _log_debug("Emulator started with pid %d" % int(self.proc.pid)) + + def wait_for_start(self): + """ + Verify that the emulator is running, the emulator device is visible + to adb, and Android has booted. + """ + if not self.proc: + _log_warning("Emulator not started!") + return False + if self.check_completed(): + return False + _log_debug("Waiting for device status...") + adb_path = _find_sdk_exe(self.substs, "adb", False) + if not adb_path: + adb_path = "adb" + adbhost = ADBHost(adb=adb_path, verbose=verbose_logging, timeout=SHORT_TIMEOUT) + devs = adbhost.devices(timeout=SHORT_TIMEOUT) + devs = [(d["device_serial"], d["state"]) for d in devs] + while ("emulator-5554", "device") not in devs: + time.sleep(10) + if self.check_completed(): + return False + devs = adbhost.devices(timeout=SHORT_TIMEOUT) + devs = [(d["device_serial"], d["state"]) for d in devs] + _log_debug("Device status verified.") + + _log_debug("Checking that Android has booted...") + device = _get_device(self.substs, self.device_serial) + complete = False + while not complete: + output = "" + try: + output = device.get_prop("sys.boot_completed", timeout=5) + except Exception: + # adb not yet responding...keep trying + pass + if output.strip() == "1": + complete = True + else: + time.sleep(10) + if self.check_completed(): + return False + _log_debug("Android boot status verified.") + + if not self._verify_emulator(): + return False + if self.avd_info.x86: + _log_info( + "Running the x86/x86_64 emulator; be sure to install an x86 or x86_64 APK!" + ) + else: + _log_info("Running the arm emulator; be sure to install an arm APK!") + return True + + def check_completed(self): + if self.proc.poll() is not None: + if self.gpu: + try: + for line in self.emulator_log.readlines(): + if ( + "Invalid value for -gpu" in line + or "Invalid GPU mode" in line + ): + self.gpu = False + break + except Exception as e: + _log_warning(str(e)) + + if not self.gpu and not self.restarted: + _log_warning( + "Emulator failed to start. Your emulator may be out of date." + ) + _log_warning("Trying to restart the emulator without -gpu argument.") + self.restarted = True + self.start() + return False + _log_warning("Emulator has already completed!") + log_path = os.path.join(EMULATOR_HOME_DIR, "emulator.log") + _log_warning( + "See log at %s and/or use --verbose for more information." % log_path + ) + return True + return False + + def wait(self): + """ + Wait for the emulator to close. If interrupted, close the emulator. + """ + try: + self.proc.wait() + except Exception: + if self.proc.poll() is None: + self.cleanup() + return self.proc.poll() + + def cleanup(self): + """ + Close the emulator. + """ + self.proc.kill(signal.SIGTERM) + + def get_avd_description(self): + """ + Return the human-friendly description of this AVD. + """ + return self.avd_info.description + + def _update_avd_paths(self): + ini_path = os.path.join(EMULATOR_HOME_DIR, "avd", "%s.ini" % self.avd_info.name) + with open(ini_path, "r") as f: + lines = f.readlines() + with open(ini_path, "w") as f: + for line in lines: + if line.startswith("path="): + f.write("path=%s\n" % self.avd_path) + elif line.startswith("path.rel="): + f.write("path.rel=avd/%s.avd\n" % self.avd_info.name) + else: + f.write(line) + + def _telnet_read_until(self, telnet, expected, timeout): + if six.PY3 and isinstance(expected, six.text_type): + expected = expected.encode("ascii") + return telnet.read_until(expected, timeout) + + def _telnet_write(self, telnet, command): + if six.PY3 and isinstance(command, six.text_type): + command = command.encode("ascii") + telnet.write(command) + + def _telnet_cmd(self, telnet, command): + _log_debug(">>> %s" % command) + self._telnet_write(telnet, "%s\n" % command) + result = self._telnet_read_until(telnet, "OK", 10) + _log_debug("<<< %s" % result) + return result + + def _verify_emulator(self): + telnet_ok = False + tn = None + while not telnet_ok: + try: + tn = telnetlib.Telnet("localhost", 5554, 10) + if tn is not None: + self._telnet_read_until(tn, "OK", 10) + self._telnet_cmd(tn, "avd status") + self._telnet_cmd(tn, "redir list") + self._telnet_cmd(tn, "network status") + self._telnet_write(tn, "quit\n") + tn.read_all() + telnet_ok = True + else: + _log_warning("Unable to connect to port 5554") + except Exception: + _log_warning("Trying again after unexpected exception") + finally: + if tn is not None: + tn.close() + if not telnet_ok: + time.sleep(10) + if self.proc.poll() is not None: + _log_warning("Emulator has already completed!") + return False + return telnet_ok + + def _get_avd_type(self, requested): + if requested in AVD_DICT.keys(): + return requested + if self.substs: + target_cpu = self.substs["TARGET_CPU"] + if target_cpu == "aarch64": + return "arm64" + elif target_cpu.startswith("arm"): + return "arm" + return "x86_64" + + +def _find_sdk_exe(substs, exe, tools): + if tools: + subdirs = ["emulator", "tools"] + else: + subdirs = ["platform-tools"] + + found = False + if not found and substs: + # It's best to use the tool specified by the build, rather + # than something we find on the PATH or crawl for. + try: + exe_path = substs[exe.upper()] + if os.path.exists(exe_path): + found = True + else: + _log_debug("Unable to find executable at %s" % exe_path) + except KeyError: + _log_debug("%s not set" % exe.upper()) + + # Append '.exe' to the name on Windows if it's not present, + # so that the executable can be found. + if os.name == "nt" and not exe.lower().endswith(".exe"): + exe += ".exe" + + if not found: + # Can exe be found in the Android SDK? + try: + android_sdk_root = os.environ["ANDROID_SDK_ROOT"] + for subdir in subdirs: + exe_path = os.path.join(android_sdk_root, subdir, exe) + if os.path.exists(exe_path): + found = True + break + else: + _log_debug("Unable to find executable at %s" % exe_path) + except KeyError: + _log_debug("ANDROID_SDK_ROOT not set") + + if not found: + # Can exe be found in the default bootstrap location? + for subdir in subdirs: + exe_path = os.path.join(MOZBUILD_PATH, "android-sdk-linux", subdir, exe) + if os.path.exists(exe_path): + found = True + break + else: + _log_debug("Unable to find executable at %s" % exe_path) + + if not found: + # Is exe on PATH? + exe_path = find_executable(exe) + if exe_path: + found = True + else: + _log_debug("Unable to find executable on PATH") + + if found: + _log_debug("%s found at %s" % (exe, exe_path)) + try: + creation_time = os.path.getctime(exe_path) + _log_debug(" ...with creation time %s" % time.ctime(creation_time)) + except Exception: + _log_warning("Could not get creation time for %s" % exe_path) + + prop_path = os.path.join(os.path.dirname(exe_path), "source.properties") + if os.path.exists(prop_path): + with open(prop_path, "r") as f: + for line in f.readlines(): + if line.startswith("Pkg.Revision"): + line = line.strip() + _log_debug( + " ...with SDK version in %s: %s" % (prop_path, line) + ) + break + else: + exe_path = None + return exe_path + + +def _log_debug(text): + if verbose_logging: + print("DEBUG: %s" % text) + + +def _log_warning(text): + print("WARNING: %s" % text) + + +def _log_info(text): + print("%s" % text) + + +def _download_file(url, filename, path): + _log_debug("Download %s to %s/%s..." % (url, path, filename)) + f = urllib.request.urlopen(url) + if not os.path.isdir(path): + try: + os.makedirs(path) + except Exception as e: + _log_warning(str(e)) + return False + local_file = open(os.path.join(path, filename), "wb") + local_file.write(f.read()) + local_file.close() + _log_debug("Downloaded %s to %s/%s" % (url, path, filename)) + return True + + +def _get_tooltool_manifest(substs, src_path, dst_path, filename): + if not os.path.isdir(dst_path): + try: + os.makedirs(dst_path) + except Exception as e: + _log_warning(str(e)) + copied = False + if substs and "top_srcdir" in substs: + src = os.path.join(substs["top_srcdir"], src_path) + if os.path.exists(src): + dst = os.path.join(dst_path, filename) + shutil.copy(src, dst) + copied = True + _log_debug("Copied tooltool manifest %s to %s" % (src, dst)) + if not copied: + url = os.path.join(TRY_URL, src_path) + _download_file(url, filename, dst_path) + + +def _tooltool_fetch(substs): + tooltool_full_path = os.path.join(substs["top_srcdir"], TOOLTOOL_PATH) + command = [ + sys.executable, + tooltool_full_path, + "fetch", + "-o", + "-m", + "releng.manifest", + ] + try: + response = subprocess.check_output(command, cwd=EMULATOR_HOME_DIR) + _log_debug(response) + except Exception as e: + _log_warning(str(e)) + + +def _get_host_platform(): + plat = None + if "darwin" in str(sys.platform).lower(): + plat = "macosx64" + elif "win32" in str(sys.platform).lower(): + plat = "win32" + elif "linux" in str(sys.platform).lower(): + if "64" in platform.architecture()[0]: + plat = "linux64" + else: + plat = "linux32" + return plat + + +def _verify_kvm(substs): + # 'emulator -accel-check' should produce output like: + # accel: + # 0 + # KVM (version 12) is installed and usable + # accel + emulator_path = _find_sdk_exe(substs, "emulator", True) + if not emulator_path: + emulator_path = "emulator" + command = [emulator_path, "-accel-check"] + try: + out = subprocess.check_output(command) + if six.PY3 and not isinstance(out, six.text_type): + out = out.decode("utf-8") + if "is installed and usable" in "".join(out): + return + except Exception as e: + _log_warning(str(e)) + _log_warning("Unable to verify kvm acceleration!") + _log_warning("The x86/x86_64 emulator may fail to start without kvm.") diff --git a/testing/mozbase/mozrunner/mozrunner/devices/base.py b/testing/mozbase/mozrunner/mozrunner/devices/base.py new file mode 100644 index 0000000000..6197b0d6ce --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/devices/base.py @@ -0,0 +1,259 @@ +# 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/. + +import datetime +import os +import posixpath +import shutil +import tempfile +import time + +from mozdevice import ADBError, ADBHost +from six.moves.configparser import ConfigParser, RawConfigParser + + +class Device(object): + connected = False + + def __init__(self, app_ctx, logdir=None, serial=None, restore=True): + self.app_ctx = app_ctx + self.device = self.app_ctx.device + self.restore = restore + self.serial = serial + self.logdir = os.path.abspath(os.path.expanduser(logdir)) + self.added_files = set() + self.backup_files = set() + + @property + def remote_profiles(self): + """ + A list of remote profiles on the device. + """ + remote_ini = self.app_ctx.remote_profiles_ini + if not self.device.is_file(remote_ini): + raise IOError("Remote file '%s' not found" % remote_ini) + + local_ini = tempfile.NamedTemporaryFile() + self.device.pull(remote_ini, local_ini.name) + cfg = ConfigParser() + cfg.read(local_ini.name) + + profiles = [] + for section in cfg.sections(): + if cfg.has_option(section, "Path"): + if cfg.has_option(section, "IsRelative") and cfg.getint( + section, "IsRelative" + ): + profiles.append( + posixpath.join( + posixpath.dirname(remote_ini), cfg.get(section, "Path") + ) + ) + else: + profiles.append(cfg.get(section, "Path")) + return profiles + + def pull_minidumps(self): + """ + Saves any minidumps found in the remote profile on the local filesystem. + + :returns: Path to directory containing the dumps. + """ + remote_dump_dir = posixpath.join(self.app_ctx.remote_profile, "minidumps") + local_dump_dir = tempfile.mkdtemp() + try: + self.device.pull(remote_dump_dir, local_dump_dir) + except ADBError as e: + # OK if directory not present -- sometimes called before browser start + if "does not exist" not in str(e): + try: + shutil.rmtree(local_dump_dir) + except Exception: + pass + finally: + raise e + else: + print("WARNING: {}".format(e)) + if os.listdir(local_dump_dir): + self.device.rm(remote_dump_dir, recursive=True) + self.device.mkdir(remote_dump_dir, parents=True) + return local_dump_dir + + def setup_profile(self, profile): + """ + Copy profile to the device and update the remote profiles.ini + to point to the new profile. + + :param profile: mozprofile object to copy over. + """ + if self.device.is_dir(self.app_ctx.remote_profile): + self.device.rm(self.app_ctx.remote_profile, recursive=True) + + self.device.push(profile.profile, self.app_ctx.remote_profile) + + timeout = 5 # seconds + starttime = datetime.datetime.now() + while datetime.datetime.now() - starttime < datetime.timedelta(seconds=timeout): + if self.device.is_file(self.app_ctx.remote_profiles_ini): + break + time.sleep(1) + local_profiles_ini = tempfile.NamedTemporaryFile() + if not self.device.is_file(self.app_ctx.remote_profiles_ini): + # Unless fennec is already running, and/or remote_profiles_ini is + # not inside the remote_profile (deleted above), this is entirely + # normal. + print("timed out waiting for profiles.ini") + else: + self.device.pull(self.app_ctx.remote_profiles_ini, local_profiles_ini.name) + + config = ProfileConfigParser() + config.read(local_profiles_ini.name) + for section in config.sections(): + if "Profile" in section: + config.set(section, "IsRelative", 0) + config.set(section, "Path", self.app_ctx.remote_profile) + + # delete=False to allow opening the same file from ADB on Windows. + # The file will still be deleted at the end of the `with` block. + # See the "Opening the temporary file again" paragraph in: + # https://docs.python.org/3/library/tempfile.html#tempfile.NamedTemporaryFile + with tempfile.NamedTemporaryFile(delete=False) as new_profiles_ini: + config.write(new_profiles_ini) + self.backup_file(self.app_ctx.remote_profiles_ini) + self.device.push(new_profiles_ini.name, self.app_ctx.remote_profiles_ini) + + # Ideally all applications would read the profile the same way, but in practice + # this isn't true. Perform application specific profile-related setup if necessary. + if hasattr(self.app_ctx, "setup_profile"): + for remote_path in self.app_ctx.remote_backup_files: + self.backup_file(remote_path) + self.app_ctx.setup_profile(profile) + + def _get_online_devices(self): + adbhost = ADBHost(adb=self.app_ctx.adb) + devices = adbhost.devices() + return [ + d["device_serial"] + for d in devices + if d["state"] != "offline" + if not d["device_serial"].startswith("emulator") + ] + + def connect(self): + """ + Connects to a running device. If no serial was specified in the + constructor, defaults to the first entry in `adb devices`. + """ + if self.connected: + return + + online_devices = self._get_online_devices() + if not online_devices: + raise IOError( + "No devices connected. Ensure the device is on and " + "remote debugging via adb is enabled in the settings." + ) + self.serial = online_devices[0] + + self.connected = True + + def reboot(self): + """ + Reboots the device via adb. + """ + self.device.reboot() + + def wait_for_net(self): + active = False + time_out = 0 + while not active and time_out < 40: + if self.device.get_ip_address() is not None: + active = True + time_out += 1 + time.sleep(1) + return active + + def backup_file(self, remote_path): + if not self.restore: + return + + if self.device.exists(remote_path): + self.device.cp(remote_path, "%s.orig" % remote_path, recursive=True) + self.backup_files.add(remote_path) + else: + self.added_files.add(remote_path) + + def cleanup(self): + """ + Cleanup the device. + """ + if not self.restore: + return + + try: + self.device.remount() + # Restore the original profile + for added_file in self.added_files: + self.device.rm(added_file) + + for backup_file in self.backup_files: + if self.device.exists("%s.orig" % backup_file): + self.device.mv("%s.orig" % backup_file, backup_file) + + # Perform application specific profile cleanup if necessary + if hasattr(self.app_ctx, "cleanup_profile"): + self.app_ctx.cleanup_profile() + + # Remove the test profile + self.device.rm(self.app_ctx.remote_profile, force=True, recursive=True) + except Exception as e: + print("cleanup aborted: %s" % str(e)) + + def _rotate_log(self, srclog, index=1): + """ + Rotate a logfile, by recursively rotating logs further in the sequence, + deleting the last file if necessary. + """ + basename = os.path.basename(srclog) + basename = basename[: -len(".log")] + if index > 1: + basename = basename[: -len(".1")] + basename = "%s.%d.log" % (basename, index) + + destlog = os.path.join(self.logdir, basename) + if os.path.isfile(destlog): + if index == 3: + os.remove(destlog) + else: + self._rotate_log(destlog, index + 1) + shutil.move(srclog, destlog) + + +class ProfileConfigParser(RawConfigParser): + """ + Class to create profiles.ini config files + + Subclass of RawConfigParser that outputs .ini files in the exact + format expected for profiles.ini, which is slightly different + than the default format. + """ + + def optionxform(self, optionstr): + return optionstr + + def write(self, fp): + if self._defaults: + fp.write("[%s]\n" % ConfigParser.DEFAULTSECT) + for key, value in self._defaults.items(): + fp.write("%s=%s\n" % (key, str(value).replace("\n", "\n\t"))) + fp.write("\n") + for section in self._sections: + fp.write("[%s]\n" % section) + for key, value in self._sections[section].items(): + if key == "__name__": + continue + if (value is not None) or (self._optcre == self.OPTCRE): + key = "=".join((key, str(value).replace("\n", "\n\t"))) + fp.write("%s\n" % (key)) + fp.write("\n") diff --git a/testing/mozbase/mozrunner/mozrunner/devices/emulator.py b/testing/mozbase/mozrunner/mozrunner/devices/emulator.py new file mode 100644 index 0000000000..4a2aa81733 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/devices/emulator.py @@ -0,0 +1,224 @@ +# 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/. + +import datetime +import os +import shutil +import subprocess +import tempfile +import time +from telnetlib import Telnet + +from mozdevice import ADBHost +from mozprocess import ProcessHandler + +from ..errors import TimeoutException +from .base import Device +from .emulator_battery import EmulatorBattery +from .emulator_geo import EmulatorGeo +from .emulator_screen import EmulatorScreen + + +class ArchContext(object): + def __init__(self, arch, context, binary=None, avd=None, extra_args=None): + homedir = getattr(context, "homedir", "") + kernel = os.path.join(homedir, "prebuilts", "qemu-kernel", "%s", "%s") + sysdir = os.path.join(homedir, "out", "target", "product", "%s") + self.extra_args = [] + self.binary = os.path.join(context.bindir or "", "emulator") + if arch == "x86": + self.binary = os.path.join(context.bindir or "", "emulator-x86") + self.kernel = kernel % ("x86", "kernel-qemu") + self.sysdir = sysdir % "generic_x86" + elif avd: + self.avd = avd + self.extra_args = [ + "-show-kernel", + "-debug", + "init,console,gles,memcheck,adbserver,adbclient,adb,avd_config,socket", + ] + else: + self.kernel = kernel % ("arm", "kernel-qemu-armv7") + self.sysdir = sysdir % "generic" + self.extra_args = ["-cpu", "cortex-a8"] + + if binary: + self.binary = binary + + if extra_args: + self.extra_args.extend(extra_args) + + +class SDCard(object): + def __init__(self, emulator, size): + self.emulator = emulator + self.path = self.create_sdcard(size) + + def create_sdcard(self, sdcard_size): + """ + Creates an sdcard partition in the emulator. + + :param sdcard_size: Size of partition to create, e.g '10MB'. + """ + mksdcard = self.emulator.app_ctx.which("mksdcard") + path = tempfile.mktemp(prefix="sdcard", dir=self.emulator.tmpdir) + sdargs = [mksdcard, "-l", "mySdCard", sdcard_size, path] + sd = subprocess.Popen(sdargs, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + retcode = sd.wait() + if retcode: + raise Exception( + "unable to create sdcard: exit code %d: %s" + % (retcode, sd.stdout.read()) + ) + return path + + +class BaseEmulator(Device): + port = None + proc = None + telnet = None + + def __init__(self, app_ctx, **kwargs): + self.arch = ArchContext( + kwargs.pop("arch", "arm"), + app_ctx, + binary=kwargs.pop("binary", None), + avd=kwargs.pop("avd", None), + ) + super(BaseEmulator, self).__init__(app_ctx, **kwargs) + self.tmpdir = tempfile.mkdtemp() + # These rely on telnet + self.battery = EmulatorBattery(self) + self.geo = EmulatorGeo(self) + self.screen = EmulatorScreen(self) + + @property + def args(self): + """ + Arguments to pass into the emulator binary. + """ + return [self.arch.binary] + + def start(self): + """ + Starts a new emulator. + """ + if self.proc: + return + + original_devices = set(self._get_online_devices()) + + # QEMU relies on atexit() to remove temporary files, which does not + # work since mozprocess uses SIGKILL to kill the emulator process. + # Use a customized temporary directory so we can clean it up. + os.environ["ANDROID_TMP"] = self.tmpdir + + qemu_log = None + qemu_proc_args = {} + if self.logdir: + # save output from qemu to logfile + qemu_log = os.path.join(self.logdir, "qemu.log") + if os.path.isfile(qemu_log): + self._rotate_log(qemu_log) + qemu_proc_args["logfile"] = qemu_log + else: + qemu_proc_args["processOutputLine"] = lambda line: None + self.proc = ProcessHandler(self.args, **qemu_proc_args) + self.proc.run() + + devices = set(self._get_online_devices()) + now = datetime.datetime.now() + while (devices - original_devices) == set([]): + time.sleep(1) + # Sometimes it takes more than 60s to launch emulator, so we + # increase timeout value to 180s. Please see bug 1143380. + if datetime.datetime.now() - now > datetime.timedelta(seconds=180): + raise TimeoutException("timed out waiting for emulator to start") + devices = set(self._get_online_devices()) + devices = devices - original_devices + self.serial = devices.pop() + self.connect() + + def _get_online_devices(self): + adbhost = ADBHost(adb=self.app_ctx.adb) + return [ + d["device_serial"] + for d in adbhost.devices() + if d["state"] != "offline" + if d["device_serial"].startswith("emulator") + ] + + def connect(self): + """ + Connects to a running device. If no serial was specified in the + constructor, defaults to the first entry in `adb devices`. + """ + if self.connected: + return + + super(BaseEmulator, self).connect() + self.port = int(self.serial[self.serial.rindex("-") + 1 :]) + + def cleanup(self): + """ + Cleans up and kills the emulator, if it was started by mozrunner. + """ + super(BaseEmulator, self).cleanup() + if self.proc: + self.proc.kill() + self.proc = None + self.connected = False + + # Remove temporary files + if os.path.isdir(self.tmpdir): + shutil.rmtree(self.tmpdir) + + def _get_telnet_response(self, command=None): + output = [] + assert self.telnet + if command is not None: + self.telnet.write("%s\n" % command) + while True: + line = self.telnet.read_until("\n") + output.append(line.rstrip()) + if line.startswith("OK"): + return output + elif line.startswith("KO:"): + raise Exception("bad telnet response: %s" % line) + + def _run_telnet(self, command): + if not self.telnet: + self.telnet = Telnet("localhost", self.port) + self._get_telnet_response() + return self._get_telnet_response(command) + + def __del__(self): + if self.telnet: + self.telnet.write("exit\n") + self.telnet.read_all() + + +class EmulatorAVD(BaseEmulator): + def __init__(self, app_ctx, binary, avd, port=5554, **kwargs): + super(EmulatorAVD, self).__init__(app_ctx, binary=binary, avd=avd, **kwargs) + self.port = port + + @property + def args(self): + """ + Arguments to pass into the emulator binary. + """ + qemu_args = super(EmulatorAVD, self).args + qemu_args.extend(["-avd", self.arch.avd, "-port", str(self.port)]) + qemu_args.extend(self.arch.extra_args) + return qemu_args + + def start(self): + if self.proc: + return + + env = os.environ + env["ANDROID_AVD_HOME"] = self.app_ctx.avd_home + + super(EmulatorAVD, self).start() diff --git a/testing/mozbase/mozrunner/mozrunner/devices/emulator_battery.py b/testing/mozbase/mozrunner/mozrunner/devices/emulator_battery.py new file mode 100644 index 0000000000..58d42b0a0e --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/devices/emulator_battery.py @@ -0,0 +1,53 @@ +# 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/. + + +class EmulatorBattery(object): + def __init__(self, emulator): + self.emulator = emulator + + def get_state(self): + status = {} + state = {} + + response = self.emulator._run_telnet("power display") + for line in response: + if ":" in line: + field, value = line.split(":") + value = value.strip() + if value == "true": + value = True + elif value == "false": + value = False + elif field == "capacity": + value = float(value) + status[field] = value + + # pylint --py3k W1619 + state["level"] = status.get("capacity", 0.0) / 100 + if status.get("AC") == "online": + state["charging"] = True + else: + state["charging"] = False + + return state + + def get_charging(self): + return self.get_state()["charging"] + + def get_level(self): + return self.get_state()["level"] + + def set_level(self, level): + self.emulator._run_telnet("power capacity %d" % (level * 100)) + + def set_charging(self, charging): + if charging: + cmd = "power ac on" + else: + cmd = "power ac off" + self.emulator._run_telnet(cmd) + + charging = property(get_charging, set_charging) + level = property(get_level, set_level) diff --git a/testing/mozbase/mozrunner/mozrunner/devices/emulator_geo.py b/testing/mozbase/mozrunner/mozrunner/devices/emulator_geo.py new file mode 100644 index 0000000000..a1fd6fc8b2 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/devices/emulator_geo.py @@ -0,0 +1,16 @@ +# 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/. + + +class EmulatorGeo(object): + def __init__(self, emulator): + self.emulator = emulator + + def set_default_location(self): + self.lon = -122.08769 + self.lat = 37.41857 + self.set_location(self.lon, self.lat) + + def set_location(self, lon, lat): + self.emulator._run_telnet("geo fix %0.5f %0.5f" % (self.lon, self.lat)) diff --git a/testing/mozbase/mozrunner/mozrunner/devices/emulator_screen.py b/testing/mozbase/mozrunner/mozrunner/devices/emulator_screen.py new file mode 100644 index 0000000000..8f261c3610 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/devices/emulator_screen.py @@ -0,0 +1,91 @@ +# 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/. + + +class EmulatorScreen(object): + """Class for screen related emulator commands.""" + + SO_PORTRAIT_PRIMARY = "portrait-primary" + SO_PORTRAIT_SECONDARY = "portrait-secondary" + SO_LANDSCAPE_PRIMARY = "landscape-primary" + SO_LANDSCAPE_SECONDARY = "landscape-secondary" + + def __init__(self, emulator): + self.emulator = emulator + + def initialize(self): + self.orientation = self.SO_PORTRAIT_PRIMARY + + def _get_raw_orientation(self): + """Get the raw value of the current device orientation.""" + response = self.emulator._run_telnet("sensor get orientation") + + return response[0].split("=")[1].strip() + + def _set_raw_orientation(self, data): + """Set the raw value of the specified device orientation.""" + self.emulator._run_telnet("sensor set orientation %s" % data) + + def get_orientation(self): + """Get the current device orientation. + + Returns; + orientation -- Orientation of the device. One of: + SO_PORTRAIT_PRIMARY - system buttons at the bottom + SO_PORTRIAT_SECONDARY - system buttons at the top + SO_LANDSCAPE_PRIMARY - system buttons at the right + SO_LANDSCAPE_SECONDARY - system buttons at the left + + """ + data = self._get_raw_orientation() + + if data == "0:-90:0": + orientation = self.SO_PORTRAIT_PRIMARY + elif data == "0:90:0": + orientation = self.SO_PORTRAIT_SECONDARY + elif data == "0:0:90": + orientation = self.SO_LANDSCAPE_PRIMARY + elif data == "0:0:-90": + orientation = self.SO_LANDSCAPE_SECONDARY + else: + raise ValueError("Unknown orientation sensor value: %s." % data) + + return orientation + + def set_orientation(self, orientation): + """Set the specified device orientation. + + Args + orientation -- Orientation of the device. One of: + SO_PORTRAIT_PRIMARY - system buttons at the bottom + SO_PORTRIAT_SECONDARY - system buttons at the top + SO_LANDSCAPE_PRIMARY - system buttons at the right + SO_LANDSCAPE_SECONDARY - system buttons at the left + """ + orientation = SCREEN_ORIENTATIONS[orientation] + + if orientation == self.SO_PORTRAIT_PRIMARY: + data = "0:-90:0" + elif orientation == self.SO_PORTRAIT_SECONDARY: + data = "0:90:0" + elif orientation == self.SO_LANDSCAPE_PRIMARY: + data = "0:0:90" + elif orientation == self.SO_LANDSCAPE_SECONDARY: + data = "0:0:-90" + else: + raise ValueError("Invalid orientation: %s" % orientation) + + self._set_raw_orientation(data) + + orientation = property(get_orientation, set_orientation) + + +SCREEN_ORIENTATIONS = { + "portrait": EmulatorScreen.SO_PORTRAIT_PRIMARY, + "landscape": EmulatorScreen.SO_LANDSCAPE_PRIMARY, + "portrait-primary": EmulatorScreen.SO_PORTRAIT_PRIMARY, + "landscape-primary": EmulatorScreen.SO_LANDSCAPE_PRIMARY, + "portrait-secondary": EmulatorScreen.SO_PORTRAIT_SECONDARY, + "landscape-secondary": EmulatorScreen.SO_LANDSCAPE_SECONDARY, +} diff --git a/testing/mozbase/mozrunner/mozrunner/errors.py b/testing/mozbase/mozrunner/mozrunner/errors.py new file mode 100644 index 0000000000..2c4ea50d5d --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/errors.py @@ -0,0 +1,16 @@ +#!/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/. + + +class RunnerException(Exception): + """Base exception handler for mozrunner related errors""" + + +class RunnerNotStartedError(RunnerException): + """Exception handler in case the runner hasn't been started""" + + +class TimeoutException(RunnerException): + """Raised on timeout waiting for targets to start.""" diff --git a/testing/mozbase/mozrunner/mozrunner/runners.py b/testing/mozbase/mozrunner/mozrunner/runners.py new file mode 100644 index 0000000000..75fbf60733 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/runners.py @@ -0,0 +1,144 @@ +# 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/. + + +""" +This module contains a set of shortcut methods that create runners for commonly +used Mozilla applications, such as Firefox, Firefox for Android or Thunderbird. +""" + +from .application import get_app_context +from .base import BlinkRuntimeRunner, FennecRunner, GeckoRuntimeRunner +from .devices import EmulatorAVD + + +def Runner(*args, **kwargs): + """ + Create a generic GeckoRuntime runner. + + :param binary: Path to binary. + :param cmdargs: Arguments to pass into binary. + :param profile: Profile object to use. + :param env: Environment variables to pass into the gecko process. + :param clean_profile: If True, restores profile back to original state. + :param process_class: Class used to launch the binary. + :param process_args: Arguments to pass into process_class. + :param symbols_path: Path to symbol files used for crash analysis. + :param show_crash_reporter: allow the crash reporter window to pop up. + Defaults to False. + :returns: A generic GeckoRuntimeRunner. + """ + return GeckoRuntimeRunner(*args, **kwargs) + + +def FirefoxRunner(*args, **kwargs): + """ + Create a desktop Firefox runner. + + :param binary: Path to Firefox binary. + :param cmdargs: Arguments to pass into binary. + :param profile: Profile object to use. + :param env: Environment variables to pass into the gecko process. + :param clean_profile: If True, restores profile back to original state. + :param process_class: Class used to launch the binary. + :param process_args: Arguments to pass into process_class. + :param symbols_path: Path to symbol files used for crash analysis. + :param show_crash_reporter: allow the crash reporter window to pop up. + Defaults to False. + :returns: A GeckoRuntimeRunner for Firefox. + """ + kwargs["app_ctx"] = get_app_context("firefox")() + return GeckoRuntimeRunner(*args, **kwargs) + + +def ThunderbirdRunner(*args, **kwargs): + """ + Create a desktop Thunderbird runner. + + :param binary: Path to Thunderbird binary. + :param cmdargs: Arguments to pass into binary. + :param profile: Profile object to use. + :param env: Environment variables to pass into the gecko process. + :param clean_profile: If True, restores profile back to original state. + :param process_class: Class used to launch the binary. + :param process_args: Arguments to pass into process_class. + :param symbols_path: Path to symbol files used for crash analysis. + :param show_crash_reporter: allow the crash reporter window to pop up. + Defaults to False. + :returns: A GeckoRuntimeRunner for Thunderbird. + """ + kwargs["app_ctx"] = get_app_context("thunderbird")() + return GeckoRuntimeRunner(*args, **kwargs) + + +def ChromeRunner(*args, **kwargs): + """ + Create a desktop Google Chrome runner. + + :param binary: Path to Chrome binary. + :param cmdargs: Arguments to pass into the binary. + """ + kwargs["app_ctx"] = get_app_context("chrome")() + return BlinkRuntimeRunner(*args, **kwargs) + + +def ChromiumRunner(*args, **kwargs): + """ + Create a desktop Google Chromium runner. + + :param binary: Path to Chromium binary. + :param cmdargs: Arguments to pass into the binary. + """ + kwargs["app_ctx"] = get_app_context("chromium")() + return BlinkRuntimeRunner(*args, **kwargs) + + +def FennecEmulatorRunner( + avd="mozemulator-arm", + adb_path=None, + avd_home=None, + logdir=None, + serial=None, + binary=None, + app="org.mozilla.fennec", + **kwargs +): + """ + Create a Fennec emulator runner. This can either start a new emulator + (which will use an avd), or connect to an already-running emulator. + + :param avd: name of an AVD available in your environment. + Typically obtained via tooltool: either 'mozemulator-4.3' or 'mozemulator-x86'. + Defaults to 'mozemulator-4.3' + :param avd_home: Path to avd parent directory + :param logdir: Path to save logfiles such as qemu output. + :param serial: Serial of emulator to connect to as seen in `adb devices`. + Defaults to the first entry in `adb devices`. + :param binary: Path to emulator binary. + Defaults to None, which causes the device_class to guess based on PATH. + :param app: Name of Fennec app (often org.mozilla.fennec_$USER) + Defaults to 'org.mozilla.fennec' + :param cmdargs: Arguments to pass into binary. + :returns: A DeviceRunner for Android emulators. + """ + kwargs["app_ctx"] = get_app_context("fennec")( + app, adb_path=adb_path, avd_home=avd_home, device_serial=serial + ) + device_args = { + "app_ctx": kwargs["app_ctx"], + "avd": avd, + "binary": binary, + "logdir": logdir, + } + return FennecRunner(device_class=EmulatorAVD, device_args=device_args, **kwargs) + + +runners = { + "chrome": ChromeRunner, + "chromium": ChromiumRunner, + "default": Runner, + "firefox": FirefoxRunner, + "fennec": FennecEmulatorRunner, + "thunderbird": ThunderbirdRunner, +} diff --git a/testing/mozbase/mozrunner/mozrunner/utils.py b/testing/mozbase/mozrunner/mozrunner/utils.py new file mode 100755 index 0000000000..46c20c1997 --- /dev/null +++ b/testing/mozbase/mozrunner/mozrunner/utils.py @@ -0,0 +1,299 @@ +#!/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/. + +"""Utility functions for mozrunner""" + +import os +import sys + +import mozinfo + +__all__ = ["findInPath", "get_metadata_from_egg"] + + +# python package method metadata by introspection +try: + import pkg_resources + + def get_metadata_from_egg(module): + ret = {} + try: + dist = pkg_resources.get_distribution(module) + except pkg_resources.DistributionNotFound: + return {} + if dist.has_metadata("PKG-INFO"): + key = None + value = "" + for line in dist.get_metadata("PKG-INFO").splitlines(): + # see http://www.python.org/dev/peps/pep-0314/ + if key == "Description": + # descriptions can be long + if not line or line[0].isspace(): + value += "\n" + line + continue + else: + key = key.strip() + value = value.strip() + ret[key] = value + + key, value = line.split(":", 1) + key = key.strip() + value = value.strip() + ret[key] = value + if dist.has_metadata("requires.txt"): + ret["Dependencies"] = "\n" + dist.get_metadata("requires.txt") + return ret + +except ImportError: + # package resources not avaialable + def get_metadata_from_egg(module): + return {} + + +def findInPath(fileName, path=os.environ["PATH"]): + """python equivalent of which; should really be in the stdlib""" + dirs = path.split(os.pathsep) + for dir in dirs: + if os.path.isfile(os.path.join(dir, fileName)): + return os.path.join(dir, fileName) + if mozinfo.isWin: + if os.path.isfile(os.path.join(dir, fileName + ".exe")): + return os.path.join(dir, fileName + ".exe") + + +if __name__ == "__main__": + for i in sys.argv[1:]: + print(findInPath(i)) + + +def _find_marionette_in_args(*args, **kwargs): + try: + m = [a for a in args + tuple(kwargs.values()) if hasattr(a, "session")][0] + except IndexError: + print("Can only apply decorator to function using a marionette object") + raise + return m + + +def _raw_log(): + import logging + + return logging.getLogger(__name__) + + +def test_environment( + xrePath, env=None, crashreporter=True, debugger=False, useLSan=False, log=None +): + """ + populate OS environment variables for mochitest and reftests. + + Originally comes from automationutils.py. Don't use that for new code. + """ + + env = os.environ.copy() if env is None else env + log = log or _raw_log() + + assert os.path.isabs(xrePath) + + if mozinfo.isMac: + ldLibraryPath = os.path.join(os.path.dirname(xrePath), "MacOS") + else: + ldLibraryPath = xrePath + + envVar = None + if mozinfo.isUnix: + envVar = "LD_LIBRARY_PATH" + elif mozinfo.isMac: + envVar = "DYLD_LIBRARY_PATH" + elif mozinfo.isWin: + envVar = "PATH" + if envVar: + envValue = ( + (env.get(envVar), str(ldLibraryPath)) + if mozinfo.isWin + else (ldLibraryPath, env.get(envVar)) + ) + env[envVar] = os.path.pathsep.join([path for path in envValue if path]) + + # Allow non-packaged builds to access symlinked modules in the source dir + env["MOZ_DEVELOPER_REPO_DIR"] = mozinfo.info.get("topsrcdir") + env["MOZ_DEVELOPER_OBJ_DIR"] = mozinfo.info.get("topobjdir") + + # crashreporter + env["GNOME_DISABLE_CRASH_DIALOG"] = "1" + env["XRE_NO_WINDOWS_CRASH_DIALOG"] = "1" + + if crashreporter and not debugger: + env["MOZ_CRASHREPORTER_NO_REPORT"] = "1" + env["MOZ_CRASHREPORTER"] = "1" + env["MOZ_CRASHREPORTER_SHUTDOWN"] = "1" + else: + env["MOZ_CRASHREPORTER_DISABLE"] = "1" + + # Crash on non-local network connections by default. + # MOZ_DISABLE_NONLOCAL_CONNECTIONS can be set to "0" to temporarily + # enable non-local connections for the purposes of local testing. Don't + # override the user's choice here. See bug 1049688. + env.setdefault("MOZ_DISABLE_NONLOCAL_CONNECTIONS", "1") + + # Set WebRTC logging in case it is not set yet + env.setdefault("MOZ_LOG", "signaling:3,mtransport:4,DataChannel:4,jsep:4") + env.setdefault("R_LOG_LEVEL", "6") + env.setdefault("R_LOG_DESTINATION", "stderr") + env.setdefault("R_LOG_VERBOSE", "1") + + # Ask NSS to use lower-security password encryption. See Bug 1594559 + env.setdefault("NSS_MAX_MP_PBE_ITERATION_COUNT", "10") + + # ASan specific environment stuff + if "ASAN_SYMBOLIZER_PATH" in env and os.path.isfile(env["ASAN_SYMBOLIZER_PATH"]): + llvmsym = env["ASAN_SYMBOLIZER_PATH"] + else: + if mozinfo.isMac: + llvmSymbolizerDir = ldLibraryPath + else: + llvmSymbolizerDir = xrePath + llvmsym = os.path.join( + llvmSymbolizerDir, "llvm-symbolizer" + mozinfo.info["bin_suffix"] + ) + asan = bool(mozinfo.info.get("asan")) + if asan: + try: + # Symbolizer support + if os.path.isfile(llvmsym): + env["ASAN_SYMBOLIZER_PATH"] = llvmsym + log.info("INFO | runtests.py | ASan using symbolizer at %s" % llvmsym) + else: + log.error( + "TEST-UNEXPECTED-FAIL | runtests.py | Failed to find" + " ASan symbolizer at %s" % llvmsym + ) + + # Returns total system memory in kilobytes. + if mozinfo.isWin: + # pylint --py3k W1619 + totalMemory = ( + int( + os.popen( + "wmic computersystem get TotalPhysicalMemory" + ).readlines()[1] + ) + / 1024 + ) + elif mozinfo.isMac: + # pylint --py3k W1619 + totalMemory = ( + int(os.popen("sysctl hw.memsize").readlines()[0].split()[1]) / 1024 + ) + else: + totalMemory = int(os.popen("free").readlines()[1].split()[1]) + + # Only 4 GB RAM or less available? Use custom ASan options to reduce + # the amount of resources required to do the tests. Standard options + # will otherwise lead to OOM conditions on the current test machines. + message = "INFO | runtests.py | ASan running in %s configuration" + asanOptions = [] + if totalMemory <= 1024 * 1024 * 4: + message = message % "low-memory" + asanOptions = ["quarantine_size=50331648", "malloc_context_size=5"] + else: + message = message % "default memory" + + if useLSan: + log.info("LSan enabled.") + asanOptions.append("detect_leaks=1") + lsanOptions = ["exitcode=0"] + # Uncomment out the next line to report the addresses of leaked objects. + # lsanOptions.append("report_objects=1") + env["LSAN_OPTIONS"] = ":".join(lsanOptions) + + if len(asanOptions): + env["ASAN_OPTIONS"] = ":".join(asanOptions) + + except OSError as err: + log.info( + "Failed determine available memory, disabling ASan" + " low-memory configuration: %s" % err.strerror + ) + except Exception: + log.info( + "Failed determine available memory, disabling ASan" + " low-memory configuration" + ) + else: + log.info(message) + + tsan = bool(mozinfo.info.get("tsan")) + if tsan and mozinfo.isLinux: + # Symbolizer support. + if os.path.isfile(llvmsym): + env["TSAN_OPTIONS"] = "external_symbolizer_path=%s" % llvmsym + log.info("INFO | runtests.py | TSan using symbolizer at %s" % llvmsym) + else: + log.error( + "TEST-UNEXPECTED-FAIL | runtests.py | Failed to find TSan" + " symbolizer at %s" % llvmsym + ) + + ubsan = bool(mozinfo.info.get("ubsan")) + if ubsan and (mozinfo.isLinux or mozinfo.isMac): + log.info("UBSan enabled.") + + return env + + +def get_stack_fixer_function(utilityPath, symbolsPath, hideErrors=False): + """ + Return a stack fixing function, if possible, to use on output lines. + + A stack fixing function checks if a line conforms to the output from + MozFormatCodeAddressDetails. If the line does not, the line is returned + unchanged. If the line does, an attempt is made to convert the + file+offset into something human-readable (e.g. a function name). + """ + if not mozinfo.info.get("debug"): + return None + + if os.getenv("MOZ_DISABLE_STACK_FIX", 0): + print( + "WARNING: No stack-fixing will occur because MOZ_DISABLE_STACK_FIX is set" + ) + return None + + def import_stack_fixer_module(module_name): + sys.path.insert(0, utilityPath) + module = __import__(module_name, globals(), locals(), []) + sys.path.pop(0) + return module + + if symbolsPath and os.path.exists(symbolsPath): + # Run each line through fix_stacks.py, using breakpad symbol files. + # This method is preferred for automation, since native symbols may + # have been stripped. + stack_fixer_module = import_stack_fixer_module("fix_stacks") + + def stack_fixer_function(line): + return stack_fixer_module.fixSymbols( + line, + slowWarning=True, + breakpadSymsDir=symbolsPath, + hide_errors=hideErrors, + ) + + elif mozinfo.isLinux or mozinfo.isMac or mozinfo.isWin: + # Run each line through fix_stacks.py. This method is preferred for + # developer machines, so we don't have to run "mach buildsymbols". + stack_fixer_module = import_stack_fixer_module("fix_stacks") + + def stack_fixer_function(line): + return stack_fixer_module.fixSymbols( + line, slowWarning=True, hide_errors=hideErrors + ) + + else: + return None + + return stack_fixer_function diff --git a/testing/mozbase/mozrunner/setup.cfg b/testing/mozbase/mozrunner/setup.cfg new file mode 100644 index 0000000000..2a9acf13da --- /dev/null +++ b/testing/mozbase/mozrunner/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 diff --git a/testing/mozbase/mozrunner/setup.py b/testing/mozbase/mozrunner/setup.py new file mode 100644 index 0000000000..72407ffd95 --- /dev/null +++ b/testing/mozbase/mozrunner/setup.py @@ -0,0 +1,53 @@ +# 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 setuptools import find_packages, setup + +PACKAGE_NAME = "mozrunner" +PACKAGE_VERSION = "8.3.0" + +desc = """Reliable start/stop/configuration of Mozilla Applications (Firefox, Thunderbird, etc.)""" + +deps = [ + "mozdevice>=4.0.0,<5", + "mozfile>=1.2", + "mozinfo>=0.7,<2", + "mozlog>=6.0", + "mozprocess>=1.3.0,<2", + "mozprofile~=2.3", + "six>=1.13.0,<2", +] + +EXTRAS_REQUIRE = {"crash": ["mozcrash >= 2.0"]} + +setup( + name=PACKAGE_NAME, + version=PACKAGE_VERSION, + description=desc, + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + classifiers=[ + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.5", + "Topic :: Software Development :: Libraries :: Python Modules", + ], + keywords="mozilla", + author="Mozilla Automation and Tools team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + license="MPL 2.0", + packages=find_packages(), + zip_safe=False, + install_requires=deps, + extras_require=EXTRAS_REQUIRE, + entry_points=""" + # -*- Entry points: -*- + [console_scripts] + mozrunner = mozrunner:cli + """, +) diff --git a/testing/mozbase/mozrunner/tests/conftest.py b/testing/mozbase/mozrunner/tests/conftest.py new file mode 100644 index 0000000000..991fc376fb --- /dev/null +++ b/testing/mozbase/mozrunner/tests/conftest.py @@ -0,0 +1,81 @@ +# 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/. + +import os +import threading +from time import sleep + +import mozrunner +import pytest +from moztest.selftest import fixtures + + +@pytest.fixture(scope="session") +def get_binary(): + if "BROWSER_PATH" in os.environ: + os.environ["GECKO_BINARY_PATH"] = os.environ["BROWSER_PATH"] + + def inner(app): + if app not in ("chrome", "chromium", "firefox"): + pytest.xfail(reason="{} support not implemented".format(app)) + + if app == "firefox": + binary = fixtures.binary() + elif app == "chrome": + binary = os.environ.get("CHROME_BINARY_PATH") + elif app == "chromium": + binary = os.environ.get("CHROMIUM_BINARY_PATH") + + if not binary: + pytest.skip("could not find a {} binary".format(app)) + return binary + + return inner + + +@pytest.fixture(params=["firefox", "chrome", "chromium"]) +def runner(request, get_binary): + app = request.param + binary = get_binary(app) + + cmdargs = ["--headless"] + if app in ["chrome", "chromium"]: + # prevents headless chromium from exiting after loading the page + cmdargs.append("--remote-debugging-port=9222") + # only needed on Windows, but no harm in specifying it everywhere + cmdargs.append("--disable-gpu") + runner = mozrunner.runners[app](binary, cmdargs=cmdargs) + runner.app = app + yield runner + runner.stop() + + +class RunnerThread(threading.Thread): + def __init__(self, runner, start=False, timeout=1): + threading.Thread.__init__(self) + self.runner = runner + self.timeout = timeout + self.do_start = start + + def run(self): + sleep(self.timeout) + if self.do_start: + self.runner.start() + else: + self.runner.stop() + + +@pytest.fixture +def create_thread(): + threads = [] + + def inner(*args, **kwargs): + thread = RunnerThread(*args, **kwargs) + threads.append(thread) + return thread + + yield inner + + for thread in threads: + thread.join() diff --git a/testing/mozbase/mozrunner/tests/manifest.toml b/testing/mozbase/mozrunner/tests/manifest.toml new file mode 100644 index 0000000000..2adf8ddcf2 --- /dev/null +++ b/testing/mozbase/mozrunner/tests/manifest.toml @@ -0,0 +1,19 @@ +[DEFAULT] +subsuite = "mozbase" +# We skip these tests in automated Windows builds because they trigger crashes +# in sh.exe; see bug 1489277. +skip-if = ["automation && os == 'win'"] + +["test_crash.py"] + +["test_interactive.py"] + +["test_start.py"] + +["test_states.py"] + +["test_stop.py"] + +["test_threads.py"] + +["test_wait.py"] diff --git a/testing/mozbase/mozrunner/tests/test_crash.py b/testing/mozbase/mozrunner/tests/test_crash.py new file mode 100644 index 0000000000..820851aa1c --- /dev/null +++ b/testing/mozbase/mozrunner/tests/test_crash.py @@ -0,0 +1,36 @@ +#!/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 unittest.mock import patch + +import mozunit +import pytest + + +@pytest.mark.parametrize("logger", [True, False]) +def test_crash_count_with_or_without_logger(runner, logger): + if runner.app == "chrome": + pytest.xfail("crash checking not implemented for ChromeRunner") + + if not logger: + runner.logger = None + fn = "check_for_crashes" + else: + fn = "log_crashes" + + with patch("mozcrash.{}".format(fn), return_value=2) as mock: + assert runner.crashed == 0 + assert runner.check_for_crashes() == 2 + assert runner.crashed == 2 + assert runner.check_for_crashes() == 2 + assert runner.crashed == 4 + + mock.return_value = 0 + assert runner.check_for_crashes() == 0 + assert runner.crashed == 4 + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozrunner/tests/test_interactive.py b/testing/mozbase/mozrunner/tests/test_interactive.py new file mode 100644 index 0000000000..ab700d334c --- /dev/null +++ b/testing/mozbase/mozrunner/tests/test_interactive.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python + +from time import sleep + +import mozunit + + +def test_run_interactive(runner, create_thread): + """Bug 965183: Run process in interactive mode and call wait()""" + runner.start(interactive=True) + + thread = create_thread(runner, timeout=2) + thread.start() + + # This is a blocking call. So the process should be killed by the thread + runner.wait() + thread.join() + assert not runner.is_running() + + +def test_stop_interactive(runner): + """Bug 965183: Explicitely stop process in interactive mode""" + runner.start(interactive=True) + runner.stop() + + +def test_wait_after_process_finished(runner): + """Wait after the process has been stopped should not raise an error""" + runner.start(interactive=True) + sleep(1) + runner.process_handler.kill() + + returncode = runner.wait(1) + + assert returncode not in [None, 0] + assert runner.process_handler is not None + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozrunner/tests/test_start.py b/testing/mozbase/mozrunner/tests/test_start.py new file mode 100644 index 0000000000..56e01ae84d --- /dev/null +++ b/testing/mozbase/mozrunner/tests/test_start.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python + +from time import sleep +from unittest.mock import patch + +import mozunit +from mozrunner import RunnerNotStartedError +from pytest import raises + + +def test_start_process(runner): + """Start the process and test properties""" + assert runner.process_handler is None + + runner.start() + + assert runner.is_running() + assert runner.process_handler is not None + + +def test_start_process_called_twice(runner): + """Start the process twice and test that first process is gone""" + runner.start() + # Bug 925480 + # Make a copy until mozprocess can kill a specific process + process_handler = runner.process_handler + + runner.start() + + try: + assert process_handler.wait(1) not in [None, 0] + finally: + process_handler.kill() + + +def test_start_with_timeout(runner): + """Start the process and set a timeout""" + runner.start(timeout=0.1) + sleep(1) + + assert not runner.is_running() + + +def test_start_with_outputTimeout(runner): + """Start the process and set a timeout""" + runner.start(outputTimeout=0.1) + sleep(1) + + assert not runner.is_running() + + +def test_fail_to_start(runner): + with patch("mozprocess.ProcessHandler.__init__") as ph_mock: + ph_mock.side_effect = Exception("Boom!") + with raises(RunnerNotStartedError): + runner.start(outputTimeout=0.1) + sleep(1) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozrunner/tests/test_states.py b/testing/mozbase/mozrunner/tests/test_states.py new file mode 100644 index 0000000000..b2eb3d119c --- /dev/null +++ b/testing/mozbase/mozrunner/tests/test_states.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python + +import mozunit +import pytest +from mozrunner import RunnerNotStartedError + + +def test_errors_before_start(runner): + """Bug 965714: Not started errors before start() is called""" + + with pytest.raises(RunnerNotStartedError): + runner.is_running() + + with pytest.raises(RunnerNotStartedError): + runner.returncode + + with pytest.raises(RunnerNotStartedError): + runner.wait() + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozrunner/tests/test_stop.py b/testing/mozbase/mozrunner/tests/test_stop.py new file mode 100644 index 0000000000..a4f2b1fadd --- /dev/null +++ b/testing/mozbase/mozrunner/tests/test_stop.py @@ -0,0 +1,41 @@ +#!/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/. + +import signal + +import mozunit + + +def test_stop_process(runner): + """Stop the process and test properties""" + runner.start() + returncode = runner.stop() + + assert not runner.is_running() + assert returncode not in [None, 0] + assert runner.returncode == returncode + assert runner.process_handler is not None + assert runner.wait(1) == returncode + + +def test_stop_before_start(runner): + """Stop the process before it gets started should not raise an error""" + runner.stop() + + +def test_stop_process_custom_signal(runner): + """Stop the process via a custom signal and test properties""" + runner.start() + returncode = runner.stop(signal.SIGTERM) + + assert not runner.is_running() + assert returncode not in [None, 0] + assert runner.returncode == returncode + assert runner.process_handler is not None + assert runner.wait(1) == returncode + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozrunner/tests/test_threads.py b/testing/mozbase/mozrunner/tests/test_threads.py new file mode 100644 index 0000000000..fa77d92688 --- /dev/null +++ b/testing/mozbase/mozrunner/tests/test_threads.py @@ -0,0 +1,57 @@ +#!/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/. + +import mozunit + + +def test_process_start_via_thread(runner, create_thread): + """Start the runner via a thread""" + thread = create_thread(runner, True, 2) + + thread.start() + thread.join() + + assert runner.is_running() + + +def test_process_stop_via_multiple_threads(runner, create_thread): + """Stop the runner via multiple threads""" + runner.start() + threads = [] + for i in range(5): + thread = create_thread(runner, False, 5) + threads.append(thread) + thread.start() + + # Wait until the process has been stopped by another thread + for thread in threads: + thread.join() + returncode = runner.wait(1) + + assert returncode not in [None, 0] + assert runner.returncode == returncode + assert runner.process_handler is not None + assert runner.wait(2) == returncode + + +def test_process_post_stop_via_thread(runner, create_thread): + """Stop the runner and try it again with a thread a bit later""" + runner.start() + thread = create_thread(runner, False, 5) + thread.start() + + # Wait a bit to start the application gets started + runner.wait(1) + returncode = runner.stop() + thread.join() + + assert returncode not in [None, 0] + assert runner.returncode == returncode + assert runner.process_handler is not None + assert runner.wait(2) == returncode + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozrunner/tests/test_wait.py b/testing/mozbase/mozrunner/tests/test_wait.py new file mode 100644 index 0000000000..d7ba721b3d --- /dev/null +++ b/testing/mozbase/mozrunner/tests/test_wait.py @@ -0,0 +1,32 @@ +#!/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/. + +import mozunit + + +def test_wait_while_running(runner): + """Wait for the process while it is running""" + runner.start() + returncode = runner.wait(1) + + assert runner.is_running() + assert returncode is None + assert runner.returncode == returncode + assert runner.process_handler is not None + + +def test_wait_after_process_finished(runner): + """Bug 965714: wait() after stop should not raise an error""" + runner.start() + runner.process_handler.kill() + + returncode = runner.wait(1) + + assert returncode not in [None, 0] + assert runner.process_handler is not None + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozscreenshot/mozscreenshot/__init__.py b/testing/mozbase/mozscreenshot/mozscreenshot/__init__.py new file mode 100644 index 0000000000..f176fe90cc --- /dev/null +++ b/testing/mozbase/mozscreenshot/mozscreenshot/__init__.py @@ -0,0 +1,110 @@ +# 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/. + +import os +import subprocess +import tempfile + +import mozinfo +from mozlog.formatters.process import strstatus + + +def printstatus(name, returncode): + """ + print the status of a command exit code, formatted for tbpl. + + Note that mozlog structured action "process_exit" should be used + instead of that in new code. + """ + print("TEST-INFO | %s: %s" % (name, strstatus(returncode))) + + +def dump_screen(utilityPath, log, prefix="mozilla-test-fail-screenshot_"): + """dumps a screenshot of the entire screen to a directory specified by + the MOZ_UPLOAD_DIR environment variable. + + :param utilityPath: Path of utility programs. This is typically a path + to either the objdir's bin directory or a path to the host utilities. + :param log: Reference to logger. + """ + + is_structured_log = hasattr(log, "process_exit") + + # Need to figure out which OS-dependent tool to use + if mozinfo.isUnix: + utility = [os.path.join(utilityPath, "screentopng")] + utilityname = "screentopng" + elif mozinfo.isMac: + utility = ["/usr/sbin/screencapture", "-C", "-x", "-t", "png"] + utilityname = "screencapture" + elif mozinfo.isWin: + utility = [os.path.join(utilityPath, "screenshot.exe")] + utilityname = "screenshot" + + # Get dir where to write the screenshot file + parent_dir = os.environ.get("MOZ_UPLOAD_DIR", None) + if not parent_dir: + log.info("Failed to retrieve MOZ_UPLOAD_DIR env var") + return + + # Run the capture + try: + tmpfd, imgfilename = tempfile.mkstemp( + prefix=prefix, suffix=".png", dir=parent_dir + ) + os.close(tmpfd) + if is_structured_log: + log.process_start(utilityname) + returncode = subprocess.call(utility + [imgfilename]) + if is_structured_log: + log.process_exit(utilityname, returncode) + else: + printstatus(utilityname, returncode) + except OSError as err: + log.info("Failed to start %s for screenshot: %s" % (utility[0], err.strerror)) + + +def dump_device_screen(device, log, prefix="mozilla-test-fail-screenshot_"): + """dumps a screenshot of a real device's entire screen to a directory + specified by the MOZ_UPLOAD_DIR environment variable. Cloned from + mozscreenshot.dump_screen. + + :param device: Reference to an ADBDevice object which provides the + interface to interact with Android devices. + :param log: Reference to logger. + """ + + utilityname = "screencap" + is_structured_log = hasattr(log, "process_exit") + + # Get dir where to write the screenshot file + parent_dir = os.environ.get("MOZ_UPLOAD_DIR", None) + if not parent_dir: + log.info("Failed to retrieve MOZ_UPLOAD_DIR env var") + return + + # Run the capture + try: + # Android 6.0 and later support mktemp. See + # https://android.googlesource.com/platform/system/core/ + # +/master/shell_and_utilities/README.md#android-6_0-marshmallow + # We can use mktemp on real devices since we do not test on + # real devices older than Android 6.0. Note we must create the + # file without an extension due to limitations in mktemp. + filename = device.shell_output( + "mktemp -p %s %sXXXXXX" % (device.test_root, prefix) + ) + pngfilename = filename + ".png" + device.mv(filename, pngfilename) + if is_structured_log: + log.process_start(utilityname) + device.shell_output("%s -p %s" % (utilityname, pngfilename)) + if is_structured_log: + log.process_exit(utilityname, 0) + else: + printstatus(utilityname, 0) + device.pull(pngfilename, parent_dir) + device.rm(pngfilename) + except Exception as err: + log.info("Failed to start %s for screenshot: %s" % (utilityname, str(err))) diff --git a/testing/mozbase/mozscreenshot/setup.cfg b/testing/mozbase/mozscreenshot/setup.cfg new file mode 100644 index 0000000000..3c6e79cf31 --- /dev/null +++ b/testing/mozbase/mozscreenshot/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 diff --git a/testing/mozbase/mozscreenshot/setup.py b/testing/mozbase/mozscreenshot/setup.py new file mode 100644 index 0000000000..17a9dc9ccb --- /dev/null +++ b/testing/mozbase/mozscreenshot/setup.py @@ -0,0 +1,29 @@ +# 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 setuptools import setup + +PACKAGE_NAME = "mozscreenshot" +PACKAGE_VERSION = "1.0.0" + + +setup( + name=PACKAGE_NAME, + version=PACKAGE_VERSION, + description="Library for taking screenshots in tests harness", + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + classifiers=[ + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.5", + ], + # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + keywords="mozilla", + author="Mozilla Automation and Tools team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + license="MPL", + packages=["mozscreenshot"], + zip_safe=False, + install_requires=["mozlog", "mozinfo"], +) diff --git a/testing/mozbase/mozserve/mozserve/__init__.py b/testing/mozbase/mozserve/mozserve/__init__.py new file mode 100644 index 0000000000..9e884916a1 --- /dev/null +++ b/testing/mozbase/mozserve/mozserve/__init__.py @@ -0,0 +1,12 @@ +# 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/. + +""" +mozserve is a simple script that is used to launch test servers, and +is designed for use in mochitest and xpcshelltest. +""" + +from .servers import DoHServer, Http2Server, Http3Server + +__all__ = ["Http3Server", "Http2Server", "DoHServer"] diff --git a/testing/mozbase/mozserve/mozserve/servers.py b/testing/mozbase/mozserve/mozserve/servers.py new file mode 100644 index 0000000000..fe75a12496 --- /dev/null +++ b/testing/mozbase/mozserve/mozserve/servers.py @@ -0,0 +1,289 @@ +# 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/. + +import copy +import os +import re +import subprocess +import sys +import time +from argparse import Namespace +from contextlib import contextmanager +from subprocess import PIPE, Popen +from threading import Thread + + +@contextmanager +def popenCleanupHack(isWin): + """ + Hack to work around https://bugs.python.org/issue37380 + The basic idea is that on old versions of Python on Windows, + we need to clear subprocess._cleanup before we call Popen(), + then restore it afterwards. + """ + savedCleanup = None + if isWin and sys.version_info[0] == 3 and sys.version_info < (3, 7, 5): + savedCleanup = subprocess._cleanup + subprocess._cleanup = lambda: None + try: + yield + finally: + if savedCleanup: + subprocess._cleanup = savedCleanup + + +class Http3Server(object): + """ + Class which encapsulates the Http3 server + """ + + def __init__(self, options, env, logger): + if isinstance(options, Namespace): + options = vars(options) + self._log = logger + self._profileDir = options["profilePath"] + self._env = copy.deepcopy(env) + self._ports = {} + self._echConfig = "" + self._isMochitest = options["isMochitest"] + self._http3ServerPath = options["http3ServerPath"] + self._isWin = options["isWin"] + self._http3ServerProc = {} + self._proxyPort = -1 + if options.get("proxyPort"): + self._proxyPort = options["proxyPort"] + + def ports(self): + return self._ports + + def echConfig(self): + return self._echConfig + + def read_streams(self, name, proc, pipe): + output = "stdout" if pipe == proc.stdout else "stderr" + for line in iter(pipe.readline, ""): + self._log.info("server: %s [%s] %s" % (name, output, line)) + + def start(self): + if not os.path.exists(self._http3ServerPath): + raise Exception("Http3 server not found at %s" % self._http3ServerPath) + + self._log.info("mozserve | Found Http3Server path: %s" % self._http3ServerPath) + + dbPath = os.path.join(self._profileDir, "cert9.db") + if not os.path.exists(dbPath): + raise Exception("cert db not found at %s" % dbPath) + + dbPath = self._profileDir + self._log.info("mozserve | cert db path: %s" % dbPath) + + try: + if self._isMochitest: + self._env["MOZ_HTTP3_MOCHITEST"] = "1" + if self._proxyPort != -1: + self._env["MOZ_HTTP3_PROXY_PORT"] = str(self._proxyPort) + with popenCleanupHack(self._isWin): + process = Popen( + [self._http3ServerPath, dbPath], + stdin=PIPE, + stdout=PIPE, + stderr=PIPE, + env=self._env, + cwd=os.getcwd(), + universal_newlines=True, + ) + self._http3ServerProc["http3Server"] = process + + # Check to make sure the server starts properly by waiting for it to + # tell us it's started + msg = process.stdout.readline() + self._log.info("mozserve | http3 server msg: %s" % msg) + name = "http3server" + t1 = Thread( + target=self.read_streams, + args=(name, process, process.stdout), + daemon=True, + ) + t1.start() + t2 = Thread( + target=self.read_streams, + args=(name, process, process.stderr), + daemon=True, + ) + t2.start() + if "server listening" in msg: + searchObj = re.search( + r"HTTP3 server listening on ports ([0-9]+), ([0-9]+), ([0-9]+), ([0-9]+) and ([0-9]+)." + " EchConfig is @([\x00-\x7F]+)@", + msg, + 0, + ) + if searchObj: + self._ports["MOZHTTP3_PORT"] = searchObj.group(1) + self._ports["MOZHTTP3_PORT_FAILED"] = searchObj.group(2) + self._ports["MOZHTTP3_PORT_ECH"] = searchObj.group(3) + self._ports["MOZHTTP3_PORT_PROXY"] = searchObj.group(4) + self._ports["MOZHTTP3_PORT_NO_RESPONSE"] = searchObj.group(5) + self._echConfig = searchObj.group(6) + else: + self._log.error("http3server failed to start?") + except OSError as e: + # This occurs if the subprocess couldn't be started + self._log.error("Could not run the http3 server: %s" % (str(e))) + + def stop(self): + """ + Shutdown our http3Server process, if it exists + """ + for name, proc in self._http3ServerProc.items(): + self._log.info("%s server shutting down ..." % name) + if proc.poll() is not None: + self._log.info("Http3 server %s already dead %s" % (name, proc.poll())) + else: + proc.terminate() + retries = 0 + while proc.poll() is None: + time.sleep(0.1) + retries += 1 + if retries > 40: + self._log.info("Killing proc") + proc.kill() + break + self._http3ServerProc = {} + + +class NodeHttp2Server(object): + """ + Class which encapsulates a Node Http/2 server + """ + + def __init__(self, name, options, env, logger): + if isinstance(options, Namespace): + options = vars(options) + self._name = name + self._log = logger + self._port = options["port"] + self._env = copy.deepcopy(env) + self._nodeBin = options["nodeBin"] + self._serverPath = options["serverPath"] + self._dstServerPort = options["dstServerPort"] + self._isWin = options["isWin"] + self._nodeProc = None + self._searchStr = options["searchStr"] + self._alpn = options["alpn"] + + def port(self): + return self._port + + def start(self): + if not os.path.exists(self._serverPath): + raise Exception( + "%s server not found at %s" % (self._name, self._serverPath) + ) + + self._log.info( + "mozserve | Found %s server path: %s" % (self._name, self._serverPath) + ) + + if not os.path.exists(self._nodeBin) or not os.path.isfile(self._nodeBin): + raise Exception("node not found at path %s" % (self._nodeBin)) + + self._log.info("Found node at %s" % (self._nodeBin)) + + try: + # We pipe stdin to node because the server will exit when its + # stdin reaches EOF + with popenCleanupHack(self._isWin): + process = Popen( + [ + self._nodeBin, + self._serverPath, + "serverPort={}".format(self._dstServerPort), + "listeningPort={}".format(self._port), + "alpn={}".format(self._alpn), + ], + stdin=PIPE, + stdout=PIPE, + stderr=PIPE, + env=self._env, + cwd=os.getcwd(), + universal_newlines=True, + ) + self._nodeProc = process + + msg = process.stdout.readline() + self._log.info("runtests.py | %s server msg: %s" % (self._name, msg)) + if "server listening" in msg: + searchObj = re.search(self._searchStr, msg, 0) + if searchObj: + self._port = int(searchObj.group(1)) + self._log.info( + "%s server started at port: %d" % (self._name, self._port) + ) + except OSError as e: + # This occurs if the subprocess couldn't be started + self._log.error("Could not run %s server: %s" % (self._name, str(e))) + + def stop(self): + """ + Shut down our node process, if it exists + """ + if self._nodeProc is not None: + if self._nodeProc.poll() is not None: + self._log.info("Node server already dead %s" % (self._nodeProc.poll())) + else: + self._nodeProc.terminate() + + def dumpOutput(fd, label): + firstTime = True + for msg in fd: + if firstTime: + firstTime = False + self._log.info("Process %s" % label) + self._log.info(msg) + + dumpOutput(self._nodeProc.stdout, "stdout") + dumpOutput(self._nodeProc.stderr, "stderr") + + self._nodeProc = None + + +class DoHServer(object): + """ + Class which encapsulates the DoH server + """ + + def __init__(self, options, env, logger): + options["searchStr"] = r"DoH server listening on ports ([0-9]+)" + self._server = NodeHttp2Server("DoH", options, env, logger) + + def port(self): + return self._server.port() + + def start(self): + self._server.start() + + def stop(self): + self._server.stop() + + +class Http2Server(object): + """ + Class which encapsulates the Http2 server + """ + + def __init__(self, options, env, logger): + options["searchStr"] = r"Http2 server listening on ports ([0-9]+)" + options["dstServerPort"] = -1 + options["alpn"] = "" + self._server = NodeHttp2Server("Http/2", options, env, logger) + + def port(self): + return self._server.port() + + def start(self): + self._server.start() + + def stop(self): + self._server.stop() diff --git a/testing/mozbase/mozserve/setup.py b/testing/mozbase/mozserve/setup.py new file mode 100644 index 0000000000..c00000bb0c --- /dev/null +++ b/testing/mozbase/mozserve/setup.py @@ -0,0 +1,17 @@ +# 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 setuptools import setup + +PACKAGE_VERSION = "0.1" + +setup( + name="mozserve", + version=PACKAGE_VERSION, + description="Python test server launcher intended for use with Mozilla testing", + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + license="MPL", + packages=["mozserve"], + zip_safe=False, +) diff --git a/testing/mozbase/mozsystemmonitor/README.rst b/testing/mozbase/mozsystemmonitor/README.rst new file mode 100644 index 0000000000..c8dad37671 --- /dev/null +++ b/testing/mozbase/mozsystemmonitor/README.rst @@ -0,0 +1,12 @@ +================ +mozsystemmonitor +================ + +mozsystemmonitor contains modules for monitoring a running system. + +SystemResourceMonitor +===================== + +mozsystemmonitor.resourcemonitor.SystemResourceMonitor is class used to +measure system resource usage. It is useful to get a handle on what an +overall system is doing. diff --git a/testing/mozbase/mozsystemmonitor/mozsystemmonitor/__init__.py b/testing/mozbase/mozsystemmonitor/mozsystemmonitor/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/mozbase/mozsystemmonitor/mozsystemmonitor/__init__.py diff --git a/testing/mozbase/mozsystemmonitor/mozsystemmonitor/resourcemonitor.py b/testing/mozbase/mozsystemmonitor/mozsystemmonitor/resourcemonitor.py new file mode 100644 index 0000000000..978d7d6911 --- /dev/null +++ b/testing/mozbase/mozsystemmonitor/mozsystemmonitor/resourcemonitor.py @@ -0,0 +1,1220 @@ +# 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/. + +import multiprocessing +import sys +import time +import warnings +from collections import OrderedDict, namedtuple +from contextlib import contextmanager + + +class PsutilStub(object): + def __init__(self): + self.sswap = namedtuple( + "sswap", ["total", "used", "free", "percent", "sin", "sout"] + ) + self.sdiskio = namedtuple( + "sdiskio", + [ + "read_count", + "write_count", + "read_bytes", + "write_bytes", + "read_time", + "write_time", + ], + ) + self.pcputimes = namedtuple("pcputimes", ["user", "system"]) + self.svmem = namedtuple( + "svmem", + [ + "total", + "available", + "percent", + "used", + "free", + "active", + "inactive", + "buffers", + "cached", + ], + ) + + def cpu_count(self, logical=True): + return 0 + + def cpu_percent(self, a, b): + return [0] + + def cpu_times(self, percpu): + if percpu: + return [self.pcputimes(0, 0)] + else: + return self.pcputimes(0, 0) + + def disk_io_counters(self): + return self.sdiskio(0, 0, 0, 0, 0, 0) + + def swap_memory(self): + return self.sswap(0, 0, 0, 0, 0, 0) + + def virtual_memory(self): + return self.svmem(0, 0, 0, 0, 0, 0, 0, 0, 0) + + +# psutil will raise NotImplementedError if the platform is not supported. +try: + import psutil + + have_psutil = True +except Exception: + try: + # The PsutilStub should get us time intervals, at least + psutil = PsutilStub() + except Exception: + psutil = None + + have_psutil = False + + +def get_disk_io_counters(): + try: + io_counters = psutil.disk_io_counters() + + if io_counters is None: + return PsutilStub().disk_io_counters() + except RuntimeError: + io_counters = PsutilStub().disk_io_counters() + + return io_counters + + +def _poll(pipe, poll_interval=0.1): + """Wrap multiprocessing.Pipe.poll to hide POLLERR and POLLIN + exceptions. + + multiprocessing.Pipe is not actually a pipe on at least Linux. + That has an effect on the expected outcome of reading from it when + the other end of the pipe dies, leading to possibly hanging on revc() + below. + """ + try: + return pipe.poll(poll_interval) + except Exception: + # Poll might throw an exception even though there's still + # data to read. That happens when the underlying system call + # returns both POLLERR and POLLIN, but python doesn't tell us + # about it. So assume there is something to read, and we'll + # get an exception when trying to read the data. + return True + + +def _collect(pipe, poll_interval): + """Collects system metrics. + + This is the main function for the background process. It collects + data then forwards it on a pipe until told to stop. + """ + + data = [] + + try: + # Establish initial values. + + io_last = get_disk_io_counters() + swap_last = psutil.swap_memory() + psutil.cpu_percent(None, True) + cpu_last = psutil.cpu_times(True) + last_time = time.monotonic() + + sin_index = swap_last._fields.index("sin") + sout_index = swap_last._fields.index("sout") + + sleep_interval = poll_interval + + while not _poll(pipe, poll_interval=sleep_interval): + io = get_disk_io_counters() + virt_mem = psutil.virtual_memory() + swap_mem = psutil.swap_memory() + cpu_percent = psutil.cpu_percent(None, True) + cpu_times = psutil.cpu_times(True) + # Take the timestamp as soon as possible after getting cpu_times + # to reduce the likelihood of our process being interrupted between + # the two instructions. Having a delayed timestamp would cause the + # next sample to report more than 100% CPU time. + measured_end_time = time.monotonic() + + # TODO Does this wrap? At 32 bits? At 64 bits? + # TODO Consider patching "delta" API to upstream. + io_diff = [v - io_last[i] for i, v in enumerate(io)] + io_last = io + + cpu_diff = [] + for core, values in enumerate(cpu_times): + cpu_diff.append([v - cpu_last[core][i] for i, v in enumerate(values)]) + + cpu_last = cpu_times + + swap_entry = list(swap_mem) + swap_entry[sin_index] = swap_mem.sin - swap_last.sin + swap_entry[sout_index] = swap_mem.sout - swap_last.sout + swap_last = swap_mem + + data.append( + ( + last_time, + measured_end_time, + io_diff, + cpu_diff, + cpu_percent, + list(virt_mem), + swap_entry, + ) + ) + + collection_overhead = time.monotonic() - last_time - sleep_interval + last_time = measured_end_time + sleep_interval = max(poll_interval / 2, poll_interval - collection_overhead) + + except Exception as e: + warnings.warn("_collect failed: %s" % e) + + finally: + for entry in data: + pipe.send(entry) + + pipe.send(("done", None, None, None, None, None, None)) + pipe.close() + + sys.exit(0) + + +SystemResourceUsage = namedtuple( + "SystemResourceUsage", + ["start", "end", "cpu_times", "cpu_percent", "io", "virt", "swap"], +) + + +class SystemResourceMonitor(object): + """Measures system resources. + + Each instance measures system resources from the time it is started + until it is finished. It does this on a separate process so it doesn't + impact execution of the main Python process. + + Each instance is a one-shot instance. It cannot be used to record multiple + durations. + + Aside from basic data gathering, the class supports basic analysis + capabilities. You can query for data between ranges. You can also tell it + when certain events occur and later grab data relevant to those events or + plot those events on a timeline. + + The resource monitor works by periodically polling the state of the + system. By default, it polls every second. This can be adjusted depending + on the required granularity of the data and considerations for probe + overhead. It tries to probe at the interval specified. However, variations + should be expected. Fast and well-behaving systems should experience + variations in the 1ms range. Larger variations may exist if the system is + under heavy load or depending on how accurate socket polling is on your + system. + + In its current implementation, data is not available until collection has + stopped. This may change in future iterations. + + Usage + ===== + + monitor = SystemResourceMonitor() + monitor.start() + + # Record that a single event in time just occurred. + foo.do_stuff() + monitor.record_event('foo_did_stuff') + + # Record that we're about to perform a possibly long-running event. + with monitor.phase('long_job'): + foo.do_long_running_job() + + # Stop recording. Currently we need to stop before data is available. + monitor.stop() + + # Obtain the raw data for the entire probed range. + print('CPU Usage:') + for core in monitor.aggregate_cpu(): + print(core) + + # We can also request data corresponding to a specific phase. + for data in monitor.phase_usage('long_job'): + print(data.cpu_percent) + """ + + # The interprocess communication is complicated enough to warrant + # explanation. To work around the Python GIL, we launch a separate + # background process whose only job is to collect metrics. If we performed + # collection in the main process, the polling interval would be + # inconsistent if a long-running function were on the stack. Since the + # child process is independent of the instantiating process, data + # collection should be evenly spaced. + # + # As the child process collects data, it buffers it locally. When + # collection stops, it flushes all that data to a pipe to be read by + # the parent process. + + instance = None + + def __init__(self, poll_interval=1.0, metadata={}): + """Instantiate a system resource monitor instance. + + The instance is configured with a poll interval. This is the interval + between samples, in float seconds. + """ + self.start_time = None + self.end_time = None + + self.events = [] + self.markers = [] + self.phases = OrderedDict() + + self._active_phases = {} + self._active_markers = {} + + self._running = False + self._stopped = False + self._process = None + + if psutil is None: + return + + # This try..except should not be needed! However, some tools (like + # |mach build|) attempt to load psutil before properly creating a + # virtualenv by building psutil. As a result, python/psutil may be in + # sys.path and its .py files may pick up the psutil C extension from + # the system install. If the versions don't match, we typically see + # failures invoking one of these functions. + try: + cpu_percent = psutil.cpu_percent(0.0, True) + cpu_times = psutil.cpu_times(False) + io = get_disk_io_counters() + virt = psutil.virtual_memory() + swap = psutil.swap_memory() + except Exception as e: + warnings.warn("psutil failed to run: %s" % e) + return + + self._cpu_cores = len(cpu_percent) + self._cpu_times_type = type(cpu_times) + self._cpu_times_len = len(cpu_times) + self._io_type = type(io) + self._io_len = len(io) + self._virt_type = type(virt) + self._virt_len = len(virt) + self._swap_type = type(swap) + self._swap_len = len(swap) + self.start_timestamp = time.time() + + self._pipe, child_pipe = multiprocessing.Pipe(True) + + self._process = multiprocessing.Process( + target=_collect, args=(child_pipe, poll_interval) + ) + self.poll_interval = poll_interval + self.metadata = metadata + + def __del__(self): + if self._running: + self._pipe.send(("terminate",)) + self._process.join() + + # Methods to control monitoring. + + def start(self): + """Start measuring system-wide CPU resource utilization. + + You should only call this once per instance. + """ + if not self._process: + return + + self._process.start() + self._running = True + self.start_time = time.monotonic() + SystemResourceMonitor.instance = self + + def stop(self): + """Stop measuring system-wide CPU resource utilization. + + You should call this if and only if you have called start(). You should + always pair a stop() with a start(). + + Currently, data is not available until you call stop(). + """ + if not self._process: + self._stopped = True + return + + self.stop_time = time.monotonic() + assert not self._stopped + + try: + self._pipe.send(("terminate",)) + except Exception: + pass + self._stopped = True + + self.measurements = [] + + # The child process will send each data sample over the pipe + # as a separate data structure. When it has finished sending + # samples, it sends a special "done" message to indicate it + # is finished. + + while _poll(self._pipe, poll_interval=0.1): + try: + ( + start_time, + end_time, + io_diff, + cpu_diff, + cpu_percent, + virt_mem, + swap_mem, + ) = self._pipe.recv() + except Exception as e: + warnings.warn("failed to receive data: %s" % e) + # Assume we can't recover + break + + # There should be nothing after the "done" message so + # terminate. + if start_time == "done": + break + + try: + io = self._io_type(*io_diff) + virt = self._virt_type(*virt_mem) + swap = self._swap_type(*swap_mem) + cpu_times = [self._cpu_times_type(*v) for v in cpu_diff] + + self.measurements.append( + SystemResourceUsage( + start_time, end_time, cpu_times, cpu_percent, io, virt, swap + ) + ) + except Exception: + # We also can't recover, but output the data that caused the exception + warnings.warn( + "failed to read the received data: %s" + % str( + ( + start_time, + end_time, + io_diff, + cpu_diff, + cpu_percent, + virt_mem, + swap_mem, + ) + ) + ) + + break + + # We establish a timeout so we don't hang forever if the child + # process has crashed. + if self._running: + self._process.join(10) + if self._process.is_alive(): + self._process.terminate() + self._process.join(10) + + self._running = False + SystemResourceUsage.instance = None + self.end_time = time.monotonic() + + # Methods to record events alongside the monitored data. + + @staticmethod + def record_event(name): + """Record an event as occuring now. + + Events are actions that occur at a specific point in time. If you are + looking for an action that has a duration, see the phase API below. + """ + if SystemResourceMonitor.instance: + SystemResourceMonitor.instance.events.append((time.monotonic(), name)) + + @staticmethod + def record_marker(name, start, end, text): + """Record a marker with a duration and an optional text + + Markers are typically used to record when a single command happened. + For actions with a longer duration that justifies tracking resource use + see the phase API below. + """ + if SystemResourceMonitor.instance: + SystemResourceMonitor.instance.markers.append((name, start, end, text)) + + @staticmethod + def begin_marker(name, text, disambiguator=None): + if SystemResourceMonitor.instance: + id = name + ":" + text + if disambiguator: + id += ":" + disambiguator + SystemResourceMonitor.instance._active_markers[id] = time.monotonic() + + @staticmethod + def end_marker(name, text, disambiguator=None): + if not SystemResourceMonitor.instance: + return + end = time.monotonic() + id = name + ":" + text + if disambiguator: + id += ":" + disambiguator + if not id in SystemResourceMonitor.instance._active_markers: + return + start = SystemResourceMonitor.instance._active_markers.pop(id) + SystemResourceMonitor.instance.record_marker(name, start, end, text) + + @contextmanager + def phase(self, name): + """Context manager for recording an active phase.""" + self.begin_phase(name) + yield + self.finish_phase(name) + + def begin_phase(self, name): + """Record the start of a phase. + + Phases are actions that have a duration. Multiple phases can be active + simultaneously. Phases can be closed in any order. + + Keep in mind that if phases occur in parallel, it will become difficult + to isolate resource utilization specific to individual phases. + """ + assert name not in self._active_phases + + self._active_phases[name] = time.monotonic() + + def finish_phase(self, name): + """Record the end of a phase.""" + + assert name in self._active_phases + + phase = (self._active_phases[name], time.monotonic()) + self.phases[name] = phase + del self._active_phases[name] + + return phase[1] - phase[0] + + # Methods to query data. + + def range_usage(self, start=None, end=None): + """Obtain the usage data falling within the given time range. + + This is a generator of SystemResourceUsage. + + If no time range bounds are given, all data is returned. + """ + if not self._stopped or self.start_time is None: + return + + if start is None: + start = self.start_time + + if end is None: + end = self.end_time + + for entry in self.measurements: + if entry.start < start: + continue + + if entry.end > end: + break + + yield entry + + def phase_usage(self, phase): + """Obtain usage data for a specific phase. + + This is a generator of SystemResourceUsage. + """ + time_start, time_end = self.phases[phase] + + return self.range_usage(time_start, time_end) + + def between_events_usage(self, start_event, end_event): + """Obtain usage data between two point events. + + This is a generator of SystemResourceUsage. + """ + start_time = None + end_time = None + + for t, name in self.events: + if name == start_event: + start_time = t + elif name == end_event: + end_time = t + + if start_time is None: + raise Exception("Could not find start event: %s" % start_event) + + if end_time is None: + raise Exception("Could not find end event: %s" % end_event) + + return self.range_usage(start_time, end_time) + + def aggregate_cpu_percent(self, start=None, end=None, phase=None, per_cpu=True): + """Obtain the aggregate CPU percent usage for a range. + + Returns a list of floats representing average CPU usage percentage per + core if per_cpu is True (the default). If per_cpu is False, return a + single percentage value. + + By default this will return data for the entire instrumented interval. + If phase is defined, data for a named phase will be returned. If start + and end are defined, these times will be fed into range_usage(). + """ + cpu = [[] for i in range(0, self._cpu_cores)] + + if phase: + data = self.phase_usage(phase) + else: + data = self.range_usage(start, end) + + for usage in data: + for i, v in enumerate(usage.cpu_percent): + cpu[i].append(v) + + samples = len(cpu[0]) + + if not samples: + return 0 + + if per_cpu: + # pylint --py3k W1619 + return [sum(x) / samples for x in cpu] + + cores = [sum(x) for x in cpu] + + # pylint --py3k W1619 + return sum(cores) / len(cpu) / samples + + def aggregate_cpu_times(self, start=None, end=None, phase=None, per_cpu=True): + """Obtain the aggregate CPU times for a range. + + If per_cpu is True (the default), this returns a list of named tuples. + Each tuple is as if it were returned by psutil.cpu_times(). If per_cpu + is False, this returns a single named tuple of the aforementioned type. + """ + empty = [0 for i in range(0, self._cpu_times_len)] + cpu = [list(empty) for i in range(0, self._cpu_cores)] + + if phase: + data = self.phase_usage(phase) + else: + data = self.range_usage(start, end) + + for usage in data: + for i, core_values in enumerate(usage.cpu_times): + for j, v in enumerate(core_values): + cpu[i][j] += v + + if per_cpu: + return [self._cpu_times_type(*v) for v in cpu] + + sums = list(empty) + for core in cpu: + for i, v in enumerate(core): + sums[i] += v + + return self._cpu_times_type(*sums) + + def aggregate_io(self, start=None, end=None, phase=None): + """Obtain aggregate I/O counters for a range. + + Returns an iostat named tuple from psutil. + """ + + io = [0 for i in range(self._io_len)] + + if phase: + data = self.phase_usage(phase) + else: + data = self.range_usage(start, end) + + for usage in data: + for i, v in enumerate(usage.io): + io[i] += v + + return self._io_type(*io) + + def min_memory_available(self, start=None, end=None, phase=None): + """Return the minimum observed available memory number from a range. + + Returns long bytes of memory available. + + See psutil for notes on how this is calculated. + """ + if phase: + data = self.phase_usage(phase) + else: + data = self.range_usage(start, end) + + values = [] + + for usage in data: + values.append(usage.virt.available) + + return min(values) + + def max_memory_percent(self, start=None, end=None, phase=None): + """Returns the maximum percentage of system memory used. + + Returns a float percentage. 1.00 would mean all system memory was in + use at one point. + """ + if phase: + data = self.phase_usage(phase) + else: + data = self.range_usage(start, end) + + values = [] + + for usage in data: + values.append(usage.virt.percent) + + return max(values) + + def as_dict(self): + """Convert the recorded data to a dict, suitable for serialization. + + The returned dict has the following keys: + + version - Integer version number being rendered. Currently 2. + cpu_times_fields - A list of the names of the CPU times fields. + io_fields - A list of the names of the I/O fields. + virt_fields - A list of the names of the virtual memory fields. + swap_fields - A list of the names of the swap memory fields. + samples - A list of dicts containing low-level measurements. + events - A list of lists representing point events. The inner list + has 2 elements, the float wall time of the event and the string + event name. + phases - A list of dicts describing phases. Each phase looks a lot + like an entry from samples (see below). Some phases may not have + data recorded against them, so some keys may be None. + overall - A dict representing overall resource usage. This resembles + a sample entry. + system - Contains additional information about the system including + number of processors and amount of memory. + + Each entry in the sample list is a dict with the following keys: + + start - Float wall time this measurement began on. + end - Float wall time this measurement ended on. + io - List of numerics for I/O values. + virt - List of numerics for virtual memory values. + swap - List of numerics for swap memory values. + cpu_percent - List of floats representing CPU percent on each core. + cpu_times - List of lists. Main list is each core. Inner lists are + lists of floats representing CPU times on that core. + cpu_percent_mean - Float of mean CPU percent across all cores. + cpu_times_sum - List of floats representing the sum of CPU times + across all cores. + cpu_times_total - Float representing the sum of all CPU times across + all cores. This is useful for calculating the percent in each CPU + time. + """ + + o = dict( + version=2, + cpu_times_fields=list(self._cpu_times_type._fields), + io_fields=list(self._io_type._fields), + virt_fields=list(self._virt_type._fields), + swap_fields=list(self._swap_type._fields), + samples=[], + phases=[], + system={}, + ) + + def populate_derived(e): + if e["cpu_percent_cores"]: + # pylint --py3k W1619 + e["cpu_percent_mean"] = sum(e["cpu_percent_cores"]) / len( + e["cpu_percent_cores"] + ) + else: + e["cpu_percent_mean"] = None + + if e["cpu_times"]: + e["cpu_times_sum"] = [0.0] * self._cpu_times_len + for i in range(0, self._cpu_times_len): + e["cpu_times_sum"][i] = sum(core[i] for core in e["cpu_times"]) + + e["cpu_times_total"] = sum(e["cpu_times_sum"]) + + def phase_entry(name, start, end): + e = dict( + name=name, + start=start, + end=end, + duration=end - start, + cpu_percent_cores=self.aggregate_cpu_percent(phase=name), + cpu_times=[list(c) for c in self.aggregate_cpu_times(phase=name)], + io=list(self.aggregate_io(phase=name)), + ) + populate_derived(e) + return e + + for m in self.measurements: + e = dict( + start=m.start, + end=m.end, + io=list(m.io), + virt=list(m.virt), + swap=list(m.swap), + cpu_percent_cores=list(m.cpu_percent), + cpu_times=list(list(cpu) for cpu in m.cpu_times), + ) + + populate_derived(e) + o["samples"].append(e) + + if o["samples"]: + o["start"] = o["samples"][0]["start"] + o["end"] = o["samples"][-1]["end"] + o["duration"] = o["end"] - o["start"] + o["overall"] = phase_entry(None, o["start"], o["end"]) + else: + o["start"] = None + o["end"] = None + o["duration"] = None + o["overall"] = None + + o["events"] = [list(ev) for ev in self.events] + + for phase, v in self.phases.items(): + o["phases"].append(phase_entry(phase, v[0], v[1])) + + if have_psutil: + o["system"].update( + dict( + cpu_logical_count=psutil.cpu_count(logical=True), + cpu_physical_count=psutil.cpu_count(logical=False), + swap_total=psutil.swap_memory()[0], + vmem_total=psutil.virtual_memory()[0], + ) + ) + + return o + + def as_profile(self): + profile_time = time.monotonic() + start_time = self.start_time + profile = { + "meta": { + "processType": 0, + "product": "mach", + "stackwalk": 0, + "version": 27, + "preprocessedProfileVersion": 47, + "symbolicationNotSupported": True, + "interval": self.poll_interval * 1000, + "startTime": self.start_timestamp * 1000, + "profilingStartTime": 0, + "logicalCPUs": psutil.cpu_count(logical=True), + "physicalCPUs": psutil.cpu_count(logical=False), + "mainMemory": psutil.virtual_memory()[0], + "markerSchema": [ + { + "name": "Phase", + "tooltipLabel": "{marker.data.phase}", + "tableLabel": "{marker.name} — {marker.data.phase} — CPU time: {marker.data.cpuTime} ({marker.data.cpuPercent})", + "chartLabel": "{marker.data.phase}", + "display": [ + "marker-chart", + "marker-table", + "timeline-overview", + ], + "data": [ + { + "key": "cpuTime", + "label": "CPU Time", + "format": "duration", + }, + { + "key": "cpuPercent", + "label": "CPU Percent", + "format": "string", + }, + ], + }, + { + "name": "Text", + "tooltipLabel": "{marker.name}", + "tableLabel": "{marker.name} — {marker.data.text}", + "chartLabel": "{marker.data.text}", + "display": ["marker-chart", "marker-table"], + "data": [ + { + "key": "text", + "label": "Description", + "format": "string", + "searchable": True, + } + ], + }, + { + "name": "Mem", + "tooltipLabel": "{marker.name}", + "display": [], + "data": [ + {"key": "used", "label": "Memory Used", "format": "bytes"}, + { + "key": "cached", + "label": "Memory cached", + "format": "bytes", + }, + { + "key": "buffers", + "label": "Memory buffers", + "format": "bytes", + }, + ], + "graphs": [ + {"key": "used", "color": "orange", "type": "line-filled"} + ], + }, + { + "name": "IO", + "tooltipLabel": "{marker.name}", + "display": [], + "data": [ + { + "key": "write_bytes", + "label": "Written", + "format": "bytes", + }, + { + "key": "write_count", + "label": "Write count", + "format": "integer", + }, + {"key": "read_bytes", "label": "Read", "format": "bytes"}, + { + "key": "read_count", + "label": "Read count", + "format": "integer", + }, + ], + "graphs": [ + {"key": "read_bytes", "color": "green", "type": "bar"}, + {"key": "write_bytes", "color": "red", "type": "bar"}, + ], + }, + ], + "usesOnlyOneStackType": True, + }, + "libs": [], + "threads": [ + { + "processType": "default", + "processName": "mach", + "processStartupTime": 0, + "processShutdownTime": None, + "registerTime": 0, + "unregisterTime": None, + "pausedRanges": [], + "showMarkersInTimeline": True, + "name": "", + "isMainThread": False, + "pid": "0", + "tid": 0, + "samples": { + "weightType": "samples", + "weight": None, + "stack": [], + "time": [], + "length": 0, + }, + "stringArray": ["(root)"], + "markers": { + "data": [], + "name": [], + "startTime": [], + "endTime": [], + "phase": [], + "category": [], + "length": 0, + }, + "stackTable": { + "frame": [0], + "prefix": [None], + "category": [0], + "subcategory": [0], + "length": 1, + }, + "frameTable": { + "address": [-1], + "inlineDepth": [0], + "category": [None], + "subcategory": [0], + "func": [0], + "nativeSymbol": [None], + "innerWindowID": [0], + "implementation": [None], + "line": [None], + "column": [None], + "length": 1, + }, + "funcTable": { + "isJS": [False], + "relevantForJS": [False], + "name": [0], + "resource": [-1], + "fileName": [None], + "lineNumber": [None], + "columnNumber": [None], + "length": 1, + }, + "resourceTable": { + "lib": [], + "name": [], + "host": [], + "type": [], + "length": 0, + }, + "nativeSymbols": { + "libIndex": [], + "address": [], + "name": [], + "functionSize": [], + "length": 0, + }, + } + ], + "counters": [], + } + + firstThread = profile["threads"][0] + markers = firstThread["markers"] + for key in self.metadata: + profile["meta"][key] = self.metadata[key] + + def get_string_index(string): + stringArray = firstThread["stringArray"] + try: + return stringArray.index(string) + except ValueError: + stringArray.append(string) + return len(stringArray) - 1 + + def add_marker(name_index, start, end, data, precision=None): + # The precision argument allows setting how many digits after the + # decimal point are desired. + # For resource use samples where we sample with a timer, an integer + # number of ms is good enough. + # For short duration markers, the profiler front-end may show up to + # 3 digits after the decimal point (ie. µs precision). + markers["startTime"].append(round((start - start_time) * 1000, precision)) + if end is None: + markers["endTime"].append(None) + # 0 = Instant marker + markers["phase"].append(0) + else: + markers["endTime"].append(round((end - start_time) * 1000, precision)) + # 1 = marker with start and end times, 2 = start but no end. + markers["phase"].append(1) + markers["category"].append(0) + markers["name"].append(name_index) + markers["data"].append(data) + markers["length"] = markers["length"] + 1 + + def format_percent(value): + return str(round(value, 1)) + "%" + + samples = firstThread["samples"] + samples["stack"].append(0) + samples["time"].append(0) + + cpu_string_index = get_string_index("CPU Use") + memory_string_index = get_string_index("Memory") + io_string_index = get_string_index("IO") + valid_cpu_fields = set() + for m in self.measurements: + # Ignore samples that are much too short. + if m.end - m.start < self.poll_interval / 10: + continue + + # Sample times + samples["stack"].append(0) + samples["time"].append(round((m.end - start_time) * 1000)) + + # CPU + markerData = { + "type": "CPU", + "cpuPercent": format_percent( + sum(list(m.cpu_percent)) / len(m.cpu_percent) + ), + } + + # due to inconsistencies in the sampling rate, sometimes the + # cpu_times add up to more than 100%, causing annoying + # spikes in the CPU use charts. Avoid them by dividing the + # values by the total if it is above 1. + total = 0 + for field in ["nice", "user", "system", "iowait", "softirq", "idle"]: + if hasattr(m.cpu_times[0], field): + total += sum(getattr(core, field) for core in m.cpu_times) / ( + m.end - m.start + ) + divisor = total if total > 1 else 1 + + total = 0 + for field in ["nice", "user", "system", "iowait", "softirq"]: + if hasattr(m.cpu_times[0], field): + total += ( + sum(getattr(core, field) for core in m.cpu_times) + / (m.end - m.start) + / divisor + ) + if total > 0: + valid_cpu_fields.add(field) + markerData[field] = round(total, 3) + for field in ["nice", "user", "system", "iowait", "idle"]: + if hasattr(m.cpu_times[0], field): + markerData[field + "_pct"] = format_percent( + 100 + * sum(getattr(core, field) for core in m.cpu_times) + / (m.end - m.start) + / len(m.cpu_times) + ) + add_marker(cpu_string_index, m.start, m.end, markerData) + + # Memory + markerData = {"type": "Mem", "used": m.virt.used} + if hasattr(m.virt, "cached"): + markerData["cached"] = m.virt.cached + if hasattr(m.virt, "buffers"): + markerData["buffers"] = m.virt.buffers + add_marker(memory_string_index, m.start, m.end, markerData) + + # IO + markerData = { + "type": "IO", + "read_count": m.io.read_count, + "read_bytes": m.io.read_bytes, + "write_count": m.io.write_count, + "write_bytes": m.io.write_bytes, + } + add_marker(io_string_index, m.start, m.end, markerData) + samples["length"] = len(samples["stack"]) + + # The marker schema for CPU markers should only contain graph + # definitions for fields we actually have, or the profiler front-end + # will detect missing data and skip drawing the track entirely. + cpuSchema = { + "name": "CPU", + "tooltipLabel": "{marker.name}", + "display": [], + "data": [{"key": "cpuPercent", "label": "CPU Percent", "format": "string"}], + "graphs": [], + } + cpuData = cpuSchema["data"] + for field, label in { + "user": "User %", + "iowait": "IO Wait %", + "system": "System %", + "nice": "Nice %", + "idle": "Idle %", + }.items(): + if field in valid_cpu_fields or field == "idle": + cpuData.append( + {"key": field + "_pct", "label": label, "format": "string"} + ) + cpuGraphs = cpuSchema["graphs"] + for field, color in { + "softirq": "orange", + "iowait": "red", + "system": "grey", + "user": "yellow", + "nice": "blue", + }.items(): + if field in valid_cpu_fields: + cpuGraphs.append({"key": field, "color": color, "type": "bar"}) + profile["meta"]["markerSchema"].insert(0, cpuSchema) + + # Create markers for phases + phase_string_index = get_string_index("Phase") + for phase, v in self.phases.items(): + markerData = {"type": "Phase", "phase": phase} + + cpu_percent_cores = self.aggregate_cpu_percent(phase=phase) + if cpu_percent_cores: + markerData["cpuPercent"] = format_percent( + sum(cpu_percent_cores) / len(cpu_percent_cores) + ) + + cpu_times = [list(c) for c in self.aggregate_cpu_times(phase=phase)] + cpu_times_sum = [0.0] * self._cpu_times_len + for i in range(0, self._cpu_times_len): + cpu_times_sum[i] = sum(core[i] for core in cpu_times) + total_cpu_time_ms = sum(cpu_times_sum) * 1000 + if total_cpu_time_ms > 0: + markerData["cpuTime"] = total_cpu_time_ms + + add_marker(phase_string_index, v[0], v[1], markerData, 3) + + # Add generic markers + for name, start, end, text in self.markers: + markerData = {"type": "Text"} + if text: + markerData["text"] = text + add_marker(get_string_index(name), start, end, markerData, 3) + if self.events: + event_string_index = get_string_index("Event") + for event_time, text in self.events: + if text: + add_marker( + event_string_index, + event_time, + None, + {"type": "Text", "text": text}, + 3, + ) + + # We may have spent some time generating this profile, and there might + # also have been some time elapsed between stopping the resource + # monitor, and the profile being created. These are hidden costs that + # we should account for as best as possible, and the best we can do + # is to make the profile contain information about this cost somehow. + # We extend the profile end time up to now rather than self.end_time, + # and add a phase covering that period of time. + now = time.monotonic() + profile["meta"]["profilingEndTime"] = round( + (now - self.start_time) * 1000 + 0.0005, 3 + ) + markerData = { + "type": "Phase", + "phase": "teardown", + } + add_marker(phase_string_index, self.stop_time, now, markerData, 3) + teardown_string_index = get_string_index("resourcemonitor") + markerData = { + "type": "Text", + "text": "stop", + } + add_marker(teardown_string_index, self.stop_time, self.end_time, markerData, 3) + markerData = { + "type": "Text", + "text": "as_profile", + } + add_marker(teardown_string_index, profile_time, now, markerData, 3) + + # Unfortunately, whatever the caller does with the profile (e.g. json) + # or after that (hopefully, exit) is not going to be counted, but we + # assume it's fast enough. + return profile diff --git a/testing/mozbase/mozsystemmonitor/setup.cfg b/testing/mozbase/mozsystemmonitor/setup.cfg new file mode 100644 index 0000000000..3c6e79cf31 --- /dev/null +++ b/testing/mozbase/mozsystemmonitor/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 diff --git a/testing/mozbase/mozsystemmonitor/setup.py b/testing/mozbase/mozsystemmonitor/setup.py new file mode 100644 index 0000000000..7b575ef4bd --- /dev/null +++ b/testing/mozbase/mozsystemmonitor/setup.py @@ -0,0 +1,33 @@ +# 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/. + +import os + +from setuptools import setup + +PACKAGE_VERSION = "1.0.1" + +try: + pwd = os.path.dirname(os.path.abspath(__file__)) + description = open(os.path.join(pwd, "README.rst")).read() +except Exception: + description = "" + +setup( + name="mozsystemmonitor", + description="Monitor system resource usage.", + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + classifiers=[ + "Programming Language :: Python :: 3", + ], + # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + license="MPL 2.0", + keywords="mozilla", + author="Mozilla Automation and Tools Team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + packages=["mozsystemmonitor"], + version=PACKAGE_VERSION, + install_requires=["psutil >= 3.1.1"], +) diff --git a/testing/mozbase/mozsystemmonitor/tests/manifest.toml b/testing/mozbase/mozsystemmonitor/tests/manifest.toml new file mode 100644 index 0000000000..babb20c1b0 --- /dev/null +++ b/testing/mozbase/mozsystemmonitor/tests/manifest.toml @@ -0,0 +1,4 @@ +[DEFAULT] +subsuite = "mozbase" + +["test_resource_monitor.py"] diff --git a/testing/mozbase/mozsystemmonitor/tests/test_resource_monitor.py b/testing/mozbase/mozsystemmonitor/tests/test_resource_monitor.py new file mode 100644 index 0000000000..6de99152b9 --- /dev/null +++ b/testing/mozbase/mozsystemmonitor/tests/test_resource_monitor.py @@ -0,0 +1,183 @@ +# 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/. + +import multiprocessing +import time +import unittest + +import mozunit +from six import integer_types + +try: + import psutil +except ImportError: + psutil = None + +from mozsystemmonitor.resourcemonitor import SystemResourceMonitor, SystemResourceUsage + + +@unittest.skipIf(psutil is None, "Resource monitor requires psutil.") +class TestResourceMonitor(unittest.TestCase): + def test_basic(self): + monitor = SystemResourceMonitor(poll_interval=0.5) + + monitor.start() + time.sleep(3) + + monitor.stop() + + data = list(monitor.range_usage()) + self.assertGreater(len(data), 3) + + self.assertIsInstance(data[0], SystemResourceUsage) + + def test_empty(self): + monitor = SystemResourceMonitor(poll_interval=2.0) + monitor.start() + monitor.stop() + + data = list(monitor.range_usage()) + self.assertEqual(len(data), 0) + + def test_phases(self): + monitor = SystemResourceMonitor(poll_interval=0.25) + + monitor.start() + time.sleep(1) + + with monitor.phase("phase1"): + time.sleep(1) + + with monitor.phase("phase2"): + time.sleep(1) + + monitor.stop() + + self.assertEqual(len(monitor.phases), 2) + self.assertEqual(["phase2", "phase1"], list(monitor.phases.keys())) + + all = list(monitor.range_usage()) + data1 = list(monitor.phase_usage("phase1")) + data2 = list(monitor.phase_usage("phase2")) + + self.assertGreater(len(all), len(data1)) + self.assertGreater(len(data1), len(data2)) + + # This could fail if time.monotonic() takes more than 0.1s. It really + # shouldn't. + self.assertAlmostEqual(data1[-1].end, data2[-1].end, delta=0.25) + + def test_no_data(self): + monitor = SystemResourceMonitor() + + data = list(monitor.range_usage()) + self.assertEqual(len(data), 0) + + def test_events(self): + monitor = SystemResourceMonitor(poll_interval=0.25) + + monitor.start() + time.sleep(0.5) + + t0 = time.monotonic() + monitor.record_event("t0") + time.sleep(2) + + monitor.record_event("t1") + time.sleep(0.5) + monitor.stop() + + events = monitor.events + self.assertEqual(len(events), 2) + + event = events[0] + + self.assertEqual(event[1], "t0") + self.assertAlmostEqual(event[0], t0, delta=0.25) + + data = list(monitor.between_events_usage("t0", "t1")) + self.assertGreater(len(data), 0) + + def test_aggregate_cpu(self): + monitor = SystemResourceMonitor(poll_interval=0.25) + + monitor.start() + time.sleep(1) + monitor.stop() + + values = monitor.aggregate_cpu_percent() + self.assertIsInstance(values, list) + self.assertEqual(len(values), multiprocessing.cpu_count()) + for v in values: + self.assertIsInstance(v, float) + + value = monitor.aggregate_cpu_percent(per_cpu=False) + self.assertIsInstance(value, float) + + values = monitor.aggregate_cpu_times() + self.assertIsInstance(values, list) + self.assertGreater(len(values), 0) + self.assertTrue(hasattr(values[0], "user")) + + t = type(values[0]) + + value = monitor.aggregate_cpu_times(per_cpu=False) + self.assertIsInstance(value, t) + + def test_aggregate_io(self): + monitor = SystemResourceMonitor(poll_interval=0.25) + + # There's really no easy way to ensure I/O occurs. For all we know + # reads and writes will all be serviced by the page cache. + monitor.start() + time.sleep(1.0) + monitor.stop() + + values = monitor.aggregate_io() + self.assertTrue(hasattr(values, "read_count")) + + def test_memory(self): + monitor = SystemResourceMonitor(poll_interval=0.25) + + monitor.start() + time.sleep(1.0) + monitor.stop() + + v = monitor.min_memory_available() + self.assertIsInstance(v, integer_types) + + v = monitor.max_memory_percent() + self.assertIsInstance(v, float) + + def test_as_dict(self): + monitor = SystemResourceMonitor(poll_interval=0.25) + + monitor.start() + time.sleep(0.1) + monitor.begin_phase("phase1") + monitor.record_event("foo") + time.sleep(0.1) + monitor.begin_phase("phase2") + monitor.record_event("bar") + time.sleep(0.2) + monitor.finish_phase("phase1") + time.sleep(0.2) + monitor.finish_phase("phase2") + time.sleep(0.4) + monitor.stop() + + d = monitor.as_dict() + + self.assertEqual(d["version"], 2) + self.assertEqual(len(d["events"]), 2) + self.assertEqual(len(d["phases"]), 2) + self.assertIn("system", d) + self.assertIsInstance(d["system"], dict) + self.assertIsInstance(d["overall"], dict) + self.assertIn("duration", d["overall"]) + self.assertIn("cpu_times", d["overall"]) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/moztest/moztest/__init__.py b/testing/mozbase/moztest/moztest/__init__.py new file mode 100644 index 0000000000..c2366466cf --- /dev/null +++ b/testing/mozbase/moztest/moztest/__init__.py @@ -0,0 +1,7 @@ +# 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 moztest import adapters + +__all__ = ["adapters"] diff --git a/testing/mozbase/moztest/moztest/adapters/__init__.py b/testing/mozbase/moztest/moztest/adapters/__init__.py new file mode 100644 index 0000000000..5bd3a52844 --- /dev/null +++ b/testing/mozbase/moztest/moztest/adapters/__init__.py @@ -0,0 +1,7 @@ +# 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 moztest.adapters import unit + +__all__ = ["unit"] diff --git a/testing/mozbase/moztest/moztest/adapters/unit.py b/testing/mozbase/moztest/moztest/adapters/unit.py new file mode 100644 index 0000000000..4273adf353 --- /dev/null +++ b/testing/mozbase/moztest/moztest/adapters/unit.py @@ -0,0 +1,216 @@ +# 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/. + +import sys +import time +import traceback +import unittest +from unittest import TextTestResult + +"""Adapter used to output structuredlog messages from unittest +testsuites""" + + +def get_test_class_name(test): + """ + This method is used to return the full class name from a + :class:`unittest.TestCase` instance. + + It is used as a default to define the "class_name" extra value + passed in structured loggers. You can override the default by + implementing a "get_test_class_name" method on you TestCase subclass. + """ + return "%s.%s" % (test.__class__.__module__, test.__class__.__name__) + + +def get_test_method_name(test): + """ + This method is used to return the full method name from a + :class:`unittest.TestCase` instance. + + It is used as a default to define the "method_name" extra value + passed in structured loggers. You can override the default by + implementing a "get_test_method_name" method on you TestCase subclass. + """ + return test._testMethodName + + +class StructuredTestResult(TextTestResult): + def __init__(self, *args, **kwargs): + self.logger = kwargs.pop("logger") + self.test_list = kwargs.pop("test_list", []) + self.result_callbacks = kwargs.pop("result_callbacks", []) + self.passed = 0 + self.testsRun = 0 + TextTestResult.__init__(self, *args, **kwargs) + + def call_callbacks(self, test, status): + debug_info = {} + for callback in self.result_callbacks: + info = callback(test, status) + if info is not None: + debug_info.update(info) + return debug_info + + def startTestRun(self): + # This would be an opportunity to call the logger's suite_start action, + # however some users may use multiple suites, and per the structured + # logging protocol, this action should only be called once. + pass + + def startTest(self, test): + self.testsRun += 1 + self.logger.test_start(test.id()) + + def stopTest(self, test): + pass + + def stopTestRun(self): + # This would be an opportunity to call the logger's suite_end action, + # however some users may use multiple suites, and per the structured + # logging protocol, this action should only be called once. + pass + + def _extract_err_message(self, err): + # Format an exception message in the style of unittest's _exc_info_to_string + # while maintaining a division between a traceback and a message. + exc_ty, val, _ = err + exc_msg = "".join(traceback.format_exception_only(exc_ty, val)) + if self.buffer: + output_msg = "\n".join([sys.stdout.getvalue(), sys.stderr.getvalue()]) + return "".join([exc_msg, output_msg]) + return exc_msg.rstrip() + + def _extract_stacktrace(self, err, test): + # Format an exception stack in the style of unittest's _exc_info_to_string + # while maintaining a division between a traceback and a message. + # This is mostly borrowed from unittest.result._exc_info_to_string. + + exctype, value, tb = err + while tb and self._is_relevant_tb_level(tb): + tb = tb.tb_next + # Header usually included by print_exception + lines = ["Traceback (most recent call last):\n"] + if exctype is test.failureException and hasattr( + self, "_count_relevant_tb_levels" + ): + length = self._count_relevant_tb_levels(tb) + lines += traceback.format_tb(tb, length) + else: + lines += traceback.format_tb(tb) + return "".join(lines) + + def _get_class_method_name(self, test): + if hasattr(test, "get_test_class_name"): + class_name = test.get_test_class_name() + else: + class_name = get_test_class_name(test) + + if hasattr(test, "get_test_method_name"): + method_name = test.get_test_method_name() + else: + method_name = get_test_method_name(test) + + return {"class_name": class_name, "method_name": method_name} + + def addError(self, test, err): + self.errors.append((test, self._exc_info_to_string(err, test))) + extra = self.call_callbacks(test, "ERROR") + extra.update(self._get_class_method_name(test)) + self.logger.test_end( + test.id(), + "ERROR", + message=self._extract_err_message(err), + expected="PASS", + stack=self._extract_stacktrace(err, test), + extra=extra, + ) + + def addFailure(self, test, err): + extra = self.call_callbacks(test, "FAIL") + extra.update(self._get_class_method_name(test)) + self.logger.test_end( + test.id(), + "FAIL", + message=self._extract_err_message(err), + expected="PASS", + stack=self._extract_stacktrace(err, test), + extra=extra, + ) + + def addSuccess(self, test): + extra = self._get_class_method_name(test) + self.logger.test_end(test.id(), "PASS", expected="PASS", extra=extra) + + def addExpectedFailure(self, test, err): + extra = self.call_callbacks(test, "FAIL") + extra.update(self._get_class_method_name(test)) + self.logger.test_end( + test.id(), + "FAIL", + message=self._extract_err_message(err), + expected="FAIL", + stack=self._extract_stacktrace(err, test), + extra=extra, + ) + + def addUnexpectedSuccess(self, test): + extra = self.call_callbacks(test, "PASS") + extra.update(self._get_class_method_name(test)) + self.logger.test_end(test.id(), "PASS", expected="FAIL", extra=extra) + + def addSkip(self, test, reason): + extra = self.call_callbacks(test, "SKIP") + extra.update(self._get_class_method_name(test)) + self.logger.test_end( + test.id(), "SKIP", message=reason, expected="PASS", extra=extra + ) + + +class StructuredTestRunner(unittest.TextTestRunner): + resultclass = StructuredTestResult + + def __init__(self, **kwargs): + """TestRunner subclass designed for structured logging. + + :params logger: A ``StructuredLogger`` to use for logging the test run. + :params test_list: An optional list of tests that will be passed along + the `suite_start` message. + + """ + + self.logger = kwargs.pop("logger") + self.test_list = kwargs.pop("test_list", []) + self.result_callbacks = kwargs.pop("result_callbacks", []) + unittest.TextTestRunner.__init__(self, **kwargs) + + def _makeResult(self): + return self.resultclass( + self.stream, + self.descriptions, + self.verbosity, + logger=self.logger, + test_list=self.test_list, + ) + + def run(self, test): + """Run the given test case or test suite.""" + result = self._makeResult() + result.failfast = self.failfast + result.buffer = self.buffer + startTime = time.time() + startTestRun = getattr(result, "startTestRun", None) + if startTestRun is not None: + startTestRun() + try: + test(result) + finally: + stopTestRun = getattr(result, "stopTestRun", None) + if stopTestRun is not None: + stopTestRun() + stopTime = time.time() + if hasattr(result, "time_taken"): + result.time_taken = stopTime - startTime + + return result diff --git a/testing/mozbase/moztest/moztest/resolve.py b/testing/mozbase/moztest/moztest/resolve.py new file mode 100644 index 0000000000..42bf0ebdda --- /dev/null +++ b/testing/mozbase/moztest/moztest/resolve.py @@ -0,0 +1,1042 @@ +# 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/. + +import fnmatch +import os +import pickle +import sys +from abc import ABCMeta, abstractmethod +from collections import defaultdict + +import mozpack.path as mozpath +import six +from manifestparser import TestManifest, combine_fields +from mozbuild.base import MozbuildObject +from mozbuild.testing import REFTEST_FLAVORS, TEST_MANIFESTS +from mozbuild.util import OrderedDefaultDict +from mozpack.files import FileFinder + +here = os.path.abspath(os.path.dirname(__file__)) + +MOCHITEST_CHUNK_BY_DIR = 4 +MOCHITEST_TOTAL_CHUNKS = 5 + + +def WebglSuite(name): + return { + "aliases": (name,), + "build_flavor": "mochitest", + "mach_command": "mochitest", + "kwargs": {"flavor": "plain", "subsuite": name, "test_paths": None}, + "task_regex": [ + "mochitest-" + name + "($|.*(-1|[^0-9])$)", + "test-verify($|.*(-1|[^0-9])$)", + ], + } + + +TEST_SUITES = { + "cppunittest": { + "aliases": ("cpp",), + "mach_command": "cppunittest", + "kwargs": {"test_files": None}, + }, + "crashtest": { + "aliases": ("c", "rc"), + "build_flavor": "crashtest", + "mach_command": "crashtest", + "kwargs": {"test_file": None}, + "task_regex": ["crashtest($|.*(-1|[^0-9])$)", "test-verify($|.*(-1|[^0-9])$)"], + }, + "crashtest-qr": { + "aliases": ("c", "rc"), + "build_flavor": "crashtest", + "mach_command": "crashtest", + "kwargs": {"test_file": None}, + "task_regex": [ + "crashtest-qr($|.*(-1|[^0-9])$)", + "test-verify($|.*(-1|[^0-9])$)", + ], + }, + "firefox-ui-functional": { + "aliases": ("fxfn",), + "mach_command": "firefox-ui-functional", + "kwargs": {}, + }, + "firefox-ui-update": { + "aliases": ("fxup",), + "mach_command": "firefox-ui-update", + "kwargs": {}, + }, + "marionette": { + "aliases": ("mn",), + "mach_command": "marionette-test", + "kwargs": {"tests": None}, + "task_regex": ["marionette($|.*(-1|[^0-9])$)"], + }, + "mochitest-a11y": { + "aliases": ("a11y", "ally"), + "build_flavor": "a11y", + "mach_command": "mochitest", + "kwargs": { + "flavor": "a11y", + "test_paths": None, + "e10s": False, + "enable_fission": False, + }, + "task_regex": [ + "mochitest-a11y($|.*(-1|[^0-9])$)", + "test-verify($|.*(-1|[^0-9])$)", + ], + }, + "mochitest-browser-chrome": { + "aliases": ("bc", "browser"), + "build_flavor": "browser-chrome", + "mach_command": "mochitest", + "kwargs": {"flavor": "browser-chrome", "test_paths": None}, + "task_regex": [ + "mochitest-browser-chrome($|.*(-1|[^0-9])$)", + "test-verify($|.*(-1|[^0-9])$)", + ], + }, + "mochitest-browser-screenshots": { + "aliases": ("ss", "screenshots-chrome"), + "build_flavor": "browser-chrome", + "mach_command": "mochitest", + "kwargs": { + "flavor": "browser-chrome", + "subsuite": "screenshots", + "test_paths": None, + }, + "task_regex": ["mochitest-browser-screenshots($|.*(-1|[^0-9])$)"], + }, + "mochitest-chrome": { + "aliases": ("mc",), + "build_flavor": "chrome", + "mach_command": "mochitest", + "kwargs": { + "flavor": "chrome", + "test_paths": None, + "e10s": False, + "enable_fission": False, + }, + "task_regex": [ + "mochitest-chrome($|.*(-1|[^0-9])$)", + "test-verify($|.*(-1|[^0-9])$)", + ], + }, + "mochitest-chrome-gpu": { + "aliases": ("gpu",), + "build_flavor": "chrome", + "mach_command": "mochitest", + "kwargs": { + "flavor": "chrome", + "subsuite": "gpu", + "test_paths": None, + "e10s": False, + "enable_fission": False, + }, + "task_regex": [ + "mochitest-gpu($|.*(-1|[^0-9])$)", + "test-verify($|.*(-1|[^0-9])$)", + ], + }, + "mochitest-devtools-chrome": { + "aliases": ("dt", "devtools"), + "build_flavor": "browser-chrome", + "mach_command": "mochitest", + "kwargs": { + "flavor": "browser-chrome", + "subsuite": "devtools", + "test_paths": None, + }, + "task_regex": [ + "mochitest-devtools-chrome($|.*(-1|[^0-9])$)", + "test-verify($|.*(-1|[^0-9])$)", + ], + }, + "mochitest-browser-a11y": { + "aliases": ("ba", "browser-a11y"), + "build_flavor": "browser-chrome", + "mach_command": "mochitest", + "kwargs": { + "flavor": "browser-chrome", + "subsuite": "a11y", + "test_paths": None, + }, + "task_regex": [ + "mochitest-browser-a11y($|.*(-1|[^0-9])$)", + "test-verify($|.*(-1|[^0-9])$)", + ], + }, + "mochitest-media": { + "aliases": ("mpm", "plain-media"), + "build_flavor": "mochitest", + "mach_command": "mochitest", + "kwargs": {"flavor": "plain", "subsuite": "media", "test_paths": None}, + "task_regex": [ + "mochitest-media($|.*(-1|[^0-9])$)", + "test-verify($|.*(-1|[^0-9])$)", + ], + }, + "mochitest-browser-media": { + "aliases": ("bmda", "browser-mda"), + "build_flavor": "browser-chrome", + "mach_command": "mochitest", + "kwargs": { + "flavor": "browser-chrome", + "subsuite": "media-bc", + "test_paths": None, + }, + "task_regex": [ + "mochitest-browser-media($|.*(-1|[^0-9])$)", + "test-verify($|.*(-1|[^0-9])$)", + ], + }, + "mochitest-plain": { + "aliases": ( + "mp", + "plain", + ), + "build_flavor": "mochitest", + "mach_command": "mochitest", + "kwargs": {"flavor": "plain", "test_paths": None}, + "task_regex": [ + "mochitest-plain($|.*(-1|[^0-9])$)", # noqa + "test-verify($|.*(-1|[^0-9])$)", + ], + }, + "mochitest-plain-gpu": { + "aliases": ("gpu",), + "build_flavor": "mochitest", + "mach_command": "mochitest", + "kwargs": {"flavor": "plain", "subsuite": "gpu", "test_paths": None}, + "task_regex": [ + "mochitest-gpu($|.*(-1|[^0-9])$)", + "test-verify($|.*(-1|[^0-9])$)", + ], + }, + "mochitest-remote": { + "aliases": ("remote",), + "build_flavor": "browser-chrome", + "mach_command": "mochitest", + "kwargs": { + "flavor": "browser-chrome", + "subsuite": "remote", + "test_paths": None, + }, + "task_regex": [ + "mochitest-remote($|.*(-1|[^0-9])$)", + "test-verify($|.*(-1|[^0-9])$)", + ], + }, + "mochitest-webgl1-core": WebglSuite("webgl1-core"), + "mochitest-webgl1-ext": WebglSuite("webgl1-ext"), + "mochitest-webgl2-core": WebglSuite("webgl2-core"), + "mochitest-webgl2-ext": WebglSuite("webgl2-ext"), + "mochitest-webgl2-deqp": WebglSuite("webgl2-deqp"), + "mochitest-webgpu": WebglSuite("webgpu"), + "puppeteer": { + "aliases": ("remote/test/puppeteer",), + "mach_command": "puppeteer-test", + "kwargs": {"headless": False}, + }, + "python": { + "build_flavor": "python", + "mach_command": "python-test", + "kwargs": {"tests": None}, + }, + "telemetry-tests-client": { + "aliases": ("ttc",), + "build_flavor": "telemetry-tests-client", + "mach_command": "telemetry-tests-client", + "kwargs": {}, + "task_regex": ["telemetry-tests-client($|.*(-1|[^0-9])$)"], + }, + "reftest": { + "aliases": ("rr",), + "build_flavor": "reftest", + "mach_command": "reftest", + "kwargs": {"tests": None}, + "task_regex": [ + "(opt|debug)(-geckoview)?-reftest($|.*(-1|[^0-9])$)", + "test-verify-gpu($|.*(-1|[^0-9])$)", + ], + }, + "reftest-qr": { + "aliases": ("rr",), + "build_flavor": "reftest", + "mach_command": "reftest", + "kwargs": {"tests": None}, + "task_regex": [ + "(opt|debug)(-geckoview)?-reftest-qr($|.*(-1|[^0-9])$)", + "test-verify-gpu($|.*(-1|[^0-9])$)", + ], + }, + "robocop": { + "mach_command": "robocop", + "kwargs": {"test_paths": None}, + "task_regex": ["robocop($|.*(-1|[^0-9])$)"], + }, + "web-platform-tests": { + "aliases": ("wpt",), + "mach_command": "web-platform-tests", + "build_flavor": "web-platform-tests", + "kwargs": {"subsuite": "testharness"}, + "task_regex": [ + "web-platform-tests(?!-crashtest|-reftest|-wdspec|-print)" + "($|.*(-1|[^0-9])$)", + "test-verify-wpt", + ], + }, + "web-platform-tests-crashtest": { + "aliases": ("wpt",), + "mach_command": "web-platform-tests", + "build_flavor": "web-platform-tests", + "kwargs": {"subsuite": "crashtest"}, + "task_regex": [ + "web-platform-tests-crashtest($|.*(-1|[^0-9])$)", + "test-verify-wpt", + ], + }, + "web-platform-tests-print-reftest": { + "aliases": ("wpt",), + "mach_command": "web-platform-tests", + "kwargs": {"subsuite": "print-reftest"}, + "task_regex": [ + "web-platform-tests-print-reftest($|.*(-1|[^0-9])$)", + "test-verify-wpt", + ], + }, + "web-platform-tests-reftest": { + "aliases": ("wpt",), + "mach_command": "web-platform-tests", + "build_flavor": "web-platform-tests", + "kwargs": {"subsuite": "reftest"}, + "task_regex": [ + "web-platform-tests-reftest($|.*(-1|[^0-9])$)", + "test-verify-wpt", + ], + }, + "web-platform-tests-wdspec": { + "aliases": ("wpt",), + "mach_command": "web-platform-tests", + "build_flavor": "web-platform-tests", + "kwargs": {"subsuite": "wdspec"}, + "task_regex": [ + "web-platform-tests-wdspec($|.*(-1|[^0-9])$)", + "test-verify-wpt", + ], + }, + "valgrind": { + "aliases": ("v",), + "mach_command": "valgrind-test", + "kwargs": {}, + }, + "xpcshell": { + "aliases": ("x",), + "build_flavor": "xpcshell", + "mach_command": "xpcshell-test", + "kwargs": {"test_file": "all"}, + "task_regex": ["xpcshell($|.*(-1|[^0-9])$)", "test-verify($|.*(-1|[^0-9])$)"], + }, + "xpcshell-msix": { + "aliases": ("x",), + "build_flavor": "xpcshell", + "mach_command": "xpcshell-test", + "kwargs": {"test_file": "all"}, + "task_regex": ["xpcshell($|.*(-1|[^0-9])$)", "test-verify($|.*(-1|[^0-9])$)"], + }, +} +"""Definitions of all test suites and the metadata needed to run and process +them. Each test suite definition can contain the following keys. + +Arguments: + aliases (tuple): A tuple containing shorthands used to refer to this suite. + build_flavor (str): The flavor assigned to this suite by the build system + in `mozbuild.testing.TEST_MANIFESTS` (or similar). + mach_command (str): Name of the mach command used to run this suite. + kwargs (dict): Arguments needed to pass into the mach command. + task_regex (list): A list of regexes used to filter task labels that run + this suite. +""" + +for i in range(1, MOCHITEST_TOTAL_CHUNKS + 1): + TEST_SUITES["mochitest-%d" % i] = { + "aliases": ("m%d" % i,), + "mach_command": "mochitest", + "kwargs": { + "flavor": "mochitest", + "subsuite": "default", + "chunk_by_dir": MOCHITEST_CHUNK_BY_DIR, + "total_chunks": MOCHITEST_TOTAL_CHUNKS, + "this_chunk": i, + "test_paths": None, + }, + } + + +WPT_TYPES = set() +for suite, data in TEST_SUITES.items(): + if suite.startswith("web-platform-tests"): + WPT_TYPES.add(data["kwargs"]["subsuite"]) + + +_test_flavors = { + "a11y": "mochitest-a11y", + "browser-chrome": "mochitest-browser-chrome", + "chrome": "mochitest-chrome", + "crashtest": "crashtest", + "firefox-ui-functional": "firefox-ui-functional", + "firefox-ui-update": "firefox-ui-update", + "marionette": "marionette", + "mochitest": "mochitest-plain", + "puppeteer": "puppeteer", + "python": "python", + "reftest": "reftest", + "telemetry-tests-client": "telemetry-tests-client", + "web-platform-tests": "web-platform-tests", + "xpcshell": "xpcshell", +} + +_test_subsuites = { + ("browser-chrome", "a11y"): "mochitest-browser-a11y", + ("browser-chrome", "devtools"): "mochitest-devtools-chrome", + ("browser-chrome", "media"): "mochitest-browser-media", + ("browser-chrome", "remote"): "mochitest-remote", + ("browser-chrome", "screenshots"): "mochitest-browser-screenshots", + ("chrome", "gpu"): "mochitest-chrome-gpu", + ("mochitest", "gpu"): "mochitest-plain-gpu", + ("mochitest", "media"): "mochitest-media", + ("mochitest", "robocop"): "robocop", + ("mochitest", "webgl1-core"): "mochitest-webgl1-core", + ("mochitest", "webgl1-ext"): "mochitest-webgl1-ext", + ("mochitest", "webgl2-core"): "mochitest-webgl2-core", + ("mochitest", "webgl2-ext"): "mochitest-webgl2-ext", + ("mochitest", "webgl2-deqp"): "mochitest-webgl2-deqp", + ("mochitest", "webgpu"): "mochitest-webgpu", + ("web-platform-tests", "testharness"): "web-platform-tests", + ("web-platform-tests", "crashtest"): "web-platform-tests-crashtest", + ("web-platform-tests", "print-reftest"): "web-platform-tests-print-reftest", + ("web-platform-tests", "reftest"): "web-platform-tests-reftest", + ("web-platform-tests", "wdspec"): "web-platform-tests-wdspec", +} + + +def get_suite_definition(flavor, subsuite=None, strict=False): + """Return a suite definition given a flavor and optional subsuite. + + If strict is True, a subsuite must have its own entry in TEST_SUITES. + Otherwise, the entry for 'flavor' will be returned with the 'subsuite' + keyword arg set. + + With or without strict mode, an empty dict will be returned if no + matching suite definition was found. + """ + if not subsuite: + suite_name = _test_flavors.get(flavor) + return suite_name, TEST_SUITES.get(suite_name, {}).copy() + + suite_name = _test_subsuites.get((flavor, subsuite)) + if suite_name or strict: + return suite_name, TEST_SUITES.get(suite_name, {}).copy() + + suite_name = _test_flavors.get(flavor) + if suite_name not in TEST_SUITES: + return suite_name, {} + + suite = TEST_SUITES[suite_name].copy() + suite.setdefault("kwargs", {}) + suite["kwargs"]["subsuite"] = subsuite + return suite_name, suite + + +def rewrite_test_base(test, new_base): + """Rewrite paths in a test to be under a new base path. + + This is useful for running tests from a separate location from where they + were defined. + """ + test["here"] = mozpath.join(new_base, test["dir_relpath"]) + test["path"] = mozpath.join(new_base, test["file_relpath"]) + return test + + +@six.add_metaclass(ABCMeta) +class TestLoader(MozbuildObject): + @abstractmethod + def __call__(self): + """Generate test metadata.""" + + +class BuildBackendLoader(TestLoader): + def __call__(self): + """Loads the test metadata generated by the TestManifest build backend. + + The data is stored in two files: + + - <objdir>/all-tests.pkl + - <objdir>/test-defaults.pkl + + The 'all-tests.pkl' file is a mapping of source path to test objects. The + 'test-defaults.pkl' file maps manifests to their DEFAULT configuration. + These manifest defaults will be merged into the test configuration of the + contained tests. + """ + # If installing tests is going to result in re-generating the build + # backend, we need to do this here, so that the updated contents of + # all-tests.pkl make it to the set of tests to run. + if self.backend_out_of_date( + mozpath.join(self.topobjdir, "backend.TestManifestBackend") + ): + print("Test configuration changed. Regenerating backend.") + from mozbuild.gen_test_backend import gen_test_backend + + gen_test_backend() + + all_tests = os.path.join(self.topobjdir, "all-tests.pkl") + test_defaults = os.path.join(self.topobjdir, "test-defaults.pkl") + + with open(all_tests, "rb") as fh: + test_data = pickle.load(fh) + + with open(test_defaults, "rb") as fh: + defaults = pickle.load(fh) + + # The keys in defaults use platform-specific path separators. + # self.topsrcdir was normalized to use /, revert back to \ if needed. + topsrcdir = os.path.normpath(self.topsrcdir) + + for path, tests in six.iteritems(test_data): + for metadata in tests: + defaults_manifests = [metadata["manifest"]] + + ancestor_manifest = metadata.get("ancestor_manifest") + if ancestor_manifest: + # The (ancestor manifest, included manifest) tuple + # contains the defaults of the included manifest, so + # use it instead of [metadata['manifest']]. + ancestor_manifest = os.path.join(topsrcdir, ancestor_manifest) + defaults_manifests[0] = (ancestor_manifest, metadata["manifest"]) + defaults_manifests.append(ancestor_manifest) + + for manifest in defaults_manifests: + manifest_defaults = defaults.get(manifest) + if manifest_defaults: + metadata = combine_fields(manifest_defaults, metadata) + + yield metadata + + +class TestManifestLoader(TestLoader): + def __init__(self, *args, **kwargs): + super(TestManifestLoader, self).__init__(*args, **kwargs) + self.finder = FileFinder(self.topsrcdir) + self.reader = self.mozbuild_reader(config_mode="empty") + self.variables = { + "{}_MANIFESTS".format(k): v[0] for k, v in six.iteritems(TEST_MANIFESTS) + } + self.variables.update( + {"{}_MANIFESTS".format(f.upper()): f for f in REFTEST_FLAVORS} + ) + + def _load_manifestparser_manifest(self, mpath): + mp = TestManifest( + manifests=[mpath], + strict=True, + rootdir=self.topsrcdir, + finder=self.finder, + handle_defaults=True, + ) + return (test for test in mp.tests) + + def _load_reftest_manifest(self, mpath): + import reftest + + manifest = reftest.ReftestManifest(finder=self.finder) + manifest.load(mpath) + + for test in sorted(manifest.tests, key=lambda x: x.get("path")): + test["manifest_relpath"] = test["manifest"][len(self.topsrcdir) + 1 :] + yield test + + def __call__(self): + for path, name, key, value in self.reader.find_variables_from_ast( + self.variables + ): + mpath = os.path.join(self.topsrcdir, os.path.dirname(path), value) + flavor = self.variables[name] + + if name.rsplit("_", 1)[0].lower() in REFTEST_FLAVORS: + tests = self._load_reftest_manifest(mpath) + else: + tests = self._load_manifestparser_manifest(mpath) + + for test in tests: + path = mozpath.normpath(test["path"]) + assert mozpath.basedir(path, [self.topsrcdir]) + relpath = path[len(self.topsrcdir) + 1 :] + + # Add these keys for compatibility with the build backend loader. + test["flavor"] = flavor + test["file_relpath"] = relpath + test["srcdir_relpath"] = relpath + test["dir_relpath"] = mozpath.dirname(relpath) + + yield test + + +class TestResolver(MozbuildObject): + """Helper to resolve tests from the current environment to test files.""" + + test_rewrites = { + "a11y": "_tests/testing/mochitest/a11y", + "browser-chrome": "_tests/testing/mochitest/browser", + "chrome": "_tests/testing/mochitest/chrome", + "mochitest": "_tests/testing/mochitest/tests", + "xpcshell": "_tests/xpcshell", + } + + def __init__(self, *args, **kwargs): + loader_cls = kwargs.pop("loader_cls", BuildBackendLoader) + super(TestResolver, self).__init__(*args, **kwargs) + + self.load_tests = self._spawn(loader_cls) + self._tests = [] + self._reset_state() + + # These suites aren't registered in moz.build so require special handling. + self._puppeteer_loaded = False + self._tests_loaded = False + self._wpt_loaded = False + + def _reset_state(self): + self._tests_by_path = OrderedDefaultDict(list) + self._tests_by_flavor = defaultdict(set) + self._tests_by_manifest = defaultdict(list) + self._test_dirs = set() + + @property + def tests(self): + if not self._tests_loaded: + self._reset_state() + for test in self.load_tests(): + self._tests.append(test) + self._tests_loaded = True + return self._tests + + @property + def tests_by_path(self): + if not self._tests_by_path: + for test in self.tests: + self._tests_by_path[test["file_relpath"]].append(test) + return self._tests_by_path + + @property + def tests_by_flavor(self): + if not self._tests_by_flavor: + for test in self.tests: + self._tests_by_flavor[test["flavor"]].add(test["file_relpath"]) + return self._tests_by_flavor + + @property + def tests_by_manifest(self): + if not self._tests_by_manifest: + for test in self.tests: + if test["flavor"] == "web-platform-tests": + # Use test ids instead of paths for WPT. + self._tests_by_manifest[test["manifest"]].append(test["name"]) + else: + relpath = mozpath.relpath( + test["path"], mozpath.dirname(test["manifest"]) + ) + self._tests_by_manifest[test["manifest_relpath"]].append(relpath) + return self._tests_by_manifest + + @property + def test_dirs(self): + if not self._test_dirs: + for test in self.tests: + self._test_dirs.add(test["dir_relpath"]) + return self._test_dirs + + def _resolve( + self, paths=None, flavor="", subsuite=None, under_path=None, tags=None + ): + """Given parameters, resolve them to produce an appropriate list of tests. + + Args: + paths (list): + By default, set to None. If provided as a list of paths, then + this method will attempt to load the appropriate set of tests + that live in this path. + + flavor (string): + By default, an empty string. If provided as a string, then this + method will attempt to load tests that belong to this flavor. + Additional filtering also takes the flavor into consideration. + + subsuite (string): + By default, set to None. If provided as a string, then this value + is used to perform filtering of a candidate set of tests. + """ + if tags: + tags = set(tags) + + def fltr(tests): + """Filters tests based on several criteria. + + Args: + tests (list): + List of tests that belong to the same candidate path. + + Returns: + test (dict): + If the test survived the filtering process, it is returned + as a valid test. + """ + for test in tests: + if flavor: + if flavor == "devtools" and test.get("flavor") != "browser-chrome": + continue + if flavor != "devtools" and test.get("flavor") != flavor: + continue + + if subsuite and test.get("subsuite", "undefined") != subsuite: + continue + + if tags and not (tags & set(test.get("tags", "").split())): + continue + + if under_path and not test["file_relpath"].startswith(under_path): + continue + + # Make a copy so modifications don't change the source. + yield dict(test) + + paths = paths or [] + paths = [mozpath.normpath(p) for p in paths] + if not paths: + paths = [None] + + if flavor in ("", "puppeteer", None) and ( + any(self.is_puppeteer_path(p) for p in paths) or paths == [None] + ): + self.add_puppeteer_manifest_data() + + if flavor in ("", "web-platform-tests", None) and ( + any(self.is_wpt_path(p) for p in paths) or paths == [None] + ): + self.add_wpt_manifest_data() + + candidate_paths = set() + + for path in sorted(paths): + if path is None: + candidate_paths |= set(self.tests_by_path.keys()) + continue + + if "*" in path: + candidate_paths |= { + p for p in self.tests_by_path if mozpath.match(p, path) + } + continue + + # If the path is a directory, or the path is a prefix of a directory + # containing tests, pull in all tests in that directory. + if path in self.test_dirs or any( + p.startswith(path) for p in self.tests_by_path + ): + candidate_paths |= {p for p in self.tests_by_path if p.startswith(path)} + continue + + # If the path is a manifest, add all tests defined in that manifest. + if any(path.endswith(e) for e in (".toml", ".ini", ".list")): + key = "manifest" if os.path.isabs(path) else "manifest_relpath" + candidate_paths |= { + t["file_relpath"] + for t in self.tests + if mozpath.normpath(t[key]) == path + } + continue + + # If it's a test file, add just that file. + candidate_paths |= {p for p in self.tests_by_path if path in p} + + for p in sorted(candidate_paths): + tests = self.tests_by_path[p] + for test in fltr(tests): + yield test + + def is_puppeteer_path(self, path): + if path is None: + return True + return mozpath.match(path, "remote/test/puppeteer/test/**") + + def add_puppeteer_manifest_data(self): + if self._puppeteer_loaded: + return + + self._reset_state() + + test_path = os.path.join(self.topsrcdir, "remote", "test", "puppeteer", "test") + for root, dirs, paths in os.walk(test_path): + for filename in fnmatch.filter(paths, "*.spec.js"): + path = os.path.join(root, filename) + self._tests.append( + { + "path": os.path.abspath(path), + "flavor": "puppeteer", + "here": os.path.dirname(path), + "manifest": None, + "name": path, + "file_relpath": path, + "head": "", + "support-files": "", + "subsuite": "puppeteer", + "dir_relpath": os.path.dirname(path), + "srcdir_relpath": path, + } + ) + + self._puppeteer_loaded = True + + def is_wpt_path(self, path): + """Checks if path forms part of the known web-platform-test paths. + + Args: + path (str or None): + Path to check against the list of known web-platform-test paths. + + Returns: + Boolean value. True if path is part of web-platform-tests path, or + path is None. False otherwise. + """ + if path is None: + return True + if mozpath.match(path, "testing/web-platform/tests/**"): + return True + if mozpath.match(path, "testing/web-platform/mozilla/tests/**"): + return True + return False + + def get_wpt_group(self, test, depth=3): + """Given a test object set the group (aka manifest) that it belongs to. + + If a custom value for `depth` is provided, it will override the default + value of 3 path components. + + Args: + test (dict): Test object for the particular suite and subsuite. + depth (int, optional): Custom number of path elements. + + Returns: + str: The group the given test belongs to. + """ + # This takes into account that for mozilla-specific WPT tests, the path + # contains an extra '/_mozilla' prefix that must be accounted for. + if test["name"].startswith("/_mozilla"): + depth = depth + 1 + + # Webdriver tests are nested in "classic" and "bidi" folders. Increase + # the depth to avoid grouping all classic or bidi tests in one chunk. + if test["name"].startswith(("/webdriver", "/_mozilla/webdriver")): + depth = depth + 1 + + # Webdriver BiDi tests are nested even further as tests are grouped by + # module but also by command / event name. + if test["name"].startswith( + ("/webdriver/tests/bidi", "/_mozilla/webdriver/bidi") + ): + depth = depth + 1 + + if test["name"].startswith("/_mozilla/webgpu"): + depth = 9001 + + group = os.path.dirname(test["name"]) + while group.count("/") > depth: + group = os.path.dirname(group) + return group + + def add_wpt_manifest_data(self): + """Adds manifest data for web-platform-tests into the list of available tests. + + Upon invocation, this method will download from firefox-ci the most recent + version of the web-platform-tests manifests. + + Once manifest is downloaded, this method will add details about each test + into the list of available tests. + """ + if self._wpt_loaded: + return + + self._reset_state() + + wpt_path = os.path.join(self.topsrcdir, "testing", "web-platform") + sys.path = [wpt_path] + sys.path + + import logging + + import manifestupdate + + logger = logging.getLogger("manifestupdate") + logger.disabled = True + + manifests = manifestupdate.run( + self.topsrcdir, + self.topobjdir, + rebuild=False, + download=True, + config_path=None, + rewrite_config=True, + update=True, + logger=logger, + ) + if not manifests: + print("Loading wpt manifest failed") + return + + for manifest, data in six.iteritems(manifests): + tests_root = data[ + "tests_path" + ] # full path on disk until web-platform tests directory + + for test_type, path, tests in manifest: + full_path = mozpath.join(tests_root, path) + src_path = mozpath.relpath(full_path, self.topsrcdir) + if test_type not in WPT_TYPES: + continue + + full_path = mozpath.join(tests_root, path) # absolute path on disk + src_path = mozpath.relpath(full_path, self.topsrcdir) + + for test in tests: + testobj = { + "head": "", + "support-files": "", + "path": full_path, + "flavor": "web-platform-tests", + "subsuite": test_type, + "here": mozpath.dirname(path), + "name": test.id, + "file_relpath": src_path, + "srcdir_relpath": src_path, + "dir_relpath": mozpath.dirname(src_path), + } + group = self.get_wpt_group(testobj) + testobj["manifest"] = group + + test_root = "tests" + if group.startswith("/_mozilla"): + test_root = os.path.join("mozilla", "tests") + group = group[len("/_mozilla") :] + + group = group.lstrip("/") + testobj["manifest_relpath"] = os.path.join( + wpt_path, test_root, group + ) + self._tests.append(testobj) + + self._wpt_loaded = True + + def resolve_tests(self, cwd=None, **kwargs): + """Resolve tests from an identifier. + + This is a generator of dicts describing each test. All arguments are + optional. + + Paths in returned tests are automatically translated to the paths in + the _tests directory under the object directory. + + Args: + cwd (str): + If specified, we will limit our results to tests under this + directory. The directory should be defined as an absolute path + under topsrcdir or topobjdir. + + paths (list): + An iterable of values to use to identify tests to run. If an + entry is a known test file, tests associated with that file are + returned (there may be multiple configurations for a single + file). If an entry is a directory, or a prefix of a directory + containing tests, all tests in that directory are returned. If + the string appears in a known test file, that test file is + considered. If the path contains a wildcard pattern, tests + matching that pattern are returned. + + under_path (str): + If specified, will be used to filter out tests that aren't in + the specified path prefix relative to topsrcdir or the test's + installed dir. + + flavor (str): + If specified, will be used to filter returned tests to only be + the flavor specified. A flavor is something like ``xpcshell``. + + subsuite (str): + If specified will be used to filter returned tests to only be + in the subsuite specified. To filter only tests that *don't* + have any subsuite, pass the string 'undefined'. + + tags (list): + If specified, will be used to filter out tests that don't contain + a matching tag. + """ + if cwd: + norm_cwd = mozpath.normpath(cwd) + norm_srcdir = mozpath.normpath(self.topsrcdir) + norm_objdir = mozpath.normpath(self.topobjdir) + + reldir = None + + if norm_cwd.startswith(norm_objdir): + reldir = norm_cwd[len(norm_objdir) + 1 :] + elif norm_cwd.startswith(norm_srcdir): + reldir = norm_cwd[len(norm_srcdir) + 1 :] + + kwargs["under_path"] = reldir + + rewrite_base = None + for test in self._resolve(**kwargs): + rewrite_base = self.test_rewrites.get(test["flavor"], None) + + if rewrite_base: + rewrite_base = os.path.join( + self.topobjdir, os.path.normpath(rewrite_base) + ) + yield rewrite_test_base(test, rewrite_base) + else: + yield test + + def resolve_metadata(self, what): + """Resolve tests based on the given metadata. If not specified, metadata + from outgoing files will be used instead. + """ + # Parse arguments and assemble a test "plan." + run_suites = set() + run_tests = [] + + for entry in what: + # If the path matches the name or alias of an entire suite, run + # the entire suite. + if entry in TEST_SUITES: + run_suites.add(entry) + continue + suitefound = False + for suite, v in six.iteritems(TEST_SUITES): + if entry.lower() in v.get("aliases", []): + run_suites.add(suite) + suitefound = True + if suitefound: + continue + + # Now look for file/directory matches in the TestResolver. + relpath = self._wrap_path_argument(entry).relpath() + tests = list(self.resolve_tests(paths=[relpath])) + run_tests.extend(tests) + + if not tests: + print("UNKNOWN TEST: %s" % entry, file=sys.stderr) + + return run_suites, run_tests diff --git a/testing/mozbase/moztest/moztest/results.py b/testing/mozbase/moztest/moztest/results.py new file mode 100644 index 0000000000..5193a9db2b --- /dev/null +++ b/testing/mozbase/moztest/moztest/results.py @@ -0,0 +1,366 @@ +# 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/. + +import os +import time + +import mozinfo +import six + + +class TestContext(object): + """Stores context data about the test""" + + attrs = [ + "hostname", + "arch", + "env", + "os", + "os_version", + "tree", + "revision", + "product", + "logfile", + "testgroup", + "harness", + "buildtype", + ] + + def __init__( + self, + hostname="localhost", + tree="", + revision="", + product="", + logfile=None, + arch="", + operating_system="", + testgroup="", + harness="moztest", + buildtype="", + ): + self.hostname = hostname + self.arch = arch or mozinfo.processor + self.env = os.environ.copy() + self.os = operating_system or mozinfo.os + self.os_version = mozinfo.version + self.tree = tree + self.revision = revision + self.product = product + self.logfile = logfile + self.testgroup = testgroup + self.harness = harness + self.buildtype = buildtype + + def __str__(self): + return "%s (%s, %s)" % (self.hostname, self.os, self.arch) + + def __repr__(self): + return "<%s>" % self.__str__() + + def __eq__(self, other): + if not isinstance(other, TestContext): + return False + diffs = [a for a in self.attrs if getattr(self, a) != getattr(other, a)] + return len(diffs) == 0 + + def __hash__(self): + def get(attr): + value = getattr(self, attr) + if isinstance(value, dict): + value = frozenset(six.iteritems(value)) + return value + + return hash(frozenset([get(a) for a in self.attrs])) + + +class TestResult(object): + """Stores test result data""" + + FAIL_RESULTS = [ + "UNEXPECTED-PASS", + "UNEXPECTED-FAIL", + "ERROR", + ] + COMPUTED_RESULTS = FAIL_RESULTS + [ + "PASS", + "KNOWN-FAIL", + "SKIPPED", + ] + POSSIBLE_RESULTS = [ + "PASS", + "FAIL", + "SKIP", + "ERROR", + ] + + def __init__( + self, name, test_class="", time_start=None, context=None, result_expected="PASS" + ): + """Create a TestResult instance. + name = name of the test that is running + test_class = the class that the test belongs to + time_start = timestamp (seconds since UNIX epoch) of when the test started + running; if not provided, defaults to the current time + ! Provide 0 if you only have the duration + context = TestContext instance; can be None + result_expected = string representing the expected outcome of the test""" + + msg = "Result '%s' not in possible results: %s" % ( + result_expected, + ", ".join(self.POSSIBLE_RESULTS), + ) + assert isinstance(name, six.string_types), "name has to be a string" + assert result_expected in self.POSSIBLE_RESULTS, msg + + self.name = name + self.test_class = test_class + self.context = context + self.time_start = time_start if time_start is not None else time.time() + self.time_end = None + self._result_expected = result_expected + self._result_actual = None + self.result = None + self.filename = None + self.description = None + self.output = [] + self.reason = None + + @property + def test_name(self): + return "%s.py %s.%s" % ( + self.test_class.split(".")[0], + self.test_class, + self.name, + ) + + def __str__(self): + return "%s | %s (%s) | %s" % ( + self.result or "PENDING", + self.name, + self.test_class, + self.reason, + ) + + def __repr__(self): + return "<%s>" % self.__str__() + + def calculate_result(self, expected, actual): + if actual == "ERROR": + return "ERROR" + if actual == "SKIP": + return "SKIPPED" + + if expected == "PASS": + if actual == "PASS": + return "PASS" + if actual == "FAIL": + return "UNEXPECTED-FAIL" + + if expected == "FAIL": + if actual == "PASS": + return "UNEXPECTED-PASS" + if actual == "FAIL": + return "KNOWN-FAIL" + + # if actual is skip or error, we return at the beginning, so if we get + # here it is definitely some kind of error + return "ERROR" + + def infer_results(self, computed_result): + assert computed_result in self.COMPUTED_RESULTS + if computed_result == "UNEXPECTED-PASS": + expected = "FAIL" + actual = "PASS" + elif computed_result == "UNEXPECTED-FAIL": + expected = "PASS" + actual = "FAIL" + elif computed_result == "KNOWN-FAIL": + expected = actual = "FAIL" + elif computed_result == "SKIPPED": + expected = actual = "SKIP" + else: + return + self._result_expected = expected + self._result_actual = actual + + def finish(self, result, time_end=None, output=None, reason=None): + """Marks the test as finished, storing its end time and status + ! Provide the duration as time_end if you only have that.""" + + if result in self.POSSIBLE_RESULTS: + self._result_actual = result + self.result = self.calculate_result( + self._result_expected, self._result_actual + ) + elif result in self.COMPUTED_RESULTS: + self.infer_results(result) + self.result = result + else: + valid = self.POSSIBLE_RESULTS + self.COMPUTED_RESULTS + msg = "Result '%s' not valid. Need one of: %s" % (result, ", ".join(valid)) + raise ValueError(msg) + + # use lists instead of multiline strings + if isinstance(output, six.string_types): + output = output.splitlines() + + self.time_end = time_end if time_end is not None else time.time() + self.output = output or self.output + self.reason = reason + + @property + def finished(self): + """Boolean saying if the test is finished or not""" + return self.result is not None + + @property + def duration(self): + """Returns the time it took for the test to finish. If the test is + not finished, returns the elapsed time so far""" + if self.result is not None: + return self.time_end - self.time_start + else: + # returns the elapsed time + return time.time() - self.time_start + + +class TestResultCollection(list): + """Container class that stores test results""" + + resultClass = TestResult + + def __init__(self, suite_name, time_taken=0, resultClass=None): + list.__init__(self) + self.suite_name = suite_name + self.time_taken = time_taken + if resultClass is not None: + self.resultClass = resultClass + + def __str__(self): + return "%s (%.2fs)\n%s" % (self.suite_name, self.time_taken, list.__str__(self)) + + def subset(self, predicate): + tests = self.filter(predicate) + duration = 0 + sub = TestResultCollection(self.suite_name) + for t in tests: + sub.append(t) + duration += t.duration + sub.time_taken = duration + return sub + + @property + def contexts(self): + """List of unique contexts for the test results contained""" + cs = [tr.context for tr in self] + return list(set(cs)) + + def filter(self, predicate): + """Returns a generator of TestResults that satisfy a given predicate""" + return (tr for tr in self if predicate(tr)) + + def tests_with_result(self, result): + """Returns a generator of TestResults with the given result""" + msg = "Result '%s' not in possible results: %s" % ( + result, + ", ".join(self.resultClass.COMPUTED_RESULTS), + ) + assert result in self.resultClass.COMPUTED_RESULTS, msg + return self.filter(lambda t: t.result == result) + + @property + def tests(self): + """Generator of all tests in the collection""" + return (t for t in self) + + def add_result( + self, + test, + result_expected="PASS", + result_actual="PASS", + output="", + context=None, + ): + def get_class(test): + return test.__class__.__module__ + "." + test.__class__.__name__ + + t = self.resultClass( + name=str(test).split()[0], + test_class=get_class(test), + time_start=0, + result_expected=result_expected, + context=context, + ) + t.finish(result_actual, time_end=0, reason=relevant_line(output), output=output) + self.append(t) + + @property + def num_failures(self): + fails = 0 + for t in self: + if t.result in self.resultClass.FAIL_RESULTS: + fails += 1 + return fails + + def add_unittest_result(self, result, context=None): + """Adds the python unittest result provided to the collection""" + if hasattr(result, "time_taken"): + self.time_taken += result.time_taken + + for test, output in result.errors: + self.add_result(test, result_actual="ERROR", output=output) + + for test, output in result.failures: + self.add_result(test, result_actual="FAIL", output=output) + + if hasattr(result, "unexpectedSuccesses"): + for test in result.unexpectedSuccesses: + self.add_result(test, result_expected="FAIL", result_actual="PASS") + + if hasattr(result, "skipped"): + for test, output in result.skipped: + self.add_result( + test, result_expected="SKIP", result_actual="SKIP", output=output + ) + + if hasattr(result, "expectedFailures"): + for test, output in result.expectedFailures: + self.add_result( + test, result_expected="FAIL", result_actual="FAIL", output=output + ) + + # unittest does not store these by default + if hasattr(result, "tests_passed"): + for test in result.tests_passed: + self.add_result(test) + + @classmethod + def from_unittest_results(cls, context, *results): + """Creates a TestResultCollection containing the given python + unittest results""" + + if not results: + return cls("from unittest") + + # all the TestResult instances share the same context + context = context or TestContext() + + collection = cls("from %s" % results[0].__class__.__name__) + + for result in results: + collection.add_unittest_result(result, context) + + return collection + + +# used to get exceptions/errors from tracebacks +def relevant_line(s): + KEYWORDS = ("Error:", "Exception:", "error:", "exception:") + lines = s.splitlines() + for line in lines: + for keyword in KEYWORDS: + if keyword in line: + return line + return "N/A" diff --git a/testing/mozbase/moztest/moztest/selftest/__init__.py b/testing/mozbase/moztest/moztest/selftest/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/mozbase/moztest/moztest/selftest/__init__.py diff --git a/testing/mozbase/moztest/moztest/selftest/fixtures.py b/testing/mozbase/moztest/moztest/selftest/fixtures.py new file mode 100644 index 0000000000..5d21e7aa63 --- /dev/null +++ b/testing/mozbase/moztest/moztest/selftest/fixtures.py @@ -0,0 +1,116 @@ +# 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/. +"""Pytest fixtures to help set up Firefox and a tests archive +in test harness selftests. +""" + +import os +import shutil +import sys + +import mozinstall +import pytest + +here = os.path.abspath(os.path.dirname(__file__)) + +try: + from mozbuild.base import MozbuildObject + + build = MozbuildObject.from_environment(cwd=here) +except ImportError: + build = None + + +HARNESS_ROOT_NOT_FOUND = """ +Could not find test harness root. Either a build or the 'GECKO_INSTALLER_URL' +environment variable is required. +""".lstrip() + + +def _get_test_harness(suite, install_dir, flavor="plain"): + # Check if there is a local build + if build: + harness_root = os.path.join(build.topobjdir, "_tests", install_dir) + if os.path.isdir(harness_root): + return harness_root + + if "TEST_HARNESS_ROOT" in os.environ: + harness_root = os.path.join(os.environ["TEST_HARNESS_ROOT"], suite) + if os.path.isdir(harness_root): + return harness_root + + # Couldn't find a harness root, let caller do error handling. + return None + + +@pytest.fixture(scope="session") +def setup_test_harness(request, flavor="plain"): + """Fixture for setting up a mozharness-based test harness like + mochitest or reftest""" + + def inner(files_dir, *args, **kwargs): + harness_root = _get_test_harness(*args, **kwargs) + test_root = None + if harness_root: + sys.path.insert(0, harness_root) + + # Link the test files to the test package so updates are automatically + # picked up. Fallback to copy on Windows. + if files_dir: + test_root = os.path.join(harness_root, "tests", "selftests") + if kwargs.get("flavor") == "browser-chrome": + test_root = os.path.join( + harness_root, "browser", "tests", "selftests" + ) + if not os.path.exists(test_root): + if os.path.lexists(test_root): + os.remove(test_root) + + if hasattr(os, "symlink"): + if not os.path.isdir(os.path.dirname(test_root)): + os.makedirs(os.path.dirname(test_root)) + try: + os.symlink(files_dir, test_root) + except FileExistsError: + # another pytest job set up the symlink - no problem + pass + else: + shutil.copytree(files_dir, test_root) + elif "TEST_HARNESS_ROOT" in os.environ: + # The mochitest tests will run regardless of whether a build exists or not. + # In a local environment, they should simply be skipped if setup fails. But + # in automation, we'll need to make sure an error is propagated up. + pytest.fail(HARNESS_ROOT_NOT_FOUND) + else: + # Tests will be marked skipped by the calls to pytest.importorskip() below. + # We are purposefully not failing here because running |mach python-test| + # without a build is a perfectly valid use case. + pass + return test_root + + return inner + + +def binary(): + """Return a Firefox binary""" + try: + return build.get_binary_path() + except Exception: + pass + + app = "firefox" + bindir = os.path.join(os.environ["PYTHON_TEST_TMP"], app) + if os.path.isdir(bindir): + try: + return mozinstall.get_binary(bindir, app_name=app) + except Exception: + pass + + if "GECKO_BINARY_PATH" in os.environ: + return os.environ["GECKO_BINARY_PATH"] + + +@pytest.fixture(name="binary", scope="session") +def binary_fixture(): + return binary() diff --git a/testing/mozbase/moztest/moztest/selftest/output.py b/testing/mozbase/moztest/moztest/selftest/output.py new file mode 100644 index 0000000000..cdc6600f41 --- /dev/null +++ b/testing/mozbase/moztest/moztest/selftest/output.py @@ -0,0 +1,52 @@ +# 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/. + +"""Methods for testing interactions with mozharness.""" + +import json +import os +import sys + +from mozbuild.base import MozbuildObject +from six import string_types + +here = os.path.abspath(os.path.dirname(__file__)) +build = MozbuildObject.from_environment(cwd=here) + +sys.path.insert(0, os.path.join(build.topsrcdir, "testing", "mozharness")) +from mozharness.base.errors import BaseErrorList +from mozharness.base.log import INFO +from mozharness.mozilla.structuredlog import StructuredOutputParser +from mozharness.mozilla.testing.errors import HarnessErrorList + + +def get_mozharness_status(suite, lines, status, formatter=None, buf=None): + """Given list of log lines, determine what the mozharness status would be.""" + parser = StructuredOutputParser( + config={"log_level": INFO}, + error_list=BaseErrorList + HarnessErrorList, + strict=False, + suite_category=suite, + ) + + if formatter: + parser.formatter = formatter + + # Processing the log with mozharness will re-print all the output to stdout + # Since this exact same output has already been printed by the actual test + # run, temporarily redirect stdout to devnull. + buf = buf or open(os.devnull, "w") + orig = sys.stdout + sys.stdout = buf + for line in lines: + parser.parse_single_line(json.dumps(line)) + sys.stdout = orig + return parser.evaluate_parser(status) + + +def filter_action(actions, lines): + if isinstance(actions, string_types): + actions = (actions,) + # pylint --py3k: W1639 + return list(filter(lambda x: x["action"] in actions, lines)) diff --git a/testing/mozbase/moztest/setup.py b/testing/mozbase/moztest/setup.py new file mode 100644 index 0000000000..f6749128d7 --- /dev/null +++ b/testing/mozbase/moztest/setup.py @@ -0,0 +1,33 @@ +# 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 setuptools import find_packages, setup + +PACKAGE_VERSION = "1.1.0" + +# dependencies +deps = ["mozinfo"] + +setup( + name="moztest", + version=PACKAGE_VERSION, + description="Package for storing and outputting Mozilla test results", + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + classifiers=[ + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Development Status :: 5 - Production/Stable", + ], + # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + keywords="mozilla", + author="Mozilla Automation and Tools team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + license="MPL", + packages=find_packages(), + include_package_data=True, + zip_safe=False, + install_requires=deps, +) diff --git a/testing/mozbase/moztest/tests/data/srcdir/apple/a11y.toml b/testing/mozbase/moztest/tests/data/srcdir/apple/a11y.toml new file mode 100644 index 0000000000..6cd19840bc --- /dev/null +++ b/testing/mozbase/moztest/tests/data/srcdir/apple/a11y.toml @@ -0,0 +1,3 @@ +[DEFAULT] + +["test_a11y.html"] diff --git a/testing/mozbase/moztest/tests/data/srcdir/apple/moz.build b/testing/mozbase/moztest/tests/data/srcdir/apple/moz.build new file mode 100644 index 0000000000..f86a251ad9 --- /dev/null +++ b/testing/mozbase/moztest/tests/data/srcdir/apple/moz.build @@ -0,0 +1 @@ +A11Y_MANIFESTS += ["a11y.toml"] diff --git a/testing/mozbase/moztest/tests/data/srcdir/banana/moz.build b/testing/mozbase/moztest/tests/data/srcdir/banana/moz.build new file mode 100644 index 0000000000..b71e5e29a1 --- /dev/null +++ b/testing/mozbase/moztest/tests/data/srcdir/banana/moz.build @@ -0,0 +1 @@ +XPCSHELL_TESTS_MANIFESTS += ["xpcshell.toml"] diff --git a/testing/mozbase/moztest/tests/data/srcdir/banana/xpcshell.toml b/testing/mozbase/moztest/tests/data/srcdir/banana/xpcshell.toml new file mode 100644 index 0000000000..5b576038bd --- /dev/null +++ b/testing/mozbase/moztest/tests/data/srcdir/banana/xpcshell.toml @@ -0,0 +1,5 @@ +[DEFAULT] + +["currant/test_xpcshell_A.js"] + +["currant/test_xpcshell_B.js"] diff --git a/testing/mozbase/moztest/tests/data/srcdir/carrot/moz.build b/testing/mozbase/moztest/tests/data/srcdir/carrot/moz.build new file mode 100644 index 0000000000..a82183c0bc --- /dev/null +++ b/testing/mozbase/moztest/tests/data/srcdir/carrot/moz.build @@ -0,0 +1 @@ +XPCSHELL_TESTS_MANIFESTS += ["xpcshell-one.toml", "xpcshell-two.toml"] diff --git a/testing/mozbase/moztest/tests/data/srcdir/carrot/xpcshell-one.toml b/testing/mozbase/moztest/tests/data/srcdir/carrot/xpcshell-one.toml new file mode 100644 index 0000000000..b7d36c0b04 --- /dev/null +++ b/testing/mozbase/moztest/tests/data/srcdir/carrot/xpcshell-one.toml @@ -0,0 +1,5 @@ +[DEFAULT] +head = "head_one.js" + +["include:xpcshell-shared.toml"] +stick = "one" diff --git a/testing/mozbase/moztest/tests/data/srcdir/carrot/xpcshell-shared.toml b/testing/mozbase/moztest/tests/data/srcdir/carrot/xpcshell-shared.toml new file mode 100644 index 0000000000..46d6636760 --- /dev/null +++ b/testing/mozbase/moztest/tests/data/srcdir/carrot/xpcshell-shared.toml @@ -0,0 +1,3 @@ +[DEFAULT] + +["test_included.js"] diff --git a/testing/mozbase/moztest/tests/data/srcdir/carrot/xpcshell-two.toml b/testing/mozbase/moztest/tests/data/srcdir/carrot/xpcshell-two.toml new file mode 100644 index 0000000000..bf89c8546e --- /dev/null +++ b/testing/mozbase/moztest/tests/data/srcdir/carrot/xpcshell-two.toml @@ -0,0 +1,5 @@ +[DEFAULT] +head = "head_two.js" + +["include:xpcshell-shared.toml"] +stick = "two" diff --git a/testing/mozbase/moztest/tests/data/srcdir/dragonfruit/elderberry/xpcshell_updater.toml b/testing/mozbase/moztest/tests/data/srcdir/dragonfruit/elderberry/xpcshell_updater.toml new file mode 100644 index 0000000000..d03b58326c --- /dev/null +++ b/testing/mozbase/moztest/tests/data/srcdir/dragonfruit/elderberry/xpcshell_updater.toml @@ -0,0 +1,8 @@ +[DEFAULT] +support-files = [ + "data/**", + "xpcshell_updater.toml", +] + +["test_xpcshell_C.js"] +head = "head_updates.js head2.js" diff --git a/testing/mozbase/moztest/tests/data/srcdir/dragonfruit/moz.build b/testing/mozbase/moztest/tests/data/srcdir/dragonfruit/moz.build new file mode 100644 index 0000000000..b71e5e29a1 --- /dev/null +++ b/testing/mozbase/moztest/tests/data/srcdir/dragonfruit/moz.build @@ -0,0 +1 @@ +XPCSHELL_TESTS_MANIFESTS += ["xpcshell.toml"] diff --git a/testing/mozbase/moztest/tests/data/srcdir/dragonfruit/xpcshell.toml b/testing/mozbase/moztest/tests/data/srcdir/dragonfruit/xpcshell.toml new file mode 100644 index 0000000000..c5a2d40838 --- /dev/null +++ b/testing/mozbase/moztest/tests/data/srcdir/dragonfruit/xpcshell.toml @@ -0,0 +1,6 @@ +[DEFAULT] + +["elderberry/test_xpcshell_C.js"] +head = "head_update.js" + +["include:elderberry/xpcshell_updater.toml"] diff --git a/testing/mozbase/moztest/tests/data/srcdir/fig/grape/instrumentation.toml b/testing/mozbase/moztest/tests/data/srcdir/fig/grape/instrumentation.toml new file mode 100644 index 0000000000..3794aad26a --- /dev/null +++ b/testing/mozbase/moztest/tests/data/srcdir/fig/grape/instrumentation.toml @@ -0,0 +1,4 @@ +[DEFAULT] + +["src/TestInstrumentationA.java"] +subsuite = "background" diff --git a/testing/mozbase/moztest/tests/data/srcdir/fig/huckleberry/instrumentation.toml b/testing/mozbase/moztest/tests/data/srcdir/fig/huckleberry/instrumentation.toml new file mode 100644 index 0000000000..b61d8a247a --- /dev/null +++ b/testing/mozbase/moztest/tests/data/srcdir/fig/huckleberry/instrumentation.toml @@ -0,0 +1,4 @@ +[DEFAULT] + +["src/TestInstrumentationB.java"] +subsuite = "browser" diff --git a/testing/mozbase/moztest/tests/data/srcdir/fig/moz.build b/testing/mozbase/moztest/tests/data/srcdir/fig/moz.build new file mode 100644 index 0000000000..8f46de2e35 --- /dev/null +++ b/testing/mozbase/moztest/tests/data/srcdir/fig/moz.build @@ -0,0 +1,4 @@ +ANDROID_INSTRUMENTATION_MANIFESTS += [ + "grape/instrumentation.toml", + "huckleberry/instrumentation.toml", +] diff --git a/testing/mozbase/moztest/tests/data/srcdir/juniper/browser.toml b/testing/mozbase/moztest/tests/data/srcdir/juniper/browser.toml new file mode 100644 index 0000000000..8cde9a8891 --- /dev/null +++ b/testing/mozbase/moztest/tests/data/srcdir/juniper/browser.toml @@ -0,0 +1,3 @@ +[DEFAULT] + +["browser_chrome.js"] diff --git a/testing/mozbase/moztest/tests/data/srcdir/kiwi/browser.toml b/testing/mozbase/moztest/tests/data/srcdir/kiwi/browser.toml new file mode 100644 index 0000000000..fde05f5597 --- /dev/null +++ b/testing/mozbase/moztest/tests/data/srcdir/kiwi/browser.toml @@ -0,0 +1,5 @@ +[DEFAULT] + +["browser_devtools.js"] +subsuite = "devtools" +tags = "devtools" diff --git a/testing/mozbase/moztest/tests/data/srcdir/moz.build b/testing/mozbase/moztest/tests/data/srcdir/moz.build new file mode 100644 index 0000000000..9de14eb3fc --- /dev/null +++ b/testing/mozbase/moztest/tests/data/srcdir/moz.build @@ -0,0 +1,4 @@ +BROWSER_CHROME_MANIFESTS += [ + "juniper/browser.toml", + "kiwi/browser.toml", +] diff --git a/testing/mozbase/moztest/tests/data/srcdir/wpt_manifest_data.json b/testing/mozbase/moztest/tests/data/srcdir/wpt_manifest_data.json new file mode 100644 index 0000000000..9067b0fad7 --- /dev/null +++ b/testing/mozbase/moztest/tests/data/srcdir/wpt_manifest_data.json @@ -0,0 +1,8 @@ +{ + "loganberry/web-platform/tests": { + "testharness": ["html/test_wpt.html"] + }, + "loganberry/web-platform/mozilla/tests": { + "testharness": ["html/test_wpt.html"] + } +} diff --git a/testing/mozbase/moztest/tests/manifest.toml b/testing/mozbase/moztest/tests/manifest.toml new file mode 100644 index 0000000000..99ceab929f --- /dev/null +++ b/testing/mozbase/moztest/tests/manifest.toml @@ -0,0 +1,6 @@ +[DEFAULT] +subsuite = "mozbase" + +["test.py"] + +["test_resolve.py"] diff --git a/testing/mozbase/moztest/tests/test.py b/testing/mozbase/moztest/tests/test.py new file mode 100644 index 0000000000..b2c2e7ff18 --- /dev/null +++ b/testing/mozbase/moztest/tests/test.py @@ -0,0 +1,54 @@ +# 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/. + +import math +import time + +import mozunit +import pytest +from moztest.results import TestContext, TestResult, TestResultCollection + + +def test_results(): + with pytest.raises(AssertionError): + TestResult("test", result_expected="hello") + t = TestResult("test") + with pytest.raises(ValueError): + t.finish(result="good bye") + + +def test_time(): + now = time.time() + t = TestResult("test") + time.sleep(1) + t.finish("PASS") + duration = time.time() - now + assert math.fabs(duration - t.duration) < 1 + + +def test_custom_time(): + t = TestResult("test", time_start=0) + t.finish(result="PASS", time_end=1000) + assert t.duration == 1000 + + +def test_unique_contexts(): + c1 = TestContext("host1") + c2 = TestContext("host2") + c3 = TestContext("host2") + c4 = TestContext("host1") + + t1 = TestResult("t1", context=c1) + t2 = TestResult("t2", context=c2) + t3 = TestResult("t3", context=c3) + t4 = TestResult("t4", context=c4) + + collection = TestResultCollection("tests") + collection.extend([t1, t2, t3, t4]) + + assert len(collection.contexts) == 2 + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/moztest/tests/test_resolve.py b/testing/mozbase/moztest/tests/test_resolve.py new file mode 100644 index 0000000000..8445b58819 --- /dev/null +++ b/testing/mozbase/moztest/tests/test_resolve.py @@ -0,0 +1,577 @@ +# 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/. +# flake8: noqa: E501 + +try: + import cPickle as pickle +except ImportError: + import pickle + +import json +import os +import re +import shutil +import tempfile +from collections import defaultdict + +import manifestupdate +import mozpack.path as mozpath +import mozunit +import pytest +from mozbuild.base import MozbuildObject +from mozbuild.frontend.reader import BuildReader +from mozbuild.test.common import MockConfig +from mozfile import NamedTemporaryFile +from moztest.resolve import ( + TEST_SUITES, + BuildBackendLoader, + TestManifestLoader, + TestResolver, +) + +here = os.path.abspath(os.path.dirname(__file__)) +data_path = os.path.join(here, "data") + + +@pytest.fixture(scope="module") +def topsrcdir(): + return mozpath.join(data_path, "srcdir") + + +@pytest.fixture(scope="module") +def create_tests(topsrcdir): + def inner(*paths, **defaults): + tests = defaultdict(list) + for path in paths: + if isinstance(path, tuple): + path, kwargs = path + else: + kwargs = {} + + path = mozpath.normpath(path) + manifest_name = kwargs.get("flavor", defaults.get("flavor", "manifest")) + manifest = kwargs.pop( + "manifest", + defaults.pop( + "manifest", + mozpath.join(mozpath.dirname(path), manifest_name + ".toml"), + ), + ) + + manifest_abspath = mozpath.join(topsrcdir, manifest) + relpath = mozpath.relpath(path, mozpath.dirname(manifest)) + test = { + "name": relpath, + "path": mozpath.join(topsrcdir, path), + "relpath": relpath, + "file_relpath": path, + "flavor": "faketest", + "dir_relpath": mozpath.dirname(path), + "here": mozpath.dirname(manifest_abspath), + "manifest": manifest_abspath, + "manifest_relpath": manifest, + } + test.update(**defaults) + test.update(**kwargs) + + # Normalize paths to ensure that the fixture matches reality. + for k in [ + "ancestor_manifest", + "manifest", + "manifest_relpath", + "path", + "relpath", + ]: + p = test.get(k) + if p: + test[k] = p.replace("/", os.path.sep) + + tests[path].append(test) + + # dump tests to stdout for easier debugging on failure + print("The 'create_tests' fixture returned:") + print(json.dumps(dict(tests), indent=2, sort_keys=True)) + return tests + + return inner + + +@pytest.fixture(scope="module") +def all_tests(create_tests): + return create_tests( + *[ + ( + "apple/test_a11y.html", + { + "expected": "pass", + "manifest": "apple/a11y.toml", + "flavor": "a11y", + }, + ), + ( + "banana/currant/test_xpcshell_A.js", + { + "firefox-appdir": "browser", + "flavor": "xpcshell", + "head": "head_global.js head_helpers.js head_http.js", + }, + ), + ( + "banana/currant/test_xpcshell_B.js", + { + "firefox-appdir": "browser", + "flavor": "xpcshell", + "head": "head_global.js head_helpers.js head_http.js", + }, + ), + ( + "carrot/test_included.js", + { + "ancestor_manifest": "carrot/xpcshell-one.toml", + "manifest": "carrot/xpcshell-shared.toml", + "flavor": "xpcshell", + "stick": "one", + }, + ), + ( + "carrot/test_included.js", + { + "ancestor_manifest": "carrot/xpcshell-two.toml", + "manifest": "carrot/xpcshell-shared.toml", + "flavor": "xpcshell", + "stick": "two", + }, + ), + ( + "dragonfruit/elderberry/test_xpcshell_C.js", + { + "flavor": "xpcshell", + "generated-files": "head_update.js", + "head": "head_update.js", + "manifest": "dragonfruit/xpcshell.toml", + "reason": "busted", + "run-sequentially": "Launches application.", + "skip-if": "os == 'android'", + }, + ), + ( + "dragonfruit/elderberry/test_xpcshell_C.js", + { + "flavor": "xpcshell", + "generated-files": "head_update.js", + "head": "head_update.js head2.js", + "manifest": "dragonfruit/elderberry/xpcshell_updater.toml", + "reason": "don't work", + "run-sequentially": "Launches application.", + "skip-if": "os == 'android'", + }, + ), + ( + "fig/grape/src/TestInstrumentationA.java", + { + "flavor": "instrumentation", + "manifest": "fig/grape/instrumentation.toml", + "subsuite": "background", + }, + ), + ( + "fig/huckleberry/src/TestInstrumentationB.java", + { + "flavor": "instrumentation", + "manifest": "fig/huckleberry/instrumentation.toml", + "subsuite": "browser", + }, + ), + ( + "juniper/browser_chrome.js", + { + "flavor": "browser-chrome", + "manifest": "juniper/browser.toml", + "skip-if": "e10s # broken", + }, + ), + ( + "kiwi/browser_devtools.js", + { + "flavor": "browser-chrome", + "manifest": "kiwi/browser.toml", + "subsuite": "devtools", + "tags": "devtools", + }, + ), + ] + ) + + +@pytest.fixture(scope="module") +def defaults(topsrcdir): + def to_abspath(relpath): + # test-defaults.pkl uses absolute paths with platform-specific path separators. + # Use platform-specific separators if needed to avoid regressing on bug 1644223. + return os.path.normpath(os.path.join(topsrcdir, relpath)) + + return { + (to_abspath("dragonfruit/elderberry/xpcshell_updater.toml")): { + "support-files": "data/**\nxpcshell_updater.toml" + }, + ( + to_abspath("carrot/xpcshell-one.toml"), + to_abspath("carrot/xpcshell-shared.toml"), + ): { + "head": "head_one.js", + }, + ( + to_abspath("carrot/xpcshell-two.toml"), + to_abspath("carrot/xpcshell-shared.toml"), + ): { + "head": "head_two.js", + }, + } + + +class WPTManifestNamespace(object): + """Stand-in object for various WPT classes.""" + + def __init__(self, *args): + self.args = args + + def __hash__(self): + return hash(str(self)) + + def __eq__(self, other): + return self.args == other.args + + def __iter__(self): + yield tuple(self.args) + + +def fake_wpt_manifestupdate(topsrcdir, *args, **kwargs): + with open(os.path.join(topsrcdir, "wpt_manifest_data.json")) as fh: + data = json.load(fh) + + items = {} + for tests_root, test_data in data.items(): + kwargs = {"tests_path": os.path.join(topsrcdir, tests_root)} + + for test_type, tests in test_data.items(): + for test in tests: + obj = WPTManifestNamespace() + if "mozilla" in tests_root: + obj.id = "/_mozilla/" + test + else: + obj.id = "/" + test + + items[WPTManifestNamespace(test_type, test, {obj})] = kwargs + return items + + +@pytest.fixture(params=[BuildBackendLoader, TestManifestLoader]) +def resolver(request, tmpdir, monkeypatch, topsrcdir, all_tests, defaults): + topobjdir = tmpdir.mkdir("objdir").strpath + loader_cls = request.param + + if loader_cls == BuildBackendLoader: + with open(os.path.join(topobjdir, "all-tests.pkl"), "wb") as fh: + pickle.dump(all_tests, fh) + with open(os.path.join(topobjdir, "test-defaults.pkl"), "wb") as fh: + pickle.dump(defaults, fh) + + # The mock data already exists, so prevent BuildBackendLoader from regenerating + # the build information from the whole gecko tree... + class BuildBackendLoaderNeverOutOfDate(BuildBackendLoader): + def backend_out_of_date(self, backend_file): + return False + + loader_cls = BuildBackendLoaderNeverOutOfDate + + # Patch WPT's manifestupdate.run to return tests based on the contents of + # 'data/srcdir/wpt_manifest_data.json'. + monkeypatch.setattr(manifestupdate, "run", fake_wpt_manifestupdate) + + resolver = TestResolver( + topsrcdir, None, None, topobjdir=topobjdir, loader_cls=loader_cls + ) + resolver._puppeteer_loaded = True + + if loader_cls == TestManifestLoader: + config = MockConfig(topsrcdir) + resolver.load_tests.reader = BuildReader(config) + return resolver + + +def test_load(resolver): + assert len(resolver.tests_by_path) == 9 + + assert len(resolver.tests_by_flavor["mochitest-plain"]) == 0 + assert len(resolver.tests_by_flavor["xpcshell"]) == 4 + assert len(resolver.tests_by_flavor["web-platform-tests"]) == 0 + + assert len(resolver.tests_by_manifest) == 9 + + resolver.add_wpt_manifest_data() + assert len(resolver.tests_by_path) == 11 + assert len(resolver.tests_by_flavor["web-platform-tests"]) == 2 + assert len(resolver.tests_by_manifest) == 11 + assert "/html" in resolver.tests_by_manifest + assert "/_mozilla/html" in resolver.tests_by_manifest + + +def test_resolve_all(resolver): + assert len(list(resolver._resolve())) == 13 + + +def test_resolve_filter_flavor(resolver): + assert len(list(resolver._resolve(flavor="xpcshell"))) == 6 + + +def test_resolve_by_dir(resolver): + assert len(list(resolver._resolve(paths=["banana/currant"]))) == 2 + + +def test_resolve_under_path(resolver): + assert len(list(resolver._resolve(under_path="banana"))) == 2 + assert len(list(resolver._resolve(flavor="xpcshell", under_path="banana"))) == 2 + + +def test_resolve_multiple_paths(resolver): + result = list(resolver.resolve_tests(paths=["banana", "dragonfruit"])) + assert len(result) == 4 + + +def test_resolve_support_files(resolver): + expected_support_files = "data/**\nxpcshell_updater.toml" + tests = list(resolver.resolve_tests(paths=["dragonfruit"])) + assert len(tests) == 2 + + for test in tests: + if test["manifest"].endswith("xpcshell_updater.toml"): + assert test["support-files"] == expected_support_files + else: + assert "support-files" not in test + + +def test_resolve_path_prefix(resolver): + tests = list(resolver._resolve(paths=["juniper"])) + assert len(tests) == 1 + + # relative manifest + tests = list(resolver._resolve(paths=["apple/a11y.toml"])) + assert len(tests) == 1 + assert tests[0]["name"] == "test_a11y.html" + + # absolute manifest + tests = list( + resolver._resolve(paths=[os.path.join(resolver.topsrcdir, "apple/a11y.toml")]) + ) + assert len(tests) == 1 + assert tests[0]["name"] == "test_a11y.html" + + +def test_cwd_children_only(resolver): + """If cwd is defined, only resolve tests under the specified cwd.""" + # Pretend we're under '/services' and ask for 'common'. This should + # pick up all tests from '/services/common' + tests = list( + resolver.resolve_tests( + paths=["currant"], cwd=os.path.join(resolver.topsrcdir, "banana") + ) + ) + + assert len(tests) == 2 + + # Tests should be rewritten to objdir. + for t in tests: + assert t["here"] == mozpath.join( + resolver.topobjdir, "_tests/xpcshell/banana/currant" + ) + + +def test_various_cwd(resolver): + """Test various cwd conditions are all equal.""" + expected = list(resolver.resolve_tests(paths=["banana"])) + actual = list(resolver.resolve_tests(paths=["banana"], cwd="/")) + assert actual == expected + + actual = list(resolver.resolve_tests(paths=["banana"], cwd=resolver.topsrcdir)) + assert actual == expected + + actual = list(resolver.resolve_tests(paths=["banana"], cwd=resolver.topobjdir)) + assert actual == expected + + +def test_subsuites(resolver): + """Test filtering by subsuite.""" + tests = list(resolver.resolve_tests(paths=["fig"])) + assert len(tests) == 2 + + tests = list(resolver.resolve_tests(paths=["fig"], subsuite="browser")) + assert len(tests) == 1 + assert tests[0]["name"] == "src/TestInstrumentationB.java" + + tests = list(resolver.resolve_tests(paths=["fig"], subsuite="background")) + assert len(tests) == 1 + assert tests[0]["name"] == "src/TestInstrumentationA.java" + + # Resolve tests *without* a subsuite. + tests = list(resolver.resolve_tests(flavor="browser-chrome", subsuite="undefined")) + assert len(tests) == 1 + assert tests[0]["name"] == "browser_chrome.js" + + +def test_wildcard_patterns(resolver): + """Test matching paths by wildcard.""" + tests = list(resolver.resolve_tests(paths=["fig/**"])) + assert len(tests) == 2 + for t in tests: + assert t["file_relpath"].startswith("fig") + + tests = list(resolver.resolve_tests(paths=["**/**.js", "apple/**"])) + assert len(tests) == 9 + for t in tests: + path = t["file_relpath"] + assert path.startswith("apple") or path.endswith(".js") + + +def test_resolve_metadata(resolver): + """Test finding metadata from outgoing files.""" + suites, tests = resolver.resolve_metadata(["bc"]) + assert suites == {"mochitest-browser-chrome"} + assert tests == [] + + suites, tests = resolver.resolve_metadata( + ["mochitest-a11y", "/browser", "xpcshell"] + ) + assert suites == {"mochitest-a11y", "xpcshell"} + assert sorted(t["file_relpath"] for t in tests) == [ + "juniper/browser_chrome.js", + "kiwi/browser_devtools.js", + ] + + +def test_ancestor_manifest_defaults(resolver, topsrcdir, defaults): + """Test that defaults from ancestor manifests are found.""" + tests = list(resolver._resolve(paths=["carrot/test_included.js"])) + assert len(tests) == 2 + + if tests[0]["ancestor_manifest"] == os.path.join("carrot", "xpcshell-one.toml"): + [testOne, testTwo] = tests + else: + [testTwo, testOne] = tests + + assert testOne["ancestor_manifest"] == os.path.join("carrot", "xpcshell-one.toml") + assert testOne["manifest_relpath"] == os.path.join("carrot", "xpcshell-shared.toml") + assert testOne["head"] == "head_one.js" + assert testOne["stick"] == "one" + + assert testTwo["ancestor_manifest"] == os.path.join("carrot", "xpcshell-two.toml") + assert testTwo["manifest_relpath"] == os.path.join("carrot", "xpcshell-shared.toml") + assert testTwo["head"] == "head_two.js" + assert testTwo["stick"] == "two" + + +def test_task_regexes(): + """Test the task_regexes defined in TEST_SUITES.""" + task_labels = [ + "test-linux64/opt-marionette", + "test-linux64/opt-mochitest-plain", + "test-linux64/debug-mochitest-plain-e10s", + "test-linux64/opt-mochitest-a11y", + "test-linux64/opt-mochitest-browser", + "test-linux64/opt-mochitest-browser-chrome", + "test-linux64/opt-mochitest-browser-chrome-e10s", + "test-linux64/opt-mochitest-browser-chrome-e10s-11", + "test-linux64/opt-mochitest-chrome", + "test-linux64/opt-mochitest-devtools", + "test-linux64/opt-mochitest-devtools-chrome", + "test-linux64/opt-mochitest-gpu", + "test-linux64/opt-mochitest-gpu-e10s", + "test-linux64/opt-mochitest-media-e10s-1", + "test-linux64/opt-mochitest-media-e10s-11", + "test-linux64/opt-mochitest-browser-screenshots-1", + "test-linux64/opt-mochitest-browser-screenshots-e10s-1", + "test-linux64/opt-reftest", + "test-linux64/opt-geckoview-reftest", + "test-linux64/debug-reftest-e10s-1", + "test-linux64/debug-reftest-e10s-11", + "test-linux64/opt-robocop", + "test-linux64/opt-robocop-1", + "test-linux64/opt-robocop-e10s", + "test-linux64/opt-robocop-e10s-1", + "test-linux64/opt-robocop-e10s-11", + "test-linux64/opt-web-platform-tests-e10s-1", + "test-linux64/opt-web-platform-tests-reftest-e10s-1", + "test-linux64/opt-web-platform-tests-wdspec-e10s-1", + "test-linux64/opt-web-platform-tests-1", + "test-linux64/opt-web-platform-test-e10s-1", + "test-linux64/opt-xpcshell", + "test-linux64/opt-xpcshell-1", + "test-linux64/opt-xpcshell-2", + ] + + test_cases = { + "mochitest-browser-chrome": [ + "test-linux64/opt-mochitest-browser-chrome", + "test-linux64/opt-mochitest-browser-chrome-e10s", + ], + "mochitest-chrome": [ + "test-linux64/opt-mochitest-chrome", + ], + "mochitest-devtools-chrome": [ + "test-linux64/opt-mochitest-devtools-chrome", + ], + "mochitest-media": [ + "test-linux64/opt-mochitest-media-e10s-1", + ], + "mochitest-plain": [ + "test-linux64/opt-mochitest-plain", + "test-linux64/debug-mochitest-plain-e10s", + ], + "mochitest-plain-gpu": [ + "test-linux64/opt-mochitest-gpu", + "test-linux64/opt-mochitest-gpu-e10s", + ], + "mochitest-browser-screenshots": [ + "test-linux64/opt-mochitest-browser-screenshots-1", + "test-linux64/opt-mochitest-browser-screenshots-e10s-1", + ], + "reftest": [ + "test-linux64/opt-reftest", + "test-linux64/opt-geckoview-reftest", + "test-linux64/debug-reftest-e10s-1", + ], + "robocop": [ + "test-linux64/opt-robocop", + "test-linux64/opt-robocop-1", + "test-linux64/opt-robocop-e10s", + "test-linux64/opt-robocop-e10s-1", + ], + "web-platform-tests": [ + "test-linux64/opt-web-platform-tests-e10s-1", + "test-linux64/opt-web-platform-tests-1", + ], + "web-platform-tests-reftest": [ + "test-linux64/opt-web-platform-tests-reftest-e10s-1", + ], + "web-platform-tests-wdspec": [ + "test-linux64/opt-web-platform-tests-wdspec-e10s-1", + ], + "xpcshell": [ + "test-linux64/opt-xpcshell", + "test-linux64/opt-xpcshell-1", + ], + } + + regexes = [] + + def match_task(task): + return any(re.search(pattern, task) for pattern in regexes) + + for suite, expected in sorted(test_cases.items()): + print(suite) + regexes = TEST_SUITES[suite]["task_regex"] + assert set(filter(match_task, task_labels)) == set(expected) + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozversion/mozversion/__init__.py b/testing/mozbase/mozversion/mozversion/__init__.py new file mode 100644 index 0000000000..99c6b051d0 --- /dev/null +++ b/testing/mozbase/mozversion/mozversion/__init__.py @@ -0,0 +1,9 @@ +# flake8: noqa +# 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/. + +import mozversion.errors + +from .errors import * +from .mozversion import cli, get_version diff --git a/testing/mozbase/mozversion/mozversion/errors.py b/testing/mozbase/mozversion/mozversion/errors.py new file mode 100644 index 0000000000..db005c367a --- /dev/null +++ b/testing/mozbase/mozversion/mozversion/errors.py @@ -0,0 +1,29 @@ +# 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/. + + +class VersionError(Exception): + def __init__(self, message): + Exception.__init__(self, message) + + +class AppNotFoundError(VersionError): + """Exception for the application not found""" + + def __init__(self, message): + VersionError.__init__(self, message) + + +class LocalAppNotFoundError(AppNotFoundError): + """Exception for local application not found""" + + def __init__(self, path): + AppNotFoundError.__init__(self, "Application not found at: %s" % path) + + +class RemoteAppNotFoundError(AppNotFoundError): + """Exception for remote application not found""" + + def __init__(self, message): + AppNotFoundError.__init__(self, message) diff --git a/testing/mozbase/mozversion/mozversion/mozversion.py b/testing/mozbase/mozversion/mozversion/mozversion.py new file mode 100644 index 0000000000..9a93bf4e20 --- /dev/null +++ b/testing/mozbase/mozversion/mozversion/mozversion.py @@ -0,0 +1,153 @@ +# 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/. + +import argparse +import io +import os +import sys +import zipfile + +import mozlog +from six.moves import configparser + +from mozversion import errors + +INI_DATA_MAPPING = (("application", "App"), ("platform", "Build")) + + +class Version(object): + def __init__(self): + self._info = {} + self._logger = mozlog.get_default_logger(component="mozversion") + if not self._logger: + self._logger = mozlog.unstructured.getLogger("mozversion") + + def get_gecko_info(self, path): + for type, section in INI_DATA_MAPPING: + config_file = os.path.join(path, "%s.ini" % type) + if os.path.exists(config_file): + try: + with open(config_file) as fp: + self._parse_ini_file(fp, type, section) + except OSError: + self._logger.warning("Unable to read %s" % config_file) + else: + self._logger.warning("Unable to find %s" % config_file) + + def _parse_ini_file(self, fp, type, section): + config = configparser.RawConfigParser() + config.read_file(fp) + name_map = { + "codename": "display_name", + "milestone": "version", + "sourcerepository": "repository", + "sourcestamp": "changeset", + } + for key, value in config.items(section): + name = name_map.get(key, key).lower() + self._info["%s_%s" % (type, name)] = ( + config.has_option(section, key) and config.get(section, key) or None + ) + + if not self._info.get("application_display_name"): + self._info["application_display_name"] = self._info.get("application_name") + + +class LocalFennecVersion(Version): + def __init__(self, path, **kwargs): + Version.__init__(self, **kwargs) + self.get_gecko_info(path) + + def get_gecko_info(self, path): + archive = zipfile.ZipFile(path, "r") + archive_list = archive.namelist() + for type, section in INI_DATA_MAPPING: + filename = "%s.ini" % type + if filename in archive_list: + with io.TextIOWrapper(archive.open(filename)) as fp: + self._parse_ini_file(fp, type, section) + else: + self._logger.warning("Unable to find %s" % filename) + + if "package-name.txt" in archive_list: + with io.TextIOWrapper(archive.open("package-name.txt")) as fp: + self._info["package_name"] = fp.readlines()[0].strip() + + +class LocalVersion(Version): + def __init__(self, binary, **kwargs): + Version.__init__(self, **kwargs) + + if binary: + # on Windows, the binary may be specified with or without the + # .exe extension + if not os.path.exists(binary) and not os.path.exists(binary + ".exe"): + raise IOError("Binary path does not exist: %s" % binary) + path = os.path.dirname(os.path.realpath(binary)) + else: + path = os.getcwd() + + if not self.check_location(path): + if sys.platform == "darwin": + resources_path = os.path.join(os.path.dirname(path), "Resources") + if self.check_location(resources_path): + path = resources_path + else: + raise errors.LocalAppNotFoundError(path) + + else: + raise errors.LocalAppNotFoundError(path) + + self.get_gecko_info(path) + + def check_location(self, path): + return os.path.exists(os.path.join(path, "application.ini")) and os.path.exists( + os.path.join(path, "platform.ini") + ) + + +def get_version(binary=None): + """ + Returns the application version information as a dict. You can specify + a path to the binary of the application or an Android APK file (to get + version information for Firefox for Android). If this is omitted then the + current directory is checked for the existance of an application.ini + file. + + :param binary: Path to the binary for the application or Android APK file + """ + if ( + binary + and zipfile.is_zipfile(binary) + and "AndroidManifest.xml" in zipfile.ZipFile(binary, "r").namelist() + ): + version = LocalFennecVersion(binary) + else: + version = LocalVersion(binary) + + for key, value in sorted(version._info.items()): + if value: + version._logger.info("%s: %s" % (key, value)) + + return version._info + + +def cli(args=sys.argv[1:]): + parser = argparse.ArgumentParser( + description="Display version information for Mozilla applications" + ) + parser.add_argument("--binary", help="path to application binary or apk") + mozlog.commandline.add_logging_group( + parser, include_formatters=mozlog.commandline.TEXT_FORMATTERS + ) + + args = parser.parse_args() + + mozlog.commandline.setup_logging("mozversion", args, {"mach": sys.stdout}) + + get_version(binary=args.binary) + + +if __name__ == "__main__": + cli() diff --git a/testing/mozbase/mozversion/setup.cfg b/testing/mozbase/mozversion/setup.cfg new file mode 100644 index 0000000000..3c6e79cf31 --- /dev/null +++ b/testing/mozbase/mozversion/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 diff --git a/testing/mozbase/mozversion/setup.py b/testing/mozbase/mozversion/setup.py new file mode 100644 index 0000000000..9e286872ec --- /dev/null +++ b/testing/mozbase/mozversion/setup.py @@ -0,0 +1,33 @@ +# 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 setuptools import setup + +PACKAGE_VERSION = "2.4.0" + + +setup( + name="mozversion", + version=PACKAGE_VERSION, + description="Library to get version information for applications", + long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html", + classifiers=[ + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3.5", + ], + keywords="mozilla", + author="Mozilla Automation and Testing Team", + author_email="tools@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase", + license="MPL", + packages=["mozversion"], + include_package_data=True, + zip_safe=False, + install_requires=["mozlog >= 6.0", "six >= 1.13.0"], + entry_points=""" + # -*- Entry points: -*- + [console_scripts] + mozversion = mozversion:cli + """, +) diff --git a/testing/mozbase/mozversion/tests/manifest.toml b/testing/mozbase/mozversion/tests/manifest.toml new file mode 100644 index 0000000000..fb6c062fcf --- /dev/null +++ b/testing/mozbase/mozversion/tests/manifest.toml @@ -0,0 +1,6 @@ +[DEFAULT] +subsuite = "mozbase" + +["test_apk.py"] + +["test_binary.py"] diff --git a/testing/mozbase/mozversion/tests/test_apk.py b/testing/mozbase/mozversion/tests/test_apk.py new file mode 100644 index 0000000000..7ca4d4e068 --- /dev/null +++ b/testing/mozbase/mozversion/tests/test_apk.py @@ -0,0 +1,45 @@ +#!/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/. + +import zipfile + +import mozunit +import pytest +from mozversion import get_version + +"""test getting version information from an android .apk""" + +application_changeset = "a" * 40 +platform_changeset = "b" * 40 + + +@pytest.fixture(name="apk") +def fixture_apk(tmpdir): + path = str(tmpdir.join("apk.zip")) + with zipfile.ZipFile(path, "w") as z: + z.writestr( + "application.ini", """[App]\nSourceStamp=%s\n""" % application_changeset + ) + z.writestr("platform.ini", """[Build]\nSourceStamp=%s\n""" % platform_changeset) + z.writestr("AndroidManifest.xml", "") + return path + + +def test_basic(apk): + v = get_version(apk) + assert v.get("application_changeset") == application_changeset + assert v.get("platform_changeset") == platform_changeset + + +def test_with_package_name(apk): + with zipfile.ZipFile(apk, "a") as z: + z.writestr("package-name.txt", "org.mozilla.fennec") + v = get_version(apk) + assert v.get("package_name") == "org.mozilla.fennec" + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/mozversion/tests/test_binary.py b/testing/mozbase/mozversion/tests/test_binary.py new file mode 100644 index 0000000000..9de6bb0e6b --- /dev/null +++ b/testing/mozbase/mozversion/tests/test_binary.py @@ -0,0 +1,157 @@ +#!/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/. + +import os +import shutil +import sys + +import mozunit +import pytest +from moztest.selftest.fixtures import binary_fixture # noqa: F401 +from mozversion import errors, get_version + +"""test getting application version information from a binary path""" + + +@pytest.fixture() +def fake_binary(tmpdir): + binary = tmpdir.join("binary") + binary.write("foobar") + return str(binary) + + +@pytest.fixture(name="application_ini") +def fixture_application_ini(tmpdir): + ini = tmpdir.join("application.ini") + ini.write( + """[App] +ID = AppID +Name = AppName +CodeName = AppCodeName +Version = AppVersion +BuildID = AppBuildID +SourceRepository = AppSourceRepo +SourceStamp = AppSourceStamp +Vendor = AppVendor""" + ) + return str(ini) + + +@pytest.fixture(name="platform_ini") +def fixture_platform_ini(tmpdir): + ini = tmpdir.join("platform.ini") + ini.write( + """[Build] +BuildID = PlatformBuildID +Milestone = PlatformMilestone +SourceStamp = PlatformSourceStamp +SourceRepository = PlatformSourceRepo""" + ) + return str(ini) + + +def test_real_binary(binary): # noqa: F811 + if not binary: + pytest.skip("No binary found") + v = get_version(binary) + assert isinstance(v, dict) + + +def test_binary(fake_binary, application_ini, platform_ini): + _check_version(get_version(fake_binary)) + + +@pytest.mark.skipif( + not hasattr(os, "symlink"), reason="os.symlink not supported on this platform" +) +def test_symlinked_binary(fake_binary, application_ini, platform_ini, tmpdir): + # create a symlink of the binary in another directory and check + # version against this symlink + symlink = str(tmpdir.join("symlink")) + os.symlink(fake_binary, symlink) + _check_version(get_version(symlink)) + + +def test_binary_in_current_path(fake_binary, application_ini, platform_ini, tmpdir): + os.chdir(str(tmpdir)) + _check_version(get_version()) + + +def test_with_ini_files_on_osx( + fake_binary, application_ini, platform_ini, monkeypatch, tmpdir +): + monkeypatch.setattr(sys, "platform", "darwin") + # get_version is working with ini files next to the binary + _check_version(get_version(binary=fake_binary)) + + # or if they are in the Resources dir + # in this case the binary must be in a Contents dir, next + # to the Resources dir + contents_dir = tmpdir.mkdir("Contents") + moved_binary = str(contents_dir.join(os.path.basename(fake_binary))) + shutil.move(fake_binary, moved_binary) + + resources_dir = str(tmpdir.mkdir("Resources")) + shutil.move(application_ini, resources_dir) + shutil.move(platform_ini, resources_dir) + + _check_version(get_version(binary=moved_binary)) + + +def test_invalid_binary_path(tmpdir): + with pytest.raises(IOError): + get_version(str(tmpdir.join("invalid"))) + + +def test_without_ini_files(fake_binary): + """With missing ini files an exception should be thrown""" + with pytest.raises(errors.AppNotFoundError): + get_version(fake_binary) + + +def test_without_platform_ini_file(fake_binary, application_ini): + """With a missing platform.ini file an exception should be thrown""" + with pytest.raises(errors.AppNotFoundError): + get_version(fake_binary) + + +def test_without_application_ini_file(fake_binary, platform_ini): + """With a missing application.ini file an exception should be thrown""" + with pytest.raises(errors.AppNotFoundError): + get_version(fake_binary) + + +def test_with_exe(application_ini, platform_ini, tmpdir): + """Test that we can resolve .exe files""" + binary = tmpdir.join("binary.exe") + binary.write("foobar") + _check_version(get_version(os.path.splitext(str(binary))[0])) + + +def test_not_found_with_binary_specified(fake_binary): + with pytest.raises(errors.LocalAppNotFoundError): + get_version(fake_binary) + + +def _check_version(version): + assert version.get("application_id") == "AppID" + assert version.get("application_name") == "AppName" + assert version.get("application_display_name") == "AppCodeName" + assert version.get("application_version") == "AppVersion" + assert version.get("application_buildid") == "AppBuildID" + assert version.get("application_repository") == "AppSourceRepo" + assert version.get("application_changeset") == "AppSourceStamp" + assert version.get("application_vendor") == "AppVendor" + assert version.get("platform_name") is None + assert version.get("platform_buildid") == "PlatformBuildID" + assert version.get("platform_repository") == "PlatformSourceRepo" + assert version.get("platform_changeset") == "PlatformSourceStamp" + assert version.get("invalid_key") is None + assert version.get("platform_version") == "PlatformMilestone" + + +if __name__ == "__main__": + mozunit.main() diff --git a/testing/mozbase/rust/mozdevice/Cargo.toml b/testing/mozbase/rust/mozdevice/Cargo.toml new file mode 100644 index 0000000000..b5ecd9ea19 --- /dev/null +++ b/testing/mozbase/rust/mozdevice/Cargo.toml @@ -0,0 +1,25 @@ +[package] +edition = "2021" +name = "mozdevice" +version = "0.5.2" +authors = ["Mozilla"] +description = "Client library for the Android Debug Bridge (adb)" +keywords = [ + "adb", + "android", + "firefox", + "geckoview", + "mozilla", +] +license = "MPL-2.0" +repository = "https://hg.mozilla.org/mozilla-central/file/tip/testing/mozbase/rust/mozdevice" + +[dependencies] +log = { version = "0.4", features = ["std"] } +once_cell = "1.4.0" +regex = { version = "1", default-features = false, features = ["perf", "std"] } +tempfile = "3" +thiserror = "1.0.25" +walkdir = "2" +unix_path = "1.0" +uuid = { version = "1.0", features = ["serde", "v4"] } diff --git a/testing/mozbase/rust/mozdevice/src/adb.rs b/testing/mozbase/rust/mozdevice/src/adb.rs new file mode 100644 index 0000000000..9d9c91fd07 --- /dev/null +++ b/testing/mozbase/rust/mozdevice/src/adb.rs @@ -0,0 +1,38 @@ +/* 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/. */ + +#[derive(Debug, PartialEq)] +pub enum SyncCommand { + Data, + Dent, + Done, + Fail, + List, + Okay, + Quit, + Recv, + Send, + Stat, +} + +impl SyncCommand { + // Returns the byte serialisation of the protocol status. + pub fn code(&self) -> &'static [u8; 4] { + use self::SyncCommand::*; + match *self { + Data => b"DATA", + Dent => b"DENT", + Done => b"DONE", + Fail => b"FAIL", + List => b"LIST", + Okay => b"OKAY", + Quit => b"QUIT", + Recv => b"RECV", + Send => b"SEND", + Stat => b"STAT", + } + } +} + +pub type DeviceSerial = String; diff --git a/testing/mozbase/rust/mozdevice/src/lib.rs b/testing/mozbase/rust/mozdevice/src/lib.rs new file mode 100644 index 0000000000..5fd13b4903 --- /dev/null +++ b/testing/mozbase/rust/mozdevice/src/lib.rs @@ -0,0 +1,1065 @@ +/* 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/. */ + +pub mod adb; +pub mod shell; + +#[cfg(test)] +pub mod test; + +use log::{debug, info, trace, warn}; +use once_cell::sync::Lazy; +use regex::Regex; +use std::collections::BTreeMap; +use std::convert::TryFrom; +use std::fs::File; +use std::io::{self, Read, Write}; +use std::iter::FromIterator; +use std::net::TcpStream; +use std::num::{ParseIntError, TryFromIntError}; +use std::path::{Component, Path}; +use std::str::{FromStr, Utf8Error}; +use std::time::{Duration, SystemTime}; +use thiserror::Error; +pub use unix_path::{Path as UnixPath, PathBuf as UnixPathBuf}; +use uuid::Uuid; +use walkdir::WalkDir; + +use crate::adb::{DeviceSerial, SyncCommand}; + +pub type Result<T> = std::result::Result<T, DeviceError>; + +static SYNC_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"[^A-Za-z0-9_@%+=:,./-]").unwrap()); + +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub enum AndroidStorageInput { + #[default] + Auto, + App, + Internal, + Sdcard, +} + +impl FromStr for AndroidStorageInput { + type Err = DeviceError; + + fn from_str(s: &str) -> Result<Self> { + match s { + "auto" => Ok(AndroidStorageInput::Auto), + "app" => Ok(AndroidStorageInput::App), + "internal" => Ok(AndroidStorageInput::Internal), + "sdcard" => Ok(AndroidStorageInput::Sdcard), + _ => Err(DeviceError::InvalidStorage), + } + } +} + + + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum AndroidStorage { + App, + Internal, + Sdcard, +} + +#[derive(Debug, Error)] +pub enum DeviceError { + #[error("{0}")] + Adb(String), + #[error(transparent)] + FromInt(#[from] TryFromIntError), + #[error("Invalid storage")] + InvalidStorage, + #[error(transparent)] + Io(#[from] io::Error), + #[error("Missing package")] + MissingPackage, + #[error("Multiple Android devices online")] + MultipleDevices, + #[error(transparent)] + ParseInt(#[from] ParseIntError), + #[error("Unknown Android device with serial '{0}'")] + UnknownDevice(String), + #[error(transparent)] + Utf8(#[from] Utf8Error), + #[error(transparent)] + WalkDir(#[from] walkdir::Error), +} + +fn encode_message(payload: &str) -> Result<String> { + let hex_length = u16::try_from(payload.len()).map(|len| format!("{:0>4X}", len))?; + + Ok(format!("{}{}", hex_length, payload)) +} + +fn parse_device_info(line: &str) -> Option<DeviceInfo> { + // Turn "serial\tdevice key1:value1 key2:value2 ..." into a `DeviceInfo`. + let mut pairs = line.split_whitespace(); + let serial = pairs.next(); + let state = pairs.next(); + if let (Some(serial), Some("device")) = (serial, state) { + let info: BTreeMap<String, String> = pairs + .filter_map(|pair| { + let mut kv = pair.split(':'); + if let (Some(k), Some(v), None) = (kv.next(), kv.next(), kv.next()) { + Some((k.to_owned(), v.to_owned())) + } else { + None + } + }) + .collect(); + + Some(DeviceInfo { + serial: serial.to_owned(), + info, + }) + } else { + None + } +} + +/// Reads the payload length of a host message from the stream. +fn read_length<R: Read>(stream: &mut R) -> Result<usize> { + let mut bytes: [u8; 4] = [0; 4]; + stream.read_exact(&mut bytes)?; + + let response = std::str::from_utf8(&bytes)?; + + Ok(usize::from_str_radix(response, 16)?) +} + +/// Reads the payload length of a device message from the stream. +fn read_length_little_endian(reader: &mut dyn Read) -> Result<usize> { + let mut bytes: [u8; 4] = [0; 4]; + reader.read_exact(&mut bytes)?; + + let n: usize = (bytes[0] as usize) + + ((bytes[1] as usize) << 8) + + ((bytes[2] as usize) << 16) + + ((bytes[3] as usize) << 24); + + Ok(n) +} + +/// Writes the payload length of a device message to the stream. +fn write_length_little_endian(writer: &mut dyn Write, n: usize) -> Result<usize> { + let mut bytes = [0; 4]; + bytes[0] = (n & 0xFF) as u8; + bytes[1] = ((n >> 8) & 0xFF) as u8; + bytes[2] = ((n >> 16) & 0xFF) as u8; + bytes[3] = ((n >> 24) & 0xFF) as u8; + + writer.write(&bytes[..]).map_err(DeviceError::Io) +} + +fn read_response(stream: &mut TcpStream, has_output: bool, has_length: bool) -> Result<Vec<u8>> { + let mut bytes: [u8; 1024] = [0; 1024]; + + stream.read_exact(&mut bytes[0..4])?; + + if !bytes.starts_with(SyncCommand::Okay.code()) { + let n = bytes.len().min(read_length(stream)?); + stream.read_exact(&mut bytes[0..n])?; + + let message = std::str::from_utf8(&bytes[0..n]).map(|s| format!("adb error: {}", s))?; + + return Err(DeviceError::Adb(message)); + } + + let mut response = Vec::new(); + + if has_output { + stream.read_to_end(&mut response)?; + + if response.starts_with(SyncCommand::Okay.code()) { + // Sometimes the server produces OKAYOKAY. Sometimes there is a transport OKAY and + // then the underlying command OKAY. This is straight from `chromedriver`. + response = response.split_off(4); + } + + if response.starts_with(SyncCommand::Fail.code()) { + // The server may even produce OKAYFAIL, which means the underlying + // command failed. First split-off the `FAIL` and length of the message. + response = response.split_off(8); + + let message = std::str::from_utf8(&response).map(|s| format!("adb error: {}", s))?; + + return Err(DeviceError::Adb(message)); + } + + if has_length { + if response.len() >= 4 { + let message = response.split_off(4); + let slice: &mut &[u8] = &mut &*response; + + let n = read_length(slice)?; + if n != message.len() { + warn!("adb server response contained hexstring len {} but remaining message length is {}", n, message.len()); + } + + trace!( + "adb server response was {:?}", + std::str::from_utf8(&message)? + ); + + return Ok(message); + } else { + return Err(DeviceError::Adb(format!( + "adb server response did not contain expected hexstring length: {:?}", + std::str::from_utf8(&response)? + ))); + } + } + } + + Ok(response) +} + +/// Detailed information about an ADB device. +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)] +pub struct DeviceInfo { + pub serial: DeviceSerial, + pub info: BTreeMap<String, String>, +} + +/// Represents a connection to an ADB host, which multiplexes the connections to +/// individual devices. +#[derive(Debug)] +pub struct Host { + /// The TCP host to connect to. Defaults to `"localhost"`. + pub host: Option<String>, + /// The TCP port to connect to. Defaults to `5037`. + pub port: Option<u16>, + /// Optional TCP read timeout duration. Defaults to 2s. + pub read_timeout: Option<Duration>, + /// Optional TCP write timeout duration. Defaults to 2s. + pub write_timeout: Option<Duration>, +} + +impl Default for Host { + fn default() -> Host { + Host { + host: Some("localhost".to_string()), + port: Some(5037), + read_timeout: Some(Duration::from_secs(2)), + write_timeout: Some(Duration::from_secs(2)), + } + } +} + +impl Host { + /// Searches for available devices, and selects the one as specified by `device_serial`. + /// + /// If multiple devices are online, and no device has been specified, + /// the `ANDROID_SERIAL` environment variable can be used to select one. + pub fn device_or_default<T: AsRef<str>>( + self, + device_serial: Option<&T>, + storage: AndroidStorageInput, + ) -> Result<Device> { + let serials: Vec<String> = self + .devices::<Vec<_>>()? + .into_iter() + .map(|d| d.serial) + .collect(); + + if let Some(ref serial) = device_serial + .map(|v| v.as_ref().to_owned()) + .or_else(|| std::env::var("ANDROID_SERIAL").ok()) + { + if !serials.contains(serial) { + return Err(DeviceError::UnknownDevice(serial.clone())); + } + + return Device::new(self, serial.to_owned(), storage); + } + + if serials.len() > 1 { + return Err(DeviceError::MultipleDevices); + } + + if let Some(ref serial) = serials.first() { + return Device::new(self, serial.to_owned().to_string(), storage); + } + + Err(DeviceError::Adb("No Android devices are online".to_owned())) + } + + pub fn connect(&self) -> Result<TcpStream> { + let stream = TcpStream::connect(format!( + "{}:{}", + self.host.clone().unwrap_or_else(|| "localhost".to_owned()), + self.port.unwrap_or(5037) + ))?; + stream.set_read_timeout(self.read_timeout)?; + stream.set_write_timeout(self.write_timeout)?; + Ok(stream) + } + + pub fn execute_command( + &self, + command: &str, + has_output: bool, + has_length: bool, + ) -> Result<String> { + let mut stream = self.connect()?; + + stream.write_all(encode_message(command)?.as_bytes())?; + let bytes = read_response(&mut stream, has_output, has_length)?; + // TODO: should we assert no bytes were read? + + let response = std::str::from_utf8(&bytes)?; + + Ok(response.to_owned()) + } + + pub fn execute_host_command( + &self, + host_command: &str, + has_length: bool, + has_output: bool, + ) -> Result<String> { + self.execute_command(&format!("host:{}", host_command), has_output, has_length) + } + + pub fn features<B: FromIterator<String>>(&self) -> Result<B> { + let features = self.execute_host_command("features", true, true)?; + Ok(features.split(',').map(|x| x.to_owned()).collect()) + } + + pub fn devices<B: FromIterator<DeviceInfo>>(&self) -> Result<B> { + let response = self.execute_host_command("devices-l", true, true)?; + + let infos: B = response.lines().filter_map(parse_device_info).collect(); + + Ok(infos) + } +} + +/// Represents an ADB device. +#[derive(Debug)] +pub struct Device { + /// ADB host that controls this device. + pub host: Host, + + /// Serial number uniquely identifying this ADB device. + pub serial: DeviceSerial, + + /// adb running as root + pub adbd_root: bool, + + /// Flag for rooted device + pub is_rooted: bool, + + /// "su 0" command available + pub su_0_root: bool, + + /// "su -c" command available + pub su_c_root: bool, + + pub run_as_package: Option<String>, + + pub storage: AndroidStorage, + + /// Cache intermediate tempfile name used in pushing via run_as. + pub tempfile: UnixPathBuf, +} + +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)] +pub struct RemoteDirEntry { + depth: usize, + metadata: RemoteMetadata, + name: String, +} + +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)] +pub enum RemoteMetadata { + RemoteFile(RemoteFileMetadata), + RemoteDir, + RemoteSymlink, +} +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)] +pub struct RemoteFileMetadata { + mode: usize, + size: usize, +} + +impl Device { + pub fn new(host: Host, serial: DeviceSerial, storage: AndroidStorageInput) -> Result<Device> { + let mut device = Device { + host, + serial, + adbd_root: false, + is_rooted: false, + run_as_package: None, + storage: AndroidStorage::App, + su_c_root: false, + su_0_root: false, + tempfile: UnixPathBuf::from("/data/local/tmp"), + }; + device + .tempfile + .push(Uuid::new_v4().as_hyphenated().to_string()); + + // check for rooted devices + let uid_check = |id: String| id.contains("uid=0"); + device.adbd_root = device + .execute_host_shell_command("id") + .map_or(false, uid_check); + device.su_0_root = device + .execute_host_shell_command("su 0 id") + .map_or(false, uid_check); + device.su_c_root = device + .execute_host_shell_command("su -c id") + .map_or(false, uid_check); + device.is_rooted = device.adbd_root || device.su_0_root || device.su_c_root; + + device.storage = match storage { + AndroidStorageInput::App => AndroidStorage::App, + AndroidStorageInput::Internal => AndroidStorage::Internal, + AndroidStorageInput::Sdcard => AndroidStorage::Sdcard, + AndroidStorageInput::Auto => AndroidStorage::Sdcard, + }; + + if device.is_rooted { + info!("Device is rooted"); + + // Set Permissive=1 if we have root. + device.execute_host_shell_command("setenforce permissive")?; + } else { + info!("Device is unrooted"); + } + + Ok(device) + } + + pub fn clear_app_data(&self, package: &str) -> Result<bool> { + self.execute_host_shell_command(&format!("pm clear {}", package)) + .map(|v| v.contains("Success")) + } + + pub fn create_dir(&self, path: &UnixPath) -> Result<()> { + debug!("Creating {}", path.display()); + + let enable_run_as = self.enable_run_as_for_path(path); + self.execute_host_shell_command_as(&format!("mkdir -p {}", path.display()), enable_run_as)?; + + Ok(()) + } + + pub fn chmod(&self, path: &UnixPath, mask: &str, recursive: bool) -> Result<()> { + let enable_run_as = self.enable_run_as_for_path(path); + + let recursive = match recursive { + true => " -R", + false => "", + }; + + self.execute_host_shell_command_as( + &format!("chmod {} {} {}", recursive, mask, path.display()), + enable_run_as, + )?; + + Ok(()) + } + + pub fn execute_host_command( + &self, + command: &str, + has_output: bool, + has_length: bool, + ) -> Result<String> { + let mut stream = self.host.connect()?; + + let switch_command = format!("host:transport:{}", self.serial); + trace!("execute_host_command: >> {:?}", &switch_command); + stream.write_all(encode_message(&switch_command)?.as_bytes())?; + let _bytes = read_response(&mut stream, false, false)?; + trace!("execute_host_command: << {:?}", _bytes); + // TODO: should we assert no bytes were read? + + trace!("execute_host_command: >> {:?}", &command); + stream.write_all(encode_message(command)?.as_bytes())?; + let bytes = read_response(&mut stream, has_output, has_length)?; + let response = std::str::from_utf8(&bytes)?; + trace!("execute_host_command: << {:?}", response); + + // Unify new lines by removing possible carriage returns + Ok(response.replace("\r\n", "\n")) + } + + pub fn enable_run_as_for_path(&self, path: &UnixPath) -> bool { + match &self.run_as_package { + Some(package) => { + let mut p = UnixPathBuf::from("/data/data/"); + p.push(package); + path.starts_with(p) + } + None => false, + } + } + + pub fn execute_host_shell_command(&self, shell_command: &str) -> Result<String> { + self.execute_host_shell_command_as(shell_command, false) + } + + pub fn execute_host_shell_command_as( + &self, + shell_command: &str, + enable_run_as: bool, + ) -> Result<String> { + // We don't want to duplicate su invocations. + if shell_command.starts_with("su") { + return self.execute_host_command(&format!("shell:{}", shell_command), true, false); + } + + let has_outer_quotes = shell_command.starts_with('"') && shell_command.ends_with('"') + || shell_command.starts_with('\'') && shell_command.ends_with('\''); + + if self.adbd_root { + return self.execute_host_command(&format!("shell:{}", shell_command), true, false); + } + + if self.su_0_root { + return self.execute_host_command( + &format!("shell:su 0 {}", shell_command), + true, + false, + ); + } + + if self.su_c_root { + if has_outer_quotes { + return self.execute_host_command( + &format!("shell:su -c {}", shell_command), + true, + false, + ); + } + + if SYNC_REGEX.is_match(shell_command) { + let arg: &str = &shell_command.replace('\'', "'\"'\"'")[..]; + return self.execute_host_command(&format!("shell:su -c '{}'", arg), true, false); + } + + return self.execute_host_command( + &format!("shell:su -c \"{}\"", shell_command), + true, + false, + ); + } + + // Execute command as package + if enable_run_as { + let run_as_package = self + .run_as_package + .as_ref() + .ok_or(DeviceError::MissingPackage)?; + + if has_outer_quotes { + return self.execute_host_command( + &format!("shell:run-as {} {}", run_as_package, shell_command), + true, + false, + ); + } + + if SYNC_REGEX.is_match(shell_command) { + let arg: &str = &shell_command.replace('\'', "'\"'\"'")[..]; + return self.execute_host_command( + &format!("shell:run-as {} {}", run_as_package, arg), + true, + false, + ); + } + + return self.execute_host_command( + &format!("shell:run-as {} \"{}\"", run_as_package, shell_command), + true, + false, + ); + } + + self.execute_host_command(&format!("shell:{}", shell_command), true, false) + } + + pub fn is_app_installed(&self, package: &str) -> Result<bool> { + self.execute_host_shell_command(&format!("pm path {}", package)) + .map(|v| v.contains("package:")) + } + + pub fn launch<T: AsRef<str>>( + &self, + package: &str, + activity: &str, + am_start_args: &[T], + ) -> Result<bool> { + let mut am_start = format!("am start -W -n {}/{}", package, activity); + + for arg in am_start_args { + am_start.push(' '); + if SYNC_REGEX.is_match(arg.as_ref()) { + am_start.push_str(&format!("\"{}\"", &shell::escape(arg.as_ref()))); + } else { + am_start.push_str(&shell::escape(arg.as_ref())); + }; + } + + self.execute_host_shell_command(&am_start) + .map(|v| v.contains("Complete")) + } + + pub fn force_stop(&self, package: &str) -> Result<()> { + debug!("Force stopping Android package: {}", package); + self.execute_host_shell_command(&format!("am force-stop {}", package)) + .and(Ok(())) + } + + pub fn forward_port(&self, local: u16, remote: u16) -> Result<u16> { + let command = format!( + "host-serial:{}:forward:tcp:{};tcp:{}", + self.serial, local, remote + ); + let response = self.host.execute_command(&command, true, false)?; + + if local == 0 { + Ok(response.parse::<u16>()?) + } else { + Ok(local) + } + } + + pub fn kill_forward_port(&self, local: u16) -> Result<()> { + let command = format!("host-serial:{}:killforward:tcp:{}", self.serial, local); + self.execute_host_command(&command, true, false).and(Ok(())) + } + + pub fn kill_forward_all_ports(&self) -> Result<()> { + let command = format!("host-serial:{}:killforward-all", self.serial); + self.execute_host_command(&command, false, false) + .and(Ok(())) + } + + pub fn reverse_port(&self, remote: u16, local: u16) -> Result<u16> { + let command = format!("reverse:forward:tcp:{};tcp:{}", remote, local); + let response = self.execute_host_command(&command, true, false)?; + + if remote == 0 { + Ok(response.parse::<u16>()?) + } else { + Ok(remote) + } + } + + pub fn kill_reverse_port(&self, remote: u16) -> Result<()> { + let command = format!("reverse:killforward:tcp:{}", remote); + self.execute_host_command(&command, true, true).and(Ok(())) + } + + pub fn kill_reverse_all_ports(&self) -> Result<()> { + let command = "reverse:killforward-all".to_owned(); + self.execute_host_command(&command, false, false) + .and(Ok(())) + } + + pub fn list_dir(&self, src: &UnixPath) -> Result<Vec<RemoteDirEntry>> { + let src = src.to_path_buf(); + let mut queue = vec![(src.clone(), 0, "".to_string())]; + + let mut listings = Vec::new(); + + while let Some((next, depth, prefix)) = queue.pop() { + for listing in self.list_dir_flat(&next, depth, prefix)? { + if listing.metadata == RemoteMetadata::RemoteDir { + let mut child = src.clone(); + child.push(listing.name.clone()); + queue.push((child, depth + 1, listing.name.clone())); + } + + listings.push(listing); + } + } + + Ok(listings) + } + + fn list_dir_flat( + &self, + src: &UnixPath, + depth: usize, + prefix: String, + ) -> Result<Vec<RemoteDirEntry>> { + // Implement the ADB protocol to list a directory from the device. + let mut stream = self.host.connect()?; + + // Send "host:transport" command with device serial + let message = encode_message(&format!("host:transport:{}", self.serial))?; + stream.write_all(message.as_bytes())?; + let _bytes = read_response(&mut stream, false, true)?; + + // Send "sync:" command to initialize file transfer + let message = encode_message("sync:")?; + stream.write_all(message.as_bytes())?; + let _bytes = read_response(&mut stream, false, true)?; + + // Send "LIST" command with name of the directory + stream.write_all(SyncCommand::List.code())?; + let args_ = format!("{}", src.display()); + let args = args_.as_bytes(); + write_length_little_endian(&mut stream, args.len())?; + stream.write_all(args)?; + + // Use the maximum 64KB buffer to transfer the file contents. + let mut buf = [0; 64 * 1024]; + + let mut listings = Vec::new(); + + // Read "DENT" command one or more times for the directory entries + loop { + stream.read_exact(&mut buf[0..4])?; + + if &buf[0..4] == SyncCommand::Dent.code() { + // From https://github.com/cstyan/adbDocumentation/blob/6d025b3e4af41be6f93d37f516a8ac7913688623/README.md: + // + // A four-byte integer representing file mode - first 9 bits of this mode represent + // the file permissions, as with chmod mode. Bits 14 to 16 seem to represent the + // file type, one of 0b100 (file), 0b010 (directory), 0b101 (symlink) + // A four-byte integer representing file size. + // A four-byte integer representing last modified time in seconds since Unix Epoch. + // A four-byte integer representing file name length. + // A utf-8 string representing the file name. + let mode = read_length_little_endian(&mut stream)?; + let size = read_length_little_endian(&mut stream)?; + let _time = read_length_little_endian(&mut stream)?; + let name_length = read_length_little_endian(&mut stream)?; + stream.read_exact(&mut buf[0..name_length])?; + + let mut name = std::str::from_utf8(&buf[0..name_length])?.to_owned(); + + if name == "." || name == ".." { + continue; + } + + if !prefix.is_empty() { + name = format!("{}/{}", prefix, &name); + } + + let file_type = (mode >> 13) & 0b111; + let metadata = match file_type { + 0b010 => RemoteMetadata::RemoteDir, + 0b100 => RemoteMetadata::RemoteFile(RemoteFileMetadata { + mode: mode & 0b111111111, + size, + }), + 0b101 => RemoteMetadata::RemoteSymlink, + _ => return Err(DeviceError::Adb(format!("Invalid file mode {}", file_type))), + }; + + listings.push(RemoteDirEntry { + name, + depth, + metadata, + }); + } else if &buf[0..4] == SyncCommand::Done.code() { + // "DONE" command indicates end of file transfer + break; + } else if &buf[0..4] == SyncCommand::Fail.code() { + let n = buf.len().min(read_length_little_endian(&mut stream)?); + + stream.read_exact(&mut buf[0..n])?; + + let message = std::str::from_utf8(&buf[0..n]) + .map(|s| format!("adb error: {}", s)) + .unwrap_or_else(|_| "adb error was not utf-8".into()); + + return Err(DeviceError::Adb(message)); + } else { + return Err(DeviceError::Adb("FAIL (unknown)".to_owned())); + } + } + + Ok(listings) + } + + pub fn path_exists(&self, path: &UnixPath, enable_run_as: bool) -> Result<bool> { + self.execute_host_shell_command_as(format!("ls {}", path.display()).as_str(), enable_run_as) + .map(|path| !path.contains("No such file or directory")) + } + + pub fn pull(&self, src: &UnixPath, buffer: &mut dyn Write) -> Result<()> { + // Implement the ADB protocol to receive a file from the device. + let mut stream = self.host.connect()?; + + // Send "host:transport" command with device serial + let message = encode_message(&format!("host:transport:{}", self.serial))?; + stream.write_all(message.as_bytes())?; + let _bytes = read_response(&mut stream, false, true)?; + + // Send "sync:" command to initialize file transfer + let message = encode_message("sync:")?; + stream.write_all(message.as_bytes())?; + let _bytes = read_response(&mut stream, false, true)?; + + // Send "RECV" command with name of the file + stream.write_all(SyncCommand::Recv.code())?; + let args_string = format!("{}", src.display()); + let args = args_string.as_bytes(); + write_length_little_endian(&mut stream, args.len())?; + stream.write_all(args)?; + + // Use the maximum 64KB buffer to transfer the file contents. + let mut buf = [0; 64 * 1024]; + + // Read "DATA" command one or more times for the file content + loop { + stream.read_exact(&mut buf[0..4])?; + + if &buf[0..4] == SyncCommand::Data.code() { + let len = read_length_little_endian(&mut stream)?; + stream.read_exact(&mut buf[0..len])?; + buffer.write_all(&buf[0..len])?; + } else if &buf[0..4] == SyncCommand::Done.code() { + // "DONE" command indicates end of file transfer + break; + } else if &buf[0..4] == SyncCommand::Fail.code() { + let n = buf.len().min(read_length_little_endian(&mut stream)?); + + stream.read_exact(&mut buf[0..n])?; + + let message = std::str::from_utf8(&buf[0..n]) + .map(|s| format!("adb error: {}", s)) + .unwrap_or_else(|_| "adb error was not utf-8".into()); + + return Err(DeviceError::Adb(message)); + } else { + return Err(DeviceError::Adb("FAIL (unknown)".to_owned())); + } + } + + Ok(()) + } + + pub fn pull_dir(&self, src: &UnixPath, dest_dir: &Path) -> Result<()> { + let src = src.to_path_buf(); + let dest_dir = dest_dir.to_path_buf(); + + for entry in self.list_dir(&src)? { + match entry.metadata { + RemoteMetadata::RemoteSymlink => {} // Ignored. + RemoteMetadata::RemoteDir => { + let mut d = dest_dir.clone(); + d.push(&entry.name); + + std::fs::create_dir_all(&d)?; + } + RemoteMetadata::RemoteFile(_) => { + let mut s = src.clone(); + s.push(&entry.name); + let mut d = dest_dir.clone(); + d.push(&entry.name); + + self.pull(&s, &mut File::create(d)?)?; + } + } + } + + Ok(()) + } + + pub fn push(&self, buffer: &mut dyn Read, dest: &UnixPath, mode: u32) -> Result<()> { + // Implement the ADB protocol to send a file to the device. + // The protocol consists of the following steps: + // * Send "host:transport" command with device serial + // * Send "sync:" command to initialize file transfer + // * Send "SEND" command with name and mode of the file + // * Send "DATA" command one or more times for the file content + // * Send "DONE" command to indicate end of file transfer + + let enable_run_as = self.enable_run_as_for_path(&dest.to_path_buf()); + let dest1 = match enable_run_as { + true => self.tempfile.as_path(), + false => UnixPath::new(dest), + }; + + // If the destination directory does not exist, adb will + // create it and any necessary ancestors however it will not + // set the directory permissions to 0o777. In addition, + // Android 9 (P) has a bug in its push implementation which + // will cause a push which creates directories to fail with + // the error `secure_mkdirs failed: Operation not + // permitted`. We can work around this by creating the + // destination directories prior to the push. Collect the + // ancestors of the destination directory which do not yet + // exist so we can create them and adjust their permissions + // prior to performing the push. + let mut current = dest.parent(); + let mut leaf: Option<&UnixPath> = None; + let mut root: Option<&UnixPath> = None; + + while let Some(path) = current { + if self.path_exists(path, enable_run_as)? { + break; + } + if leaf.is_none() { + leaf = Some(path); + } + root = Some(path); + current = path.parent(); + } + + if let Some(path) = leaf { + self.create_dir(path)?; + } + + if let Some(path) = root { + self.chmod(path, "777", true)?; + } + + let mut stream = self.host.connect()?; + + let message = encode_message(&format!("host:transport:{}", self.serial))?; + stream.write_all(message.as_bytes())?; + let _bytes = read_response(&mut stream, false, true)?; + + let message = encode_message("sync:")?; + stream.write_all(message.as_bytes())?; + let _bytes = read_response(&mut stream, false, true)?; + + stream.write_all(SyncCommand::Send.code())?; + let args_ = format!("{},{}", dest1.display(), mode); + let args = args_.as_bytes(); + write_length_little_endian(&mut stream, args.len())?; + stream.write_all(args)?; + + // Use a 32KB buffer to transfer the file contents + // TODO: Maybe adjust to maxdata (256KB) + let mut buf = [0; 32 * 1024]; + + loop { + let len = buffer.read(&mut buf)?; + + if len == 0 { + break; + } + + stream.write_all(SyncCommand::Data.code())?; + write_length_little_endian(&mut stream, len)?; + stream.write_all(&buf[0..len])?; + } + + // https://android.googlesource.com/platform/system/core/+/master/adb/SYNC.TXT#66 + // + // When the file is transferred a sync request "DONE" is sent, where length is set + // to the last modified time for the file. The server responds to this last + // request (but not to chunk requests) with an "OKAY" sync response (length can + // be ignored). + let time: u32 = ((SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)) + .unwrap() + .as_secs() + & 0xFFFF_FFFF) as u32; + + stream.write_all(SyncCommand::Done.code())?; + write_length_little_endian(&mut stream, time as usize)?; + + // Status. + stream.read_exact(&mut buf[0..4])?; + + if buf.starts_with(SyncCommand::Okay.code()) { + if enable_run_as { + // Use cp -a to preserve the permissions set by push. + let result = self.execute_host_shell_command_as( + format!("cp -aR {} {}", dest1.display(), dest.display()).as_str(), + enable_run_as, + ); + if self.remove(dest1).is_err() { + warn!("Failed to remove {}", dest1.display()); + } + result?; + } + Ok(()) + } else if buf.starts_with(SyncCommand::Fail.code()) { + if enable_run_as && self.remove(dest1).is_err() { + warn!("Failed to remove {}", dest1.display()); + } + let n = buf.len().min(read_length_little_endian(&mut stream)?); + + stream.read_exact(&mut buf[0..n])?; + + let message = std::str::from_utf8(&buf[0..n]) + .map(|s| format!("adb error: {}", s)) + .unwrap_or_else(|_| "adb error was not utf-8".into()); + + Err(DeviceError::Adb(message)) + } else { + if self.remove(dest1).is_err() { + warn!("Failed to remove {}", dest1.display()); + } + Err(DeviceError::Adb("FAIL (unknown)".to_owned())) + } + } + + pub fn push_dir(&self, source: &Path, dest_dir: &UnixPath, mode: u32) -> Result<()> { + debug!("Pushing {} to {}", source.display(), dest_dir.display()); + + let walker = WalkDir::new(source).follow_links(false).into_iter(); + + for entry in walker { + let entry = entry?; + let path = entry.path(); + + if !entry.metadata()?.is_file() { + continue; + } + + let mut file = File::open(path)?; + + let tail = path + .strip_prefix(source) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; + + let dest = append_components(dest_dir, tail)?; + self.push(&mut file, &dest, mode)?; + } + + Ok(()) + } + + pub fn remove(&self, path: &UnixPath) -> Result<()> { + debug!("Deleting {}", path.display()); + + self.execute_host_shell_command_as( + &format!("rm -rf {}", path.display()), + self.enable_run_as_for_path(path), + )?; + + Ok(()) + } +} + +pub(crate) fn append_components( + base: &UnixPath, + tail: &Path, +) -> std::result::Result<UnixPathBuf, io::Error> { + let mut buf = base.to_path_buf(); + + for component in tail.components() { + if let Component::Normal(segment) = component { + let utf8 = segment.to_str().ok_or_else(|| { + io::Error::new( + io::ErrorKind::Other, + "Could not represent path segment as UTF-8", + ) + })?; + buf.push(utf8); + } else { + return Err(io::Error::new( + io::ErrorKind::Other, + "Unexpected path component".to_owned(), + )); + } + } + + Ok(buf) +} diff --git a/testing/mozbase/rust/mozdevice/src/shell.rs b/testing/mozbase/rust/mozdevice/src/shell.rs new file mode 100644 index 0000000000..55a71c41d5 --- /dev/null +++ b/testing/mozbase/rust/mozdevice/src/shell.rs @@ -0,0 +1,66 @@ +// Copyright (c) 2017 Jimmy Cuadra +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +use regex::Regex; + +/// Escapes a string so it will be interpreted as a single word by the UNIX Bourne shell. +/// +/// If the input string is empty, this function returns an empty quoted string. +pub fn escape(input: &str) -> String { + // Stolen from + // https://docs.rs/shellwords/1.0.0/src/shellwords/lib.rs.html#24-37. + // Added space to the pattern to exclude spaces from being escaped + // which can cause problems when combining strings to form a full + // command. + let escape_pattern: Regex = Regex::new(r"([^A-Za-z0-9_\-.,:/@ \n])").unwrap(); + + if input.is_empty() { + return "''".to_owned(); + } + + let output = &escape_pattern.replace_all(input, "\\$1"); + + output.replace("'\n'", r"\n") +} + +#[cfg(test)] +mod tests { + use super::escape; + + #[test] + fn empty_escape() { + assert_eq!(escape(""), "''"); + } + + #[test] + fn full_escape() { + assert_eq!(escape("foo '\"' bar"), "foo \\'\\\"\\' bar"); + } + + #[test] + fn escape_multibyte() { + assert_eq!(escape("あい"), "\\あ\\い"); + } + + #[test] + fn escape_newline() { + assert_eq!(escape(r"'\n'"), "\\\'\\\\n\\\'"); + } +} diff --git a/testing/mozbase/rust/mozdevice/src/test.rs b/testing/mozbase/rust/mozdevice/src/test.rs new file mode 100644 index 0000000000..b4173e3649 --- /dev/null +++ b/testing/mozbase/rust/mozdevice/src/test.rs @@ -0,0 +1,760 @@ +/* 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/. */ + +// Currently the mozdevice API is not safe for multiple requests at the same +// time. It is recommended to run each of the unit tests on its own. Also adb +// specific tests cannot be run in CI yet. To check those locally, also run +// the ignored tests. +// +// Use the following command to accomplish that: +// +// $ cargo test -- --ignored --test-threads=1 + +use crate::*; + +use std::collections::BTreeSet; +use std::panic; +use std::path::PathBuf; +use tempfile::{tempdir, TempDir}; + +#[test] +fn read_length_from_valid_string() { + fn test(message: &str) -> Result<usize> { + read_length(&mut io::BufReader::new(message.as_bytes())) + } + + assert_eq!(test("0000").unwrap(), 0); + assert_eq!(test("0001").unwrap(), 1); + assert_eq!(test("000F").unwrap(), 15); + assert_eq!(test("00FF").unwrap(), 255); + assert_eq!(test("0FFF").unwrap(), 4095); + assert_eq!(test("FFFF").unwrap(), 65535); + + assert_eq!(test("FFFF0").unwrap(), 65535); +} + +#[test] +fn read_length_from_invalid_string() { + fn test(message: &str) -> Result<usize> { + read_length(&mut io::BufReader::new(message.as_bytes())) + } + + test("").expect_err("empty string"); + test("G").expect_err("invalid hex character"); + test("-1").expect_err("negative number"); + test("000").expect_err("shorter than 4 bytes"); +} + +#[test] +fn encode_message_with_valid_string() { + assert_eq!(encode_message("").unwrap(), "0000".to_string()); + assert_eq!(encode_message("a").unwrap(), "0001a".to_string()); + assert_eq!( + encode_message(&"a".repeat(15)).unwrap(), + format!("000F{}", "a".repeat(15)) + ); + assert_eq!( + encode_message(&"a".repeat(255)).unwrap(), + format!("00FF{}", "a".repeat(255)) + ); + assert_eq!( + encode_message(&"a".repeat(4095)).unwrap(), + format!("0FFF{}", "a".repeat(4095)) + ); + assert_eq!( + encode_message(&"a".repeat(65535)).unwrap(), + format!("FFFF{}", "a".repeat(65535)) + ); +} + +#[test] +fn encode_message_with_invalid_string() { + encode_message(&"a".repeat(65536)).expect_err("string lengths exceeds 4 bytes"); +} + +fn run_device_test<F>(test: F) +where + F: FnOnce(&Device, &TempDir, &UnixPath) + panic::UnwindSafe, +{ + let host = Host { + ..Default::default() + }; + let device = host + .device_or_default::<String>(None, AndroidStorageInput::Auto) + .expect("device_or_default"); + + let tmp_dir = tempdir().expect("create temp dir"); + let response = device + .execute_host_shell_command("echo $EXTERNAL_STORAGE") + .unwrap(); + let mut test_root = UnixPathBuf::from(response.trim_end_matches('\n')); + test_root.push("mozdevice"); + + let _ = device.remove(&test_root); + + let result = panic::catch_unwind(|| test(&device, &tmp_dir, &test_root)); + + let _ = device.kill_forward_all_ports(); + // let _ = device.kill_reverse_all_ports(); + + assert!(result.is_ok()) +} + +#[test] +#[ignore] +fn host_features() { + let host = Host { + ..Default::default() + }; + + let set = host.features::<BTreeSet<_>>().expect("to query features"); + assert!(set.contains("cmd")); + assert!(set.contains("shell_v2")); +} + +#[test] +#[ignore] +fn host_devices() { + let host = Host { + ..Default::default() + }; + + let set: BTreeSet<_> = host.devices().expect("to query devices"); + assert_eq!(1, set.len()); +} + +#[test] +#[ignore] +fn host_device_or_default() { + let host = Host { + ..Default::default() + }; + + let devices: Vec<_> = host.devices().expect("to query devices"); + let expected_device = devices.first().expect("found a device"); + + let device = host + .device_or_default::<String>(Some(&expected_device.serial), AndroidStorageInput::App) + .expect("connected device with serial"); + assert_eq!(device.run_as_package, None); + assert_eq!(device.serial, expected_device.serial); + assert!(device.tempfile.starts_with("/data/local/tmp")); +} + +#[test] +#[ignore] +fn host_device_or_default_invalid_serial() { + let host = Host { + ..Default::default() + }; + + host.device_or_default::<String>(Some(&"foobar".to_owned()), AndroidStorageInput::Auto) + .expect_err("invalid serial"); +} + +#[test] +#[ignore] +fn host_device_or_default_no_serial() { + let host = Host { + ..Default::default() + }; + + let devices: Vec<_> = host.devices().expect("to query devices"); + let expected_device = devices.first().expect("found a device"); + + let device = host + .device_or_default::<String>(None, AndroidStorageInput::Auto) + .expect("connected device with serial"); + assert_eq!(device.serial, expected_device.serial); +} + +#[test] +#[ignore] +fn host_device_or_default_storage_as_app() { + let host = Host { + ..Default::default() + }; + + let device = host + .device_or_default::<String>(None, AndroidStorageInput::App) + .expect("connected device"); + assert_eq!(device.storage, AndroidStorage::App); +} + +#[test] +#[ignore] +fn host_device_or_default_storage_as_auto() { + let host = Host { + ..Default::default() + }; + + let device = host + .device_or_default::<String>(None, AndroidStorageInput::Auto) + .expect("connected device"); + assert_eq!(device.storage, AndroidStorage::Sdcard); +} + +#[test] +#[ignore] +fn host_device_or_default_storage_as_internal() { + let host = Host { + ..Default::default() + }; + + let device = host + .device_or_default::<String>(None, AndroidStorageInput::Internal) + .expect("connected device"); + assert_eq!(device.storage, AndroidStorage::Internal); +} + +#[test] +#[ignore] +fn host_device_or_default_storage_as_sdcard() { + let host = Host { + ..Default::default() + }; + + let device = host + .device_or_default::<String>(None, AndroidStorageInput::Sdcard) + .expect("connected device"); + assert_eq!(device.storage, AndroidStorage::Sdcard); +} + +#[test] +#[ignore] +fn device_shell_command() { + run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| { + assert_eq!( + "Linux\n", + device + .execute_host_shell_command("uname") + .expect("to have shell output") + ); + }); +} + +#[test] +#[ignore] +fn device_forward_port_hardcoded() { + run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| { + assert_eq!( + 3035, + device + .forward_port(3035, 3036) + .expect("forwarded local port") + ); + // TODO: check with forward --list + }); +} + +// #[test] +// #[ignore] +// TODO: "adb server response to `forward tcp:0 ...` was not a u16: \"000559464\"") +// fn device_forward_port_system_allocated() { +// run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| { +// let local_port = device.forward_port(0, 3037).expect("local_port"); +// assert_ne!(local_port, 0); +// // TODO: check with forward --list +// }); +// } + +#[test] +#[ignore] +fn device_kill_forward_port_no_forwarded_port() { + run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| { + device + .kill_forward_port(3038) + .expect_err("adb error: listener 'tcp:3038' "); + }); +} + +#[test] +#[ignore] +fn device_kill_forward_port_twice() { + run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| { + let local_port = device + .forward_port(3039, 3040) + .expect("forwarded local port"); + assert_eq!(local_port, 3039); + // TODO: check with forward --list + device + .kill_forward_port(local_port) + .expect("to remove forwarded port"); + device + .kill_forward_port(local_port) + .expect_err("adb error: listener 'tcp:3039' "); + }); +} + +#[test] +#[ignore] +fn device_kill_forward_all_ports_no_forwarded_port() { + run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| { + device + .kill_forward_all_ports() + .expect("to not fail for no forwarded ports"); + }); +} + +#[test] +#[ignore] +fn device_kill_forward_all_ports_twice() { + run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| { + let local_port1 = device + .forward_port(3039, 3040) + .expect("forwarded local port"); + assert_eq!(local_port1, 3039); + let local_port2 = device + .forward_port(3041, 3042) + .expect("forwarded local port"); + assert_eq!(local_port2, 3041); + // TODO: check with forward --list + device + .kill_forward_all_ports() + .expect("to remove all forwarded ports"); + device + .kill_forward_all_ports() + .expect("to not fail for no forwarded ports"); + }); +} + +#[test] +#[ignore] +fn device_reverse_port_hardcoded() { + run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| { + assert_eq!(4035, device.reverse_port(4035, 4036).expect("remote_port")); + // TODO: check with reverse --list + }); +} + +// #[test] +// #[ignore] +// TODO: No adb response: ParseInt(ParseIntError { kind: Empty }) +// fn device_reverse_port_system_allocated() { +// run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| { +// let reverse_port = device.reverse_port(0, 4037).expect("remote port"); +// assert_ne!(reverse_port, 0); +// // TODO: check with reverse --list +// }); +// } + +#[test] +#[ignore] +fn device_kill_reverse_port_no_reverse_port() { + run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| { + device + .kill_reverse_port(4038) + .expect_err("listener 'tcp:4038' not found"); + }); +} + +// #[test] +// #[ignore] +// TODO: "adb error: adb server response did not contain expected hexstring length: \"\"" +// fn device_kill_reverse_port_twice() { +// run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| { +// let remote_port = device +// .reverse_port(4039, 4040) +// .expect("reversed local port"); +// assert_eq!(remote_port, 4039); +// // TODO: check with reverse --list +// device +// .kill_reverse_port(remote_port) +// .expect("to remove reverse port"); +// device +// .kill_reverse_port(remote_port) +// .expect_err("listener 'tcp:4039' not found"); +// }); +// } + +#[test] +#[ignore] +fn device_kill_reverse_all_ports_no_reversed_port() { + run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| { + device + .kill_reverse_all_ports() + .expect("to not fail for no reversed ports"); + }); +} + +#[test] +#[ignore] +fn device_kill_reverse_all_ports_twice() { + run_device_test(|device: &Device, _: &TempDir, _: &UnixPath| { + let local_port1 = device + .forward_port(4039, 4040) + .expect("forwarded local port"); + assert_eq!(local_port1, 4039); + let local_port2 = device + .forward_port(4041, 4042) + .expect("forwarded local port"); + assert_eq!(local_port2, 4041); + // TODO: check with reverse --list + device + .kill_reverse_all_ports() + .expect("to remove all reversed ports"); + device + .kill_reverse_all_ports() + .expect("to not fail for no reversed ports"); + }); +} + +#[test] +#[ignore] +fn device_push_pull_text_file() { + run_device_test( + |device: &Device, _: &TempDir, remote_root_path: &UnixPath| { + let content = "test"; + let remote_path = remote_root_path.join("foo.txt"); + + device + .push( + &mut io::BufReader::new(content.as_bytes()), + &remote_path, + 0o777, + ) + .expect("file has been pushed"); + + let file_content = device + .execute_host_shell_command(&format!("cat {}", remote_path.display())) + .expect("host shell command for 'cat' to succeed"); + + assert_eq!(file_content, content); + + // And as second step pull it off the device. + let mut buffer = Vec::new(); + device + .pull(&remote_path, &mut buffer) + .expect("file has been pulled"); + assert_eq!(buffer, content.as_bytes()); + }, + ); +} + +#[test] +#[ignore] +fn device_push_pull_large_binary_file() { + run_device_test( + |device: &Device, _: &TempDir, remote_root_path: &UnixPath| { + let remote_path = remote_root_path.join("foo.binary"); + + let mut content = Vec::new(); + + // Needs to be larger than 64kB to test multiple chunks. + for i in 0..100000u32 { + content.push('0' as u8 + (i % 10) as u8); + } + + device + .push( + &mut std::io::Cursor::new(content.clone()), + &remote_path, + 0o777, + ) + .expect("large file has been pushed"); + + let output = device + .execute_host_shell_command(&format!("ls -l {}", remote_path.display())) + .expect("host shell command for 'ls' to succeed"); + + assert!(output.contains(remote_path.to_str().unwrap())); + + let mut buffer = Vec::new(); + + device + .pull(&remote_path, &mut buffer) + .expect("large binary file has been pulled"); + assert_eq!(buffer, content); + }, + ); +} + +#[test] +#[ignore] +fn device_push_permission() { + run_device_test( + |device: &Device, _: &TempDir, remote_root_path: &UnixPath| { + fn adjust_mode(mode: u32) -> u32 { + // Adjust the mode by copying the user permissions to + // group and other as indicated in + // [send_impl](https://android.googlesource.com/platform/system/core/+/master/adb/daemon/file_sync_service.cpp#516). + // This ensures that group and other can both access a + // file if the user can access it. + let mut m = mode & 0o777; + m |= (m >> 3) & 0o070; + m |= (m >> 3) & 0o007; + m + } + + fn get_permissions(mode: u32) -> String { + // Convert the mode integer into the string representation + // of the mode returned by `ls`. This assumes the object is + // a file and not a directory. + let mut perms = vec!["-", "r", "w", "x", "r", "w", "x", "r", "w", "x"]; + let mut bit_pos = 0; + while bit_pos < 9 { + if (1 << bit_pos) & mode == 0 { + perms[9 - bit_pos] = "-" + } + bit_pos += 1; + } + perms.concat() + } + let content = "test"; + let remote_path = remote_root_path.join("foo.bar"); + + // First push the file to the device + let modes = vec![0o421, 0o644, 0o666, 0o777]; + for mode in modes { + let adjusted_mode = adjust_mode(mode); + let adjusted_perms = get_permissions(adjusted_mode); + device + .push( + &mut io::BufReader::new(content.as_bytes()), + &remote_path, + mode, + ) + .expect("file has been pushed"); + + let output = device + .execute_host_shell_command(&format!("ls -l {}", remote_path.display())) + .expect("host shell command for 'ls' to succeed"); + + assert!(output.contains(remote_path.to_str().unwrap())); + assert!(output.starts_with(&adjusted_perms)); + } + + let output = device + .execute_host_shell_command(&format!("ls -ld {}", remote_root_path.display())) + .expect("host shell command for 'ls parent' to succeed"); + + assert!(output.contains(remote_root_path.to_str().unwrap())); + assert!(output.starts_with("drwxrwxrwx")); + }, + ); +} + +#[test] +#[ignore] +fn device_pull_fails_for_missing_file() { + run_device_test( + |device: &Device, _: &TempDir, remote_root_path: &UnixPath| { + let mut buffer = Vec::new(); + + device + .pull(&remote_root_path.join("missing"), &mut buffer) + .expect_err("missing file should not be pulled"); + }, + ); +} + +#[test] +#[ignore] +fn device_push_and_list_dir() { + run_device_test( + |device: &Device, tmp_dir: &TempDir, remote_root_path: &UnixPath| { + let files = ["foo1.bar", "foo2.bar", "bar/foo3.bar", "bar/more/foo3.bar"]; + + for file in files.iter() { + let path = tmp_dir.path().join(Path::new(file)); + let _ = std::fs::create_dir_all(path.parent().unwrap()); + + let f = File::create(path).expect("to create file"); + let mut f = io::BufWriter::new(f); + f.write_all(file.as_bytes()).expect("to write data"); + } + + device + .push_dir(tmp_dir.path(), &remote_root_path, 0o777) + .expect("to push_dir"); + + for file in files.iter() { + let path = append_components(remote_root_path, Path::new(file)).unwrap(); + let output = device + .execute_host_shell_command(&format!("ls {}", path.display())) + .expect("host shell command for 'ls' to succeed"); + + assert!(output.contains(path.to_str().unwrap())); + } + + let mut listings = device.list_dir(&remote_root_path).expect("to list_dir"); + listings.sort(); + assert_eq!( + listings, + vec![ + RemoteDirEntry { + depth: 0, + name: "foo1.bar".to_string(), + metadata: RemoteMetadata::RemoteFile(RemoteFileMetadata { + mode: 0b110110000, + size: 8 + }) + }, + RemoteDirEntry { + depth: 0, + name: "foo2.bar".to_string(), + metadata: RemoteMetadata::RemoteFile(RemoteFileMetadata { + mode: 0b110110000, + size: 8 + }) + }, + RemoteDirEntry { + depth: 0, + name: "bar".to_string(), + metadata: RemoteMetadata::RemoteDir + }, + RemoteDirEntry { + depth: 1, + name: "bar/foo3.bar".to_string(), + metadata: RemoteMetadata::RemoteFile(RemoteFileMetadata { + mode: 0b110110000, + size: 12 + }) + }, + RemoteDirEntry { + depth: 1, + name: "bar/more".to_string(), + metadata: RemoteMetadata::RemoteDir + }, + RemoteDirEntry { + depth: 2, + name: "bar/more/foo3.bar".to_string(), + metadata: RemoteMetadata::RemoteFile(RemoteFileMetadata { + mode: 0b110110000, + size: 17 + }) + } + ] + ); + }, + ); +} + +#[test] +#[ignore] +fn device_push_and_pull_dir() { + run_device_test( + |device: &Device, tmp_dir: &TempDir, remote_root_path: &UnixPath| { + let files = ["foo1.bar", "foo2.bar", "bar/foo3.bar", "bar/more/foo3.bar"]; + + let src_dir = tmp_dir.path().join(Path::new("src")); + let dest_dir = tmp_dir.path().join(Path::new("src")); + + for file in files.iter() { + let path = src_dir.join(Path::new(file)); + let _ = std::fs::create_dir_all(path.parent().unwrap()); + + let f = File::create(path).expect("to create file"); + let mut f = io::BufWriter::new(f); + f.write_all(file.as_bytes()).expect("to write data"); + } + + device + .push_dir(&src_dir, &remote_root_path, 0o777) + .expect("to push_dir"); + + device + .pull_dir(remote_root_path, &dest_dir) + .expect("to pull_dir"); + + for file in files.iter() { + let path = dest_dir.join(Path::new(file)); + let mut f = File::open(path).expect("to open file"); + let mut buf = String::new(); + f.read_to_string(&mut buf).expect("to read content"); + assert_eq!(buf, *file); + } + }, + ) +} + +#[test] +#[ignore] +fn device_push_and_list_dir_flat() { + run_device_test( + |device: &Device, tmp_dir: &TempDir, remote_root_path: &UnixPath| { + let content = "test"; + + let files = [ + PathBuf::from("foo1.bar"), + PathBuf::from("foo2.bar"), + PathBuf::from("bar").join("foo3.bar"), + ]; + + for file in files.iter() { + let path = tmp_dir.path().join(&file); + let _ = std::fs::create_dir_all(path.parent().unwrap()); + + let f = File::create(path).expect("to create file"); + let mut f = io::BufWriter::new(f); + f.write_all(content.as_bytes()).expect("to write data"); + } + + device + .push_dir(tmp_dir.path(), &remote_root_path, 0o777) + .expect("to push_dir"); + + for file in files.iter() { + let path = append_components(remote_root_path, file).unwrap(); + let output = device + .execute_host_shell_command(&format!("ls {}", path.display())) + .expect("host shell command for 'ls' to succeed"); + + assert!(output.contains(path.to_str().unwrap())); + } + + let mut listings = device + .list_dir_flat(&remote_root_path, 7, "prefix".to_string()) + .expect("to list_dir_flat"); + listings.sort(); + assert_eq!( + listings, + vec![ + RemoteDirEntry { + depth: 7, + metadata: RemoteMetadata::RemoteFile(RemoteFileMetadata { + mode: 0b110110000, + size: 4 + }), + name: "prefix/foo1.bar".to_string(), + }, + RemoteDirEntry { + depth: 7, + metadata: RemoteMetadata::RemoteFile(RemoteFileMetadata { + mode: 0b110110000, + size: 4 + }), + name: "prefix/foo2.bar".to_string(), + }, + RemoteDirEntry { + depth: 7, + metadata: RemoteMetadata::RemoteDir, + name: "prefix/bar".to_string(), + }, + ] + ); + }, + ); +} + +#[test] +fn format_own_device_error_types() { + assert_eq!( + format!("{}", DeviceError::InvalidStorage), + "Invalid storage".to_string() + ); + assert_eq!( + format!("{}", DeviceError::MissingPackage), + "Missing package".to_string() + ); + assert_eq!( + format!("{}", DeviceError::MultipleDevices), + "Multiple Android devices online".to_string() + ); + + assert_eq!( + format!("{}", DeviceError::Adb("foo".to_string())), + "foo".to_string() + ); +} diff --git a/testing/mozbase/rust/mozprofile/Cargo.toml b/testing/mozbase/rust/mozprofile/Cargo.toml new file mode 100644 index 0000000000..efc0dc89ca --- /dev/null +++ b/testing/mozbase/rust/mozprofile/Cargo.toml @@ -0,0 +1,16 @@ +[package] +edition = "2021" +name = "mozprofile" +version = "0.9.2" +authors = ["Mozilla"] +description = "Library for working with Mozilla profiles." +keywords = [ + "firefox", + "mozilla", +] +license = "MPL-2.0" +repository = "https://hg.mozilla.org/mozilla-central/file/tip/testing/mozbase/rust/mozprofile" + +[dependencies] +tempfile = "3" +thiserror = "1" diff --git a/testing/mozbase/rust/mozprofile/fuzz/Cargo.toml b/testing/mozbase/rust/mozprofile/fuzz/Cargo.toml new file mode 100644 index 0000000000..53e116143c --- /dev/null +++ b/testing/mozbase/rust/mozprofile/fuzz/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "mozprofile-fuzz" +version = "0.0.0" +authors = ["Automatically generated"] +publish = false +edition = "2018" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" + +[dependencies.mozprofile] +path = ".." + +# Prevent this from interfering with workspaces +[workspace] +members = ["."] + +[[bin]] +name = "prefreader" +path = "fuzz_targets/prefreader.rs" +test = false +doc = false diff --git a/testing/mozbase/rust/mozprofile/fuzz/fuzz_targets/prefreader.rs b/testing/mozbase/rust/mozprofile/fuzz/fuzz_targets/prefreader.rs new file mode 100644 index 0000000000..824eb3c31e --- /dev/null +++ b/testing/mozbase/rust/mozprofile/fuzz/fuzz_targets/prefreader.rs @@ -0,0 +1,16 @@ +/* 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/. */ + +#![no_main] +use libfuzzer_sys::fuzz_target; +use std::io::Cursor; +extern crate mozprofile; + +fuzz_target!(|data: &[u8]| { + let buf = Vec::new(); + let mut out = Cursor::new(buf); + mozprofile::prefreader::parse(data).map(|parsed| { + mozprofile::prefreader::serialize(&parsed, &mut out); + }); +}); diff --git a/testing/mozbase/rust/mozprofile/src/lib.rs b/testing/mozbase/rust/mozprofile/src/lib.rs new file mode 100644 index 0000000000..346f291137 --- /dev/null +++ b/testing/mozbase/rust/mozprofile/src/lib.rs @@ -0,0 +1,241 @@ +#![forbid(unsafe_code)] +/* 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/. */ + +extern crate tempfile; + +pub mod preferences; +pub mod prefreader; +pub mod profile; + +#[cfg(test)] +mod test { + // use std::fs::File; + // use profile::Profile; + use crate::preferences::Pref; + use crate::prefreader::{parse, serialize, tokenize}; + use crate::prefreader::{Position, PrefToken}; + use std::collections::BTreeMap; + use std::io::Cursor; + use std::str; + + #[test] + fn tokenize_simple() { + let prefs = " user_pref ( 'example.pref.string', 'value' ) ;\n \ + pref(\"example.pref.int\", -123); sticky_pref('example.pref.bool',false);"; + + let p = Position::new(); + + let expected = vec![ + PrefToken::UserPrefFunction(p), + PrefToken::Paren('(', p), + PrefToken::String("example.pref.string".into(), p), + PrefToken::Comma(p), + PrefToken::String("value".into(), p), + PrefToken::Paren(')', p), + PrefToken::Semicolon(p), + PrefToken::PrefFunction(p), + PrefToken::Paren('(', p), + PrefToken::String("example.pref.int".into(), p), + PrefToken::Comma(p), + PrefToken::Int(-123, p), + PrefToken::Paren(')', p), + PrefToken::Semicolon(p), + PrefToken::StickyPrefFunction(p), + PrefToken::Paren('(', p), + PrefToken::String("example.pref.bool".into(), p), + PrefToken::Comma(p), + PrefToken::Bool(false, p), + PrefToken::Paren(')', p), + PrefToken::Semicolon(p), + ]; + + tokenize_test(prefs, &expected); + } + + #[test] + fn tokenize_comments() { + let prefs = "# bash style comment\n /*block comment*/ user_pref/*block comment*/(/*block \ + comment*/ 'example.pref.string' /*block comment*/,/*block comment*/ \ + 'value'/*block comment*/ )// line comment"; + + let p = Position::new(); + + let expected = vec![ + PrefToken::CommentBashLine(" bash style comment".into(), p), + PrefToken::CommentBlock("block comment".into(), p), + PrefToken::UserPrefFunction(p), + PrefToken::CommentBlock("block comment".into(), p), + PrefToken::Paren('(', p), + PrefToken::CommentBlock("block comment".into(), p), + PrefToken::String("example.pref.string".into(), p), + PrefToken::CommentBlock("block comment".into(), p), + PrefToken::Comma(p), + PrefToken::CommentBlock("block comment".into(), p), + PrefToken::String("value".into(), p), + PrefToken::CommentBlock("block comment".into(), p), + PrefToken::Paren(')', p), + PrefToken::CommentLine(" line comment".into(), p), + ]; + + tokenize_test(prefs, &expected); + } + + #[test] + fn tokenize_escapes() { + let prefs = r#"user_pref('example\x20pref', "\u0020\u2603\uD800\uDC96\"\'\n\r\\\w)"#; + + let p = Position::new(); + + let expected = vec![ + PrefToken::UserPrefFunction(p), + PrefToken::Paren('(', p), + PrefToken::String("example pref".into(), p), + PrefToken::Comma(p), + PrefToken::String(" ☃𐂖\"'\n\r\\\\w".into(), p), + PrefToken::Paren(')', p), + ]; + + tokenize_test(prefs, &expected); + } + + fn tokenize_test(prefs: &str, expected: &[PrefToken]) { + println!("{}\n", prefs); + + for (e, a) in expected.iter().zip(tokenize(prefs.as_bytes())) { + let success = match (e, &a) { + (&PrefToken::PrefFunction(_), &PrefToken::PrefFunction(_)) => true, + (&PrefToken::UserPrefFunction(_), &PrefToken::UserPrefFunction(_)) => true, + (&PrefToken::StickyPrefFunction(_), &PrefToken::StickyPrefFunction(_)) => true, + ( + &PrefToken::CommentBlock(ref data_e, _), + &PrefToken::CommentBlock(ref data_a, _), + ) => data_e == data_a, + ( + &PrefToken::CommentLine(ref data_e, _), + &PrefToken::CommentLine(ref data_a, _), + ) => data_e == data_a, + ( + &PrefToken::CommentBashLine(ref data_e, _), + &PrefToken::CommentBashLine(ref data_a, _), + ) => data_e == data_a, + (&PrefToken::Paren(data_e, _), &PrefToken::Paren(data_a, _)) => data_e == data_a, + (&PrefToken::Semicolon(_), &PrefToken::Semicolon(_)) => true, + (&PrefToken::Comma(_), &PrefToken::Comma(_)) => true, + (&PrefToken::String(ref data_e, _), &PrefToken::String(ref data_a, _)) => { + data_e == data_a + } + (&PrefToken::Int(data_e, _), &PrefToken::Int(data_a, _)) => data_e == data_a, + (&PrefToken::Bool(data_e, _), &PrefToken::Bool(data_a, _)) => data_e == data_a, + (&PrefToken::Error(ref data_e, _), &PrefToken::Error(ref data_a, _)) => { + *data_e == *data_a + } + (_, _) => false, + }; + if !success { + println!("Expected {:?}, got {:?}", e, a); + } + assert!(success); + } + } + + #[test] + fn parse_simple() { + let input = " user_pref /* block comment */ ( 'example.pref.string', 'value' ) ;\n \ + pref(\"example.pref.int\", -123); sticky_pref('example.pref.bool',false)"; + + let mut expected: BTreeMap<String, Pref> = BTreeMap::new(); + expected.insert("example.pref.string".into(), Pref::new("value")); + expected.insert("example.pref.int".into(), Pref::new(-123)); + expected.insert("example.pref.bool".into(), Pref::new_sticky(false)); + + parse_test(input, expected); + } + + #[test] + fn parse_escape() { + let input = r#"user_pref('example\\pref\"string', 'val\x20ue' )"#; + + let mut expected: BTreeMap<String, Pref> = BTreeMap::new(); + expected.insert("example\\pref\"string".into(), Pref::new("val ue")); + + parse_test(input, expected); + } + + #[test] + fn parse_empty() { + let inputs = ["", " ", "\n", "\n \n"]; + for input in inputs { + let expected: BTreeMap<String, Pref> = BTreeMap::new(); + parse_test(input, expected); + } + } + + #[test] + fn parse_newline() { + let inputs = vec!["\na", "\n\nfoo"]; + for input in inputs { + assert!(parse(input.as_bytes()).is_err()); + } + } + + #[test] + fn parse_minus() { + let inputs = ["pref(-", "user_pref(\"example.pref.int\", -);"]; + for input in inputs { + assert!(parse(input.as_bytes()).is_err()); + } + } + + #[test] + fn parse_boolean_eof() { + let inputs = vec!["pref(true", "pref(false", "pref(false,", "pref(false)"]; + for input in inputs { + assert!(parse(input.as_bytes()).is_err()); + } + } + + fn parse_test(input: &str, expected: BTreeMap<String, Pref>) { + match parse(input.as_bytes()) { + Ok(ref actual) => { + println!("Expected:\n{:?}\nActual\n{:?}", expected, actual); + assert_eq!(actual, &expected); + } + Err(e) => { + println!("{}", e); + assert!(false) + } + } + } + + #[test] + fn serialize_simple() { + let input = " user_pref /* block comment */ ( 'example.pref.string', 'value' ) ;\n \ + pref(\"example.pref.int\", -123); sticky_pref('example.pref.bool',false)"; + let expected = "sticky_pref(\"example.pref.bool\", false); +user_pref(\"example.pref.int\", -123); +user_pref(\"example.pref.string\", \"value\");\n"; + + serialize_test(input, expected); + } + + #[test] + fn serialize_quotes() { + let input = r#"user_pref('example\\with"quotes"', '"Value"')"#; + let expected = r#"user_pref("example\\with\"quotes\"", "\"Value\""); +"#; + + serialize_test(input, expected); + } + + fn serialize_test(input: &str, expected: &str) { + let buf = Vec::with_capacity(expected.len()); + let mut out = Cursor::new(buf); + serialize(&parse(input.as_bytes()).unwrap(), &mut out).unwrap(); + let data = out.into_inner(); + let actual = str::from_utf8(&*data).unwrap(); + println!("Expected:\n{:?}\nActual\n{:?}", expected, actual); + assert_eq!(actual, expected); + } +} diff --git a/testing/mozbase/rust/mozprofile/src/preferences.rs b/testing/mozbase/rust/mozprofile/src/preferences.rs new file mode 100644 index 0000000000..2489352384 --- /dev/null +++ b/testing/mozbase/rust/mozprofile/src/preferences.rs @@ -0,0 +1,138 @@ +/* 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/. */ + +use std::collections::BTreeMap; + +pub type Preferences = BTreeMap<String, Pref>; + +#[derive(Debug, PartialEq, Clone)] +pub enum PrefValue { + Bool(bool), + String(String), + Int(i64), +} + +impl From<bool> for PrefValue { + fn from(value: bool) -> Self { + PrefValue::Bool(value) + } +} + +impl From<String> for PrefValue { + fn from(value: String) -> Self { + PrefValue::String(value) + } +} + +impl From<&'static str> for PrefValue { + fn from(value: &'static str) -> Self { + PrefValue::String(value.into()) + } +} + +impl From<i8> for PrefValue { + fn from(value: i8) -> Self { + PrefValue::Int(value.into()) + } +} + +impl From<u8> for PrefValue { + fn from(value: u8) -> Self { + PrefValue::Int(value.into()) + } +} + +impl From<i16> for PrefValue { + fn from(value: i16) -> Self { + PrefValue::Int(value.into()) + } +} + +impl From<u16> for PrefValue { + fn from(value: u16) -> Self { + PrefValue::Int(value.into()) + } +} + +impl From<i32> for PrefValue { + fn from(value: i32) -> Self { + PrefValue::Int(value.into()) + } +} + +impl From<u32> for PrefValue { + fn from(value: u32) -> Self { + PrefValue::Int(value.into()) + } +} + +impl From<i64> for PrefValue { + fn from(value: i64) -> Self { + PrefValue::Int(value) + } +} + +// Implementing From<u64> for PrefValue wouldn't be safe +// because it might overflow. + +#[derive(Debug, PartialEq, Clone)] +pub struct Pref { + pub value: PrefValue, + pub sticky: bool, +} + +impl Pref { + /// Create a new preference with `value`. + pub fn new<T>(value: T) -> Pref + where + T: Into<PrefValue>, + { + Pref { + value: value.into(), + sticky: false, + } + } + + /// Create a new sticky, or locked, preference with `value`. + /// These cannot be changed by the user in `about:config`. + pub fn new_sticky<T>(value: T) -> Pref + where + T: Into<PrefValue>, + { + Pref { + value: value.into(), + sticky: true, + } + } +} + +#[cfg(test)] +mod test { + use super::PrefValue; + + #[test] + fn test_bool() { + assert_eq!(PrefValue::from(true), PrefValue::Bool(true)); + } + + #[test] + fn test_string() { + assert_eq!(PrefValue::from("foo"), PrefValue::String("foo".to_string())); + assert_eq!( + PrefValue::from("foo".to_string()), + PrefValue::String("foo".to_string()) + ); + } + + #[test] + fn test_int() { + assert_eq!(PrefValue::from(42i8), PrefValue::Int(42i64)); + assert_eq!(PrefValue::from(42u8), PrefValue::Int(42i64)); + assert_eq!(PrefValue::from(42i16), PrefValue::Int(42i64)); + assert_eq!(PrefValue::from(42u16), PrefValue::Int(42i64)); + assert_eq!(PrefValue::from(42i32), PrefValue::Int(42i64)); + assert_eq!(PrefValue::from(42u32), PrefValue::Int(42i64)); + assert_eq!(PrefValue::from(42i64), PrefValue::Int(42i64)); + } +} diff --git a/testing/mozbase/rust/mozprofile/src/prefreader.rs b/testing/mozbase/rust/mozprofile/src/prefreader.rs new file mode 100644 index 0000000000..9c94666e7d --- /dev/null +++ b/testing/mozbase/rust/mozprofile/src/prefreader.rs @@ -0,0 +1,1046 @@ +/* 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/. */ + +use crate::preferences::{Pref, PrefValue, Preferences}; +use std::borrow::Borrow; +use std::borrow::Cow; +use std::char; +use std::error::Error; +use std::io::{self, Write}; +use std::iter::Iterator; + +use std::str; +use thiserror::Error; + +impl PrefReaderError { + fn new(message: String, position: Position, parent: Option<Box<dyn Error>>) -> PrefReaderError { + PrefReaderError { + message, + position, + parent, + } + } +} + +impl From<io::Error> for PrefReaderError { + fn from(err: io::Error) -> PrefReaderError { + PrefReaderError::new("IOError".into(), Position::new(), Some(err.into())) + } +} + +#[derive(Copy, Clone, Debug, PartialEq)] +enum TokenizerState { + Junk, + CommentStart, + CommentLine, + CommentBlock, + FunctionName, + AfterFunctionName, + FunctionArgs, + FunctionArg, + DoubleQuotedString, + SingleQuotedString, + Number, + Bool, + AfterFunctionArg, + AfterFunction, + Error, +} + +#[derive(Copy, Clone, Debug, Default, PartialEq)] +pub struct Position { + line: u32, + column: u32, +} + +impl Position { + pub fn new() -> Position { + Position { line: 1, column: 0 } + } +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum TokenType { + None, + PrefFunction, + UserPrefFunction, + StickyPrefFunction, + CommentBlock, + CommentLine, + CommentBashLine, + Paren, + Semicolon, + Comma, + String, + Int, + Bool, + Error, +} + +#[derive(Debug, PartialEq)] +pub enum PrefToken<'a> { + PrefFunction(Position), + UserPrefFunction(Position), + StickyPrefFunction(Position), + CommentBlock(Cow<'a, str>, Position), + CommentLine(Cow<'a, str>, Position), + CommentBashLine(Cow<'a, str>, Position), + Paren(char, Position), + Semicolon(Position), + Comma(Position), + String(Cow<'a, str>, Position), + Int(i64, Position), + Bool(bool, Position), + Error(String, Position), +} + +impl<'a> PrefToken<'a> { + fn position(&self) -> Position { + match *self { + PrefToken::PrefFunction(position) => position, + PrefToken::UserPrefFunction(position) => position, + PrefToken::StickyPrefFunction(position) => position, + PrefToken::CommentBlock(_, position) => position, + PrefToken::CommentLine(_, position) => position, + PrefToken::CommentBashLine(_, position) => position, + PrefToken::Paren(_, position) => position, + PrefToken::Semicolon(position) => position, + PrefToken::Comma(position) => position, + PrefToken::String(_, position) => position, + PrefToken::Int(_, position) => position, + PrefToken::Bool(_, position) => position, + PrefToken::Error(_, position) => position, + } + } +} + +#[derive(Debug, Error)] +#[error("{message} at line {}, column {}", .position.line, .position.column)] +pub struct PrefReaderError { + message: String, + position: Position, + #[source] + parent: Option<Box<dyn Error>>, +} + +struct TokenData<'a> { + token_type: TokenType, + complete: bool, + position: Position, + data: Cow<'a, str>, + start_pos: usize, +} + +impl<'a> TokenData<'a> { + fn new(token_type: TokenType, position: Position, start_pos: usize) -> TokenData<'a> { + TokenData { + token_type, + complete: false, + position, + data: Cow::Borrowed(""), + start_pos, + } + } + + fn start(&mut self, tokenizer: &PrefTokenizer, token_type: TokenType) { + self.token_type = token_type; + self.position = tokenizer.position; + self.start_pos = tokenizer.pos; + } + + fn end(&mut self, buf: &'a [u8], end_pos: usize) -> Result<(), PrefReaderError> { + self.complete = true; + self.add_slice_to_token(buf, end_pos) + } + + fn add_slice_to_token(&mut self, buf: &'a [u8], end_pos: usize) -> Result<(), PrefReaderError> { + let data = match str::from_utf8(&buf[self.start_pos..end_pos]) { + Ok(x) => x, + Err(_) => { + return Err(PrefReaderError::new( + "Could not convert string to utf8".into(), + self.position, + None, + )); + } + }; + if self.data != "" { + self.data.to_mut().push_str(data) + } else { + self.data = Cow::Borrowed(data) + }; + Ok(()) + } + + fn push_char(&mut self, tokenizer: &PrefTokenizer, data: char) { + self.data.to_mut().push(data); + self.start_pos = tokenizer.pos + 1; + } +} + +pub struct PrefTokenizer<'a> { + data: &'a [u8], + pos: usize, + cur: Option<char>, + position: Position, + state: TokenizerState, + next_state: Option<TokenizerState>, +} + +impl<'a> PrefTokenizer<'a> { + pub fn new(data: &'a [u8]) -> PrefTokenizer<'a> { + PrefTokenizer { + data, + pos: 0, + cur: None, + position: Position::new(), + state: TokenizerState::Junk, + next_state: Some(TokenizerState::FunctionName), + } + } + + fn make_token(&mut self, token_data: TokenData<'a>) -> PrefToken<'a> { + let buf = token_data.data; + let position = token_data.position; + // Note: the panic! here are for cases where the invalid input is regarded as + // a bug in the caller. In cases where `make_token` can legitimately be called + // with invalid data we must instead return a PrefToken::Error + match token_data.token_type { + TokenType::None => panic!("Got a token without a type"), + TokenType::PrefFunction => PrefToken::PrefFunction(position), + TokenType::UserPrefFunction => PrefToken::UserPrefFunction(position), + TokenType::StickyPrefFunction => PrefToken::StickyPrefFunction(position), + TokenType::CommentBlock => PrefToken::CommentBlock(buf, position), + TokenType::CommentLine => PrefToken::CommentLine(buf, position), + TokenType::CommentBashLine => PrefToken::CommentBashLine(buf, position), + TokenType::Paren => { + if buf.len() != 1 { + panic!("Expected a buffer of length one"); + } + PrefToken::Paren(buf.chars().next().unwrap(), position) + } + TokenType::Semicolon => PrefToken::Semicolon(position), + TokenType::Comma => PrefToken::Comma(position), + TokenType::String => PrefToken::String(buf, position), + TokenType::Int => { + return match buf.parse::<i64>() { + Ok(value) => PrefToken::Int(value, position), + Err(_) => PrefToken::Error(format!("Expected integer, got {}", buf), position), + } + } + TokenType::Bool => { + let value = match buf.borrow() { + "true" => true, + "false" => false, + x => panic!("Boolean wasn't 'true' or 'false' (was {})", x), + }; + PrefToken::Bool(value, position) + } + TokenType::Error => panic!("make_token can't construct errors"), + } + } + + fn get_char(&mut self) -> Option<char> { + if self.pos + 1 >= self.data.len() { + self.cur = None; + return None; + }; + if self.cur.is_some() { + self.pos += 1; + } + let c = self.data[self.pos] as char; + if self.cur == Some('\n') { + self.position.line += 1; + self.position.column = 0; + } else if self.cur.is_some() { + self.position.column += 1; + }; + self.cur = Some(c); + self.cur + } + + fn unget_char(&mut self) -> Option<char> { + if self.pos == 0 { + self.position.column = 0; + self.cur = None + } else { + self.pos -= 1; + let c = self.data[self.pos] as char; + if c == '\n' { + self.position.line -= 1; + let mut col_pos = self.pos; + while col_pos > 0 { + col_pos -= 1; + if self.data[col_pos] as char == '\n' { + break; + } + } + self.position.column = (self.pos - col_pos) as u32; + } else { + self.position.column -= 1; + } + self.cur = Some(c); + } + self.cur + } + + fn is_space(c: char) -> bool { + matches!(c, ' ' | '\t' | '\r' | '\n') + } + + fn skip_whitespace(&mut self) -> Option<char> { + while let Some(c) = self.cur { + if PrefTokenizer::is_space(c) { + self.get_char(); + } else { + break; + }; + } + self.cur + } + + fn consume_escape(&mut self, token_data: &mut TokenData<'a>) -> Result<(), PrefReaderError> { + let pos = self.pos; + let escaped = self.read_escape()?; + if let Some(escape_char) = escaped { + token_data.add_slice_to_token(self.data, pos)?; + token_data.push_char(self, escape_char); + }; + Ok(()) + } + + fn read_escape(&mut self) -> Result<Option<char>, PrefReaderError> { + let escape_char = match self.get_char() { + Some('u') => self.read_hex_escape(4, true)?, + Some('x') => self.read_hex_escape(2, true)?, + Some('\\') => '\\' as u32, + Some('"') => '"' as u32, + Some('\'') => '\'' as u32, + Some('r') => '\r' as u32, + Some('n') => '\n' as u32, + Some(_) => return Ok(None), + None => { + return Err(PrefReaderError::new( + "EOF in character escape".into(), + self.position, + None, + )) + } + }; + Ok(Some(char::from_u32(escape_char).ok_or_else(|| { + PrefReaderError::new( + "Invalid codepoint decoded from escape".into(), + self.position, + None, + ) + })?)) + } + + fn read_hex_escape(&mut self, hex_chars: isize, first: bool) -> Result<u32, PrefReaderError> { + let mut value = 0; + for _ in 0..hex_chars { + match self.get_char() { + Some(x) => { + value <<= 4; + match x { + '0'..='9' => value += x as u32 - '0' as u32, + 'a'..='f' => value += x as u32 - 'a' as u32, + 'A'..='F' => value += x as u32 - 'A' as u32, + _ => { + return Err(PrefReaderError::new( + "Unexpected character in escape".into(), + self.position, + None, + )) + } + } + } + None => { + return Err(PrefReaderError::new( + "Unexpected EOF in escape".into(), + self.position, + None, + )) + } + } + } + if first && (0xD800..=0xDBFF).contains(&value) { + // First part of a surrogate pair + if self.get_char() != Some('\\') || self.get_char() != Some('u') { + return Err(PrefReaderError::new( + "Lone high surrogate in surrogate pair".into(), + self.position, + None, + )); + } + self.unget_char(); + let high_surrogate = value; + let low_surrogate = self.read_hex_escape(4, false)?; + let high_value = (high_surrogate - 0xD800) << 10; + let low_value = low_surrogate - 0xDC00; + value = high_value + low_value + 0x10000; + } else if first && (0xDC00..=0xDFFF).contains(&value) { + return Err(PrefReaderError::new( + "Lone low surrogate".into(), + self.position, + None, + )); + } else if !first && !(0xDC00..=0xDFFF).contains(&value) { + return Err(PrefReaderError::new( + "Invalid low surrogate in surrogate pair".into(), + self.position, + None, + )); + } + Ok(value) + } + + fn get_match(&mut self, target: &str, separators: &str) -> bool { + let initial_pos = self.pos; + let mut matched = true; + for c in target.chars() { + if self.cur == Some(c) { + self.get_char(); + } else { + matched = false; + break; + } + } + + if !matched { + for _ in 0..(self.pos - initial_pos) { + self.unget_char(); + } + } else { + // Check that the next character is whitespace or a separator + if let Some(c) = self.cur { + if !(PrefTokenizer::is_space(c) || separators.contains(c) || c == '/') { + matched = false; + } + self.unget_char(); + } + // Otherwise the token was followed by EOF. That's a valid match, but + // will presumably cause a parse error later. + } + + matched + } + + fn next_token(&mut self) -> Result<Option<TokenData<'a>>, PrefReaderError> { + let mut token_data = TokenData::new(TokenType::None, Position::new(), 0); + + loop { + let mut c = match self.get_char() { + Some(x) => x, + None => return Ok(None), + }; + + self.state = match self.state { + TokenizerState::Junk => { + c = match self.skip_whitespace() { + Some(x) => x, + None => return Ok(None), + }; + match c { + '/' => TokenizerState::CommentStart, + '#' => { + token_data.start(self, TokenType::CommentBashLine); + token_data.start_pos = self.pos + 1; + TokenizerState::CommentLine + } + _ => { + self.unget_char(); + let next = match self.next_state { + Some(x) => x, + None => { + return Err(PrefReaderError::new( + "In Junk state without a next state defined".into(), + self.position, + None, + )) + } + }; + self.next_state = None; + next + } + } + } + TokenizerState::CommentStart => match c { + '*' => { + token_data.start(self, TokenType::CommentBlock); + token_data.start_pos = self.pos + 1; + TokenizerState::CommentBlock + } + '/' => { + token_data.start(self, TokenType::CommentLine); + token_data.start_pos = self.pos + 1; + TokenizerState::CommentLine + } + _ => { + return Err(PrefReaderError::new( + "Invalid character after /".into(), + self.position, + None, + )) + } + }, + TokenizerState::CommentLine => match c { + '\n' => { + token_data.end(self.data, self.pos)?; + TokenizerState::Junk + } + _ => TokenizerState::CommentLine, + }, + TokenizerState::CommentBlock => match c { + '*' => { + if self.get_char() == Some('/') { + token_data.end(self.data, self.pos - 1)?; + TokenizerState::Junk + } else { + TokenizerState::CommentBlock + } + } + _ => TokenizerState::CommentBlock, + }, + TokenizerState::FunctionName => { + let position = self.position; + let start_pos = self.pos; + match c { + 'u' => { + if self.get_match("user_pref", "(") { + token_data.start(self, TokenType::UserPrefFunction); + } + } + 's' => { + if self.get_match("sticky_pref", "(") { + token_data.start(self, TokenType::StickyPrefFunction); + } + } + 'p' => { + if self.get_match("pref", "(") { + token_data.start(self, TokenType::PrefFunction); + } + } + _ => {} + }; + if token_data.token_type == TokenType::None { + // We didn't match anything + return Err(PrefReaderError::new( + "Expected a pref function name".into(), + position, + None, + )); + } else { + token_data.start_pos = start_pos; + token_data.position = position; + token_data.end(self.data, self.pos + 1)?; + self.next_state = Some(TokenizerState::AfterFunctionName); + TokenizerState::Junk + } + } + TokenizerState::AfterFunctionName => match c { + '(' => { + self.next_state = Some(TokenizerState::FunctionArgs); + token_data.start(self, TokenType::Paren); + token_data.end(self.data, self.pos + 1)?; + self.next_state = Some(TokenizerState::FunctionArgs); + TokenizerState::Junk + } + _ => { + return Err(PrefReaderError::new( + "Expected an opening paren".into(), + self.position, + None, + )) + } + }, + TokenizerState::FunctionArgs => match c { + ')' => { + token_data.start(self, TokenType::Paren); + token_data.end(self.data, self.pos + 1)?; + self.next_state = Some(TokenizerState::AfterFunction); + TokenizerState::Junk + } + _ => { + self.unget_char(); + TokenizerState::FunctionArg + } + }, + TokenizerState::FunctionArg => match c { + '"' => { + token_data.start(self, TokenType::String); + token_data.start_pos = self.pos + 1; + TokenizerState::DoubleQuotedString + } + '\'' => { + token_data.start(self, TokenType::String); + token_data.start_pos = self.pos + 1; + TokenizerState::SingleQuotedString + } + 't' | 'f' => { + self.unget_char(); + TokenizerState::Bool + } + '0'..='9' | '-' | '+' => { + token_data.start(self, TokenType::Int); + TokenizerState::Number + } + _ => { + return Err(PrefReaderError::new( + "Invalid character at start of function argument".into(), + self.position, + None, + )) + } + }, + TokenizerState::DoubleQuotedString => match c { + '"' => { + token_data.end(self.data, self.pos)?; + self.next_state = Some(TokenizerState::AfterFunctionArg); + TokenizerState::Junk + } + '\n' => { + return Err(PrefReaderError::new( + "EOL in double quoted string".into(), + self.position, + None, + )) + } + '\\' => { + self.consume_escape(&mut token_data)?; + TokenizerState::DoubleQuotedString + } + _ => TokenizerState::DoubleQuotedString, + }, + TokenizerState::SingleQuotedString => match c { + '\'' => { + token_data.end(self.data, self.pos)?; + self.next_state = Some(TokenizerState::AfterFunctionArg); + TokenizerState::Junk + } + '\n' => { + return Err(PrefReaderError::new( + "EOL in single quoted string".into(), + self.position, + None, + )) + } + '\\' => { + self.consume_escape(&mut token_data)?; + TokenizerState::SingleQuotedString + } + _ => TokenizerState::SingleQuotedString, + }, + TokenizerState::Number => match c { + '0'..='9' => TokenizerState::Number, + ')' | ',' => { + token_data.end(self.data, self.pos)?; + self.unget_char(); + self.next_state = Some(TokenizerState::AfterFunctionArg); + TokenizerState::Junk + } + x if PrefTokenizer::is_space(x) => { + token_data.end(self.data, self.pos)?; + self.next_state = Some(TokenizerState::AfterFunctionArg); + TokenizerState::Junk + } + _ => { + return Err(PrefReaderError::new( + "Invalid character in number literal".into(), + self.position, + None, + )) + } + }, + TokenizerState::Bool => { + let start_pos = self.pos; + let position = self.position; + match c { + 't' => { + if self.get_match("true", ",)") { + token_data.start(self, TokenType::Bool) + } + } + 'f' => { + if self.get_match("false", ",)") { + token_data.start(self, TokenType::Bool) + } + } + _ => {} + }; + if token_data.token_type == TokenType::None { + return Err(PrefReaderError::new( + "Unexpected characters in function argument".into(), + position, + None, + )); + } else { + token_data.start_pos = start_pos; + token_data.position = position; + token_data.end(self.data, self.pos + 1)?; + self.next_state = Some(TokenizerState::AfterFunctionArg); + TokenizerState::Junk + } + } + TokenizerState::AfterFunctionArg => match c { + ',' => { + token_data.start(self, TokenType::Comma); + token_data.end(self.data, self.pos + 1)?; + self.next_state = Some(TokenizerState::FunctionArg); + TokenizerState::Junk + } + ')' => { + token_data.start(self, TokenType::Paren); + token_data.end(self.data, self.pos + 1)?; + self.next_state = Some(TokenizerState::AfterFunction); + TokenizerState::Junk + } + _ => { + return Err(PrefReaderError::new( + "Unexpected character after function argument".into(), + self.position, + None, + )) + } + }, + TokenizerState::AfterFunction => match c { + ';' => { + token_data.start(self, TokenType::Semicolon); + token_data.end(self.data, self.pos)?; + self.next_state = Some(TokenizerState::FunctionName); + TokenizerState::Junk + } + _ => { + return Err(PrefReaderError::new( + "Unexpected character after function".into(), + self.position, + None, + )) + } + }, + TokenizerState::Error => TokenizerState::Error, + }; + if token_data.complete { + return Ok(Some(token_data)); + } + } + } +} + +impl<'a> Iterator for PrefTokenizer<'a> { + type Item = PrefToken<'a>; + + fn next(&mut self) -> Option<PrefToken<'a>> { + if let TokenizerState::Error = self.state { + return None; + } + let token_data = match self.next_token() { + Err(e) => { + self.state = TokenizerState::Error; + return Some(PrefToken::Error(e.message.clone(), e.position)); + } + Ok(Some(token_data)) => token_data, + Ok(None) => return None, + }; + let token = self.make_token(token_data); + Some(token) + } +} + +pub fn tokenize(data: &[u8]) -> PrefTokenizer { + PrefTokenizer::new(data) +} + +pub fn serialize_token<T: Write>(token: &PrefToken, output: &mut T) -> Result<(), PrefReaderError> { + let mut data_buf = String::new(); + + let data = match *token { + PrefToken::PrefFunction(_) => "pref", + PrefToken::UserPrefFunction(_) => "user_pref", + PrefToken::StickyPrefFunction(_) => "sticky_pref", + PrefToken::CommentBlock(ref data, _) => { + data_buf.reserve(data.len() + 4); + data_buf.push_str("/*"); + data_buf.push_str(data.borrow()); + data_buf.push('*'); + &*data_buf + } + PrefToken::CommentLine(ref data, _) => { + data_buf.reserve(data.len() + 2); + data_buf.push_str("//"); + data_buf.push_str(data.borrow()); + &*data_buf + } + PrefToken::CommentBashLine(ref data, _) => { + data_buf.reserve(data.len() + 1); + data_buf.push('#'); + data_buf.push_str(data.borrow()); + &*data_buf + } + PrefToken::Paren(data, _) => { + data_buf.push(data); + &*data_buf + } + PrefToken::Comma(_) => ",", + PrefToken::Semicolon(_) => ";\n", + PrefToken::String(ref data, _) => { + data_buf.reserve(data.len() + 2); + data_buf.push('"'); + data_buf.push_str(escape_quote(data.borrow()).borrow()); + data_buf.push('"'); + &*data_buf + } + PrefToken::Int(data, _) => { + data_buf.push_str(&data.to_string()); + &*data_buf + } + PrefToken::Bool(data, _) => { + if data { + "true" + } else { + "false" + } + } + PrefToken::Error(ref data, pos) => { + return Err(PrefReaderError::new(data.clone(), pos, None)) + } + }; + output.write_all(data.as_bytes())?; + Ok(()) +} + +pub fn serialize_tokens<'a, I, W>(tokens: I, output: &mut W) -> Result<(), PrefReaderError> +where + I: Iterator<Item = &'a PrefToken<'a>>, + W: Write, +{ + for token in tokens { + serialize_token(token, output)?; + } + Ok(()) +} + +fn escape_quote(data: &str) -> Cow<str> { + // Not very efficient… + if data.contains('"') || data.contains('\\') { + Cow::Owned(data.replace('\\', r"\\").replace('"', r#"\""#)) + } else { + Cow::Borrowed(data) + } +} + +#[derive(Debug, PartialEq)] +enum ParserState { + Function, + Key, + Value, +} + +struct PrefBuilder { + key: Option<String>, + value: Option<PrefValue>, + sticky: bool, +} + +impl PrefBuilder { + fn new() -> PrefBuilder { + PrefBuilder { + key: None, + value: None, + sticky: false, + } + } +} + +fn skip_comments<'a>(tokenizer: &mut PrefTokenizer<'a>) -> Option<PrefToken<'a>> { + loop { + match tokenizer.next() { + Some(PrefToken::CommentBashLine(_, _)) + | Some(PrefToken::CommentBlock(_, _)) + | Some(PrefToken::CommentLine(_, _)) => {} + Some(x) => return Some(x), + None => return None, + } + } +} + +pub fn parse_tokens(tokenizer: &mut PrefTokenizer<'_>) -> Result<Preferences, PrefReaderError> { + let mut state = ParserState::Function; + let mut current_pref = PrefBuilder::new(); + let mut rv = Preferences::new(); + + loop { + // Not just using a for loop here seems strange, but this restricts the + // scope of the borrow + let token = { + match tokenizer.next() { + Some(x) => x, + None => break, + } + }; + // First deal with comments and errors + match token { + PrefToken::Error(msg, position) => { + return Err(PrefReaderError::new(msg, position, None)); + } + PrefToken::CommentBashLine(_, _) + | PrefToken::CommentLine(_, _) + | PrefToken::CommentBlock(_, _) => continue, + _ => {} + } + state = match state { + ParserState::Function => { + match token { + PrefToken::PrefFunction(_) => { + current_pref.sticky = false; + } + PrefToken::UserPrefFunction(_) => { + current_pref.sticky = false; + } + PrefToken::StickyPrefFunction(_) => { + current_pref.sticky = true; + } + _ => { + return Err(PrefReaderError::new( + "Expected pref function".into(), + token.position(), + None, + )); + } + } + let next = skip_comments(tokenizer); + match next { + Some(PrefToken::Paren('(', _)) => ParserState::Key, + _ => { + return Err(PrefReaderError::new( + "Expected open paren".into(), + next.map(|x| x.position()).unwrap_or(tokenizer.position), + None, + )) + } + } + } + ParserState::Key => { + match token { + PrefToken::String(data, _) => current_pref.key = Some(data.into_owned()), + _ => { + return Err(PrefReaderError::new( + "Expected string".into(), + token.position(), + None, + )); + } + } + let next = skip_comments(tokenizer); + match next { + Some(PrefToken::Comma(_)) => ParserState::Value, + _ => { + return Err(PrefReaderError::new( + "Expected comma".into(), + next.map(|x| x.position()).unwrap_or(tokenizer.position), + None, + )) + } + } + } + ParserState::Value => { + match token { + PrefToken::String(data, _) => { + current_pref.value = Some(PrefValue::String(data.into_owned())) + } + PrefToken::Int(data, _) => current_pref.value = Some(PrefValue::Int(data)), + PrefToken::Bool(data, _) => current_pref.value = Some(PrefValue::Bool(data)), + _ => { + return Err(PrefReaderError::new( + "Expected value".into(), + token.position(), + None, + )) + } + } + let next = skip_comments(tokenizer); + match next { + Some(PrefToken::Paren(')', _)) => {} + _ => { + return Err(PrefReaderError::new( + "Expected close paren".into(), + next.map(|x| x.position()).unwrap_or(tokenizer.position), + None, + )) + } + } + let next = skip_comments(tokenizer); + match next { + Some(PrefToken::Semicolon(_)) | None => {} + _ => { + return Err(PrefReaderError::new( + "Expected semicolon".into(), + next.map(|x| x.position()).unwrap_or(tokenizer.position), + None, + )) + } + } + let key = current_pref.key.take(); + let value = current_pref.value.take(); + let pref = if current_pref.sticky { + Pref::new_sticky(value.unwrap()) + } else { + Pref::new(value.unwrap()) + }; + rv.insert(key.unwrap(), pref); + current_pref.sticky = false; + ParserState::Function + } + } + } + match state { + ParserState::Key | ParserState::Value => { + return Err(PrefReaderError::new( + "EOF in middle of function".into(), + tokenizer.position, + None, + )); + } + _ => {} + } + Ok(rv) +} + +pub fn serialize<W: Write>(prefs: &Preferences, output: &mut W) -> io::Result<()> { + let mut p: Vec<_> = prefs.iter().collect(); + p.sort_by(|a, b| a.0.cmp(b.0)); + for &(key, pref) in &p { + let func = if pref.sticky { + "sticky_pref(" + } else { + "user_pref(" + } + .as_bytes(); + output.write_all(func)?; + output.write_all(b"\"")?; + output.write_all(escape_quote(key).as_bytes())?; + output.write_all(b"\"")?; + output.write_all(b", ")?; + match pref.value { + PrefValue::Bool(x) => { + output.write_all(if x { b"true" } else { b"false" })?; + } + PrefValue::Int(x) => { + output.write_all(x.to_string().as_bytes())?; + } + PrefValue::String(ref x) => { + output.write_all(b"\"")?; + output.write_all(escape_quote(x).as_bytes())?; + output.write_all(b"\"")?; + } + }; + output.write_all(b");\n")?; + } + Ok(()) +} + +pub fn parse(data: &[u8]) -> Result<Preferences, PrefReaderError> { + let mut tokenizer = tokenize(data); + parse_tokens(&mut tokenizer) +} diff --git a/testing/mozbase/rust/mozprofile/src/profile.rs b/testing/mozbase/rust/mozprofile/src/profile.rs new file mode 100644 index 0000000000..8da0cdd96a --- /dev/null +++ b/testing/mozbase/rust/mozprofile/src/profile.rs @@ -0,0 +1,135 @@ +/* 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/. */ + +use crate::preferences::{Pref, Preferences}; +use crate::prefreader::{parse, serialize, PrefReaderError}; +use std::collections::btree_map::Iter; +use std::fs::File; +use std::io::prelude::*; +use std::io::Result as IoResult; +use std::path::{Path, PathBuf}; +use tempfile::{Builder, TempDir}; + +#[derive(Debug)] +pub struct Profile { + pub path: PathBuf, + pub temp_dir: Option<TempDir>, + prefs: Option<PrefFile>, + user_prefs: Option<PrefFile>, +} + +impl PartialEq for Profile { + fn eq(&self, other: &Profile) -> bool { + self.path == other.path + } +} + +impl Profile { + pub fn new(temp_root: Option<&Path>) -> IoResult<Profile> { + let mut dir_builder = Builder::new(); + dir_builder.prefix("rust_mozprofile"); + let dir = if let Some(temp_root) = temp_root { + dir_builder.tempdir_in(temp_root) + } else { + dir_builder.tempdir() + }?; + let path = dir.path().to_path_buf(); + let temp_dir = Some(dir); + Ok(Profile { + path, + temp_dir, + prefs: None, + user_prefs: None, + }) + } + + pub fn new_from_path(p: &Path) -> IoResult<Profile> { + let path = p.to_path_buf(); + let temp_dir = None; + Ok(Profile { + path, + temp_dir, + prefs: None, + user_prefs: None, + }) + } + + pub fn prefs(&mut self) -> Result<&mut PrefFile, PrefReaderError> { + if self.prefs.is_none() { + let mut pref_path = PathBuf::from(&self.path); + pref_path.push("prefs.js"); + self.prefs = Some(PrefFile::new(pref_path)?) + }; + // This error handling doesn't make much sense + Ok(self.prefs.as_mut().unwrap()) + } + + pub fn user_prefs(&mut self) -> Result<&mut PrefFile, PrefReaderError> { + if self.user_prefs.is_none() { + let mut pref_path = PathBuf::from(&self.path); + pref_path.push("user.js"); + self.user_prefs = Some(PrefFile::new(pref_path)?) + }; + // This error handling doesn't make much sense + Ok(self.user_prefs.as_mut().unwrap()) + } +} + +#[derive(Debug)] +pub struct PrefFile { + pub path: PathBuf, + pub prefs: Preferences, +} + +impl PrefFile { + pub fn new(path: PathBuf) -> Result<PrefFile, PrefReaderError> { + let prefs = if !path.exists() { + Preferences::new() + } else { + let mut f = File::open(&path)?; + let mut buf = String::with_capacity(4096); + f.read_to_string(&mut buf)?; + parse(buf.as_bytes())? + }; + + Ok(PrefFile { path, prefs }) + } + + pub fn write(&self) -> IoResult<()> { + let mut f = File::create(&self.path)?; + serialize(&self.prefs, &mut f) + } + + pub fn insert_slice<K>(&mut self, preferences: &[(K, Pref)]) + where + K: Into<String> + Clone, + { + for (name, value) in preferences.iter() { + self.insert((*name).clone(), (*value).clone()); + } + } + + pub fn insert<K>(&mut self, key: K, value: Pref) + where + K: Into<String>, + { + self.prefs.insert(key.into(), value); + } + + pub fn remove(&mut self, key: &str) -> Option<Pref> { + self.prefs.remove(key) + } + + pub fn get(&mut self, key: &str) -> Option<&Pref> { + self.prefs.get(key) + } + + pub fn contains_key(&self, key: &str) -> bool { + self.prefs.contains_key(key) + } + + pub fn iter(&self) -> Iter<String, Pref> { + self.prefs.iter() + } +} diff --git a/testing/mozbase/rust/mozrunner/Cargo.toml b/testing/mozbase/rust/mozrunner/Cargo.toml new file mode 100644 index 0000000000..7b745f18bf --- /dev/null +++ b/testing/mozbase/rust/mozrunner/Cargo.toml @@ -0,0 +1,28 @@ +[package] +edition = "2021" +name = "mozrunner" +version = "0.15.2" +authors = ["Mozilla"] +description = "Reliable Firefox process management." +keywords = [ + "firefox", + "mozilla", + "process-manager", +] +license = "MPL-2.0" +repository = "https://hg.mozilla.org/mozilla-central/file/tip/testing/mozbase/rust/mozrunner" + +[dependencies] +log = "0.4" +mozprofile = { path = "../mozprofile", version = "0.9" } +plist = "1.0" +thiserror = "1" + +[target.'cfg(target_os = "windows")'.dependencies] +winreg = "0.10.1" + +[target.'cfg(target_os = "macos")'.dependencies] +dirs = "4" + +[[bin]] +name = "firefox-default-path" diff --git a/testing/mozbase/rust/mozrunner/src/bin/firefox-default-path.rs b/testing/mozbase/rust/mozrunner/src/bin/firefox-default-path.rs new file mode 100644 index 0000000000..94958aac90 --- /dev/null +++ b/testing/mozbase/rust/mozrunner/src/bin/firefox-default-path.rs @@ -0,0 +1,21 @@ +/* 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/. */ + +extern crate mozrunner; + +use mozrunner::runner::platform; +use std::io::Write; + +fn main() { + let (path, code) = platform::firefox_default_path() + .map(|x| (x.to_string_lossy().into_owned(), 0)) + .unwrap_or(("Firefox binary not found".to_owned(), 1)); + + let mut writer: Box<dyn Write> = match code { + 0 => Box::new(std::io::stdout()), + _ => Box::new(std::io::stderr()), + }; + writeln!(&mut writer, "{}", &*path).unwrap(); + std::process::exit(code); +} diff --git a/testing/mozbase/rust/mozrunner/src/firefox_args.rs b/testing/mozbase/rust/mozrunner/src/firefox_args.rs new file mode 100644 index 0000000000..49f873f9dc --- /dev/null +++ b/testing/mozbase/rust/mozrunner/src/firefox_args.rs @@ -0,0 +1,384 @@ +/* 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/. */ + +//! Argument string parsing and matching functions for Firefox. +//! +//! Which arguments Firefox accepts and in what style depends on the platform. +//! On Windows only, arguments can be prefixed with `/` (slash), such as +//! `/screenshot`. Elsewhere, including Windows, arguments may be prefixed +//! with both single (`-screenshot`) and double (`--screenshot`) dashes. +//! +//! An argument's name is determined by a space or an assignment operator (`=`) +//! so that for the string `-foo=bar`, `foo` is considered the argument's +//! basename. + +use crate::runner::platform; +use std::ffi::{OsStr, OsString}; +use std::fmt; + +/// Parse an argument string into a name and value +/// +/// Given an argument like `"--arg=value"` this will split it into +/// `(Some("arg"), Some("value")). For a case like `"--arg"` it will +/// return `(Some("arg"), None)` and where the input doesn't look like +/// an argument e.g. `"value"` it will return `(None, Some("value"))` +fn parse_arg_name_value<T>(arg: T) -> (Option<String>, Option<String>) +where + T: AsRef<OsStr>, +{ + let arg_os_str: &OsStr = arg.as_ref(); + let arg_str = arg_os_str.to_string_lossy(); + + let mut name_start = 0; + let mut name_end = 0; + + // Look for an argument name at the start of the + // string + for (i, c) in arg_str.chars().enumerate() { + if i == 0 { + if !platform::arg_prefix_char(c) { + break; + } + } else if i == 1 { + if name_end_char(c) { + break; + } else if c != '-' { + name_start = i; + name_end = name_start + 1; + } else { + name_start = i + 1; + name_end = name_start; + } + } else { + name_end += 1; + if name_end_char(c) { + name_end -= 1; + break; + } + } + } + + let name = if name_start > 0 && name_end > name_start { + Some(arg_str[name_start..name_end].into()) + } else { + None + }; + + // If there are characters in the string after the argument, read + // them as the value, excluding the seperator (e.g. "=") if + // present. + let mut value_start = name_end; + let value_end = arg_str.len(); + let value = if value_start < value_end { + if let Some(c) = arg_str[value_start..value_end].chars().next() { + if name_end_char(c) { + value_start += 1; + } + } + Some(arg_str[value_start..value_end].into()) + } else { + None + }; + (name, value) +} + +fn name_end_char(c: char) -> bool { + c == ' ' || c == '=' +} + +/// Represents a Firefox command-line argument. +#[derive(Debug, PartialEq)] +pub enum Arg { + /// `-foreground` ensures application window gets focus, which is not the + /// default on macOS. As such Firefox only supports it on MacOS. + Foreground, + + /// --marionette enables Marionette in the application which is used + /// by WebDriver HTTP. + Marionette, + + /// `-no-remote` prevents remote commands to this instance of Firefox, and + /// ensure we always start a new instance. + NoRemote, + + /// `-P NAME` starts Firefox with a profile with a given name. + NamedProfile, + + /// `-profile PATH` starts Firefox with the profile at the specified path. + Profile, + + /// `-ProfileManager` starts Firefox with the profile chooser dialogue. + ProfileManager, + + /// All other arguments. + Other(String), + + /// --remote-allow-hosts contains comma-separated values of the Host header + /// to allow for incoming WebSocket requests of the Remote Agent. + RemoteAllowHosts, + + /// --remote-allow-origins contains comma-separated values of the Origin header + /// to allow for incoming WebSocket requests of the Remote Agent. + RemoteAllowOrigins, + + /// --remote-debugging-port enables the Remote Agent in the application + /// which is used for the WebDriver BiDi and CDP remote debugging protocols. + RemoteDebuggingPort, + + /// Not an argument. + None, +} + +impl Arg { + pub fn new(name: &str) -> Arg { + match name { + "foreground" => Arg::Foreground, + "marionette" => Arg::Marionette, + "no-remote" => Arg::NoRemote, + "profile" => Arg::Profile, + "P" => Arg::NamedProfile, + "ProfileManager" => Arg::ProfileManager, + "remote-allow-hosts" => Arg::RemoteAllowHosts, + "remote-allow-origins" => Arg::RemoteAllowOrigins, + "remote-debugging-port" => Arg::RemoteDebuggingPort, + _ => Arg::Other(name.into()), + } + } +} + +impl<'a> From<&'a OsString> for Arg { + fn from(arg_str: &OsString) -> Arg { + if let (Some(name), _) = parse_arg_name_value(arg_str) { + Arg::new(&name) + } else { + Arg::None + } + } +} + +impl fmt::Display for Arg { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&match self { + Arg::Foreground => "--foreground".to_string(), + Arg::Marionette => "--marionette".to_string(), + Arg::NamedProfile => "-P".to_string(), + Arg::None => "".to_string(), + Arg::NoRemote => "--no-remote".to_string(), + Arg::Other(x) => format!("--{}", x), + Arg::Profile => "--profile".to_string(), + Arg::ProfileManager => "--ProfileManager".to_string(), + Arg::RemoteAllowHosts => "--remote-allow-hosts".to_string(), + Arg::RemoteAllowOrigins => "--remote-allow-origins".to_string(), + Arg::RemoteDebuggingPort => "--remote-debugging-port".to_string(), + }) + } +} + +/// Parse an iterator over arguments into an vector of (name, value) +/// tuples +/// +/// Each entry in the input argument will produce a single item in the +/// output. Because we don't know anything about the specific +/// arguments, something that doesn't parse as a named argument may +/// either be the value of a previous named argument, or may be a +/// positional argument. +pub fn parse_args<'a>( + args: impl Iterator<Item = &'a OsString>, +) -> Vec<(Option<Arg>, Option<String>)> { + args.map(parse_arg_name_value) + .map(|(name, value)| { + if let Some(arg_name) = name { + (Some(Arg::new(&arg_name)), value) + } else { + (None, value) + } + }) + .collect() +} + +/// Given an iterator over all arguments, get the value of an argument +/// +/// This assumes that the argument takes a single value and that is +/// either provided as a single argument entry +/// (e.g. `["--name=value"]`) or as the following argument +/// (e.g. `["--name", "value"]) +pub fn get_arg_value<'a>( + mut parsed_args: impl Iterator<Item = &'a (Option<Arg>, Option<String>)>, + arg: Arg, +) -> Option<String> { + let mut found_value = None; + for (arg_name, arg_value) in &mut parsed_args { + if let (Some(name), value) = (arg_name, arg_value) { + if *name == arg { + found_value = value.clone(); + break; + } + } + } + if found_value.is_none() { + // If there wasn't a value, check if the following argument is a value + if let Some((None, value)) = parsed_args.next() { + found_value = value.clone(); + } + } + found_value +} + +#[cfg(test)] +mod tests { + use super::{get_arg_value, parse_arg_name_value, parse_args, Arg}; + use std::ffi::OsString; + + fn parse(arg: &str, name: Option<&str>) { + let (result, _) = parse_arg_name_value(arg); + assert_eq!(result, name.map(|x| x.to_string())); + } + + #[test] + fn test_parse_arg_name_value() { + parse("-p", Some("p")); + parse("--p", Some("p")); + parse("--profile foo", Some("profile")); + parse("--profile", Some("profile")); + parse("--", None); + parse("", None); + parse("-=", None); + parse("--=", None); + parse("-- foo", None); + parse("foo", None); + parse("/ foo", None); + parse("/- foo", None); + parse("/=foo", None); + parse("foo", None); + parse("-profile", Some("profile")); + parse("-profile=foo", Some("profile")); + parse("-profile = foo", Some("profile")); + parse("-profile abc", Some("profile")); + parse("-profile /foo", Some("profile")); + } + + #[cfg(target_os = "windows")] + #[test] + fn test_parse_arg_name_value_windows() { + parse("/profile", Some("profile")); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn test_parse_arg_name_value_non_windows() { + parse("/profile", None); + } + + #[test] + fn test_arg_from_osstring() { + assert_eq!(Arg::from(&OsString::from("--foreground")), Arg::Foreground); + assert_eq!(Arg::from(&OsString::from("-foreground")), Arg::Foreground); + + assert_eq!(Arg::from(&OsString::from("--marionette")), Arg::Marionette); + assert_eq!(Arg::from(&OsString::from("-marionette")), Arg::Marionette); + + assert_eq!(Arg::from(&OsString::from("--no-remote")), Arg::NoRemote); + assert_eq!(Arg::from(&OsString::from("-no-remote")), Arg::NoRemote); + + assert_eq!(Arg::from(&OsString::from("-- profile")), Arg::None); + assert_eq!(Arg::from(&OsString::from("profile")), Arg::None); + assert_eq!(Arg::from(&OsString::from("profile -P")), Arg::None); + assert_eq!( + Arg::from(&OsString::from("-profiled")), + Arg::Other("profiled".into()) + ); + assert_eq!( + Arg::from(&OsString::from("-PROFILEMANAGER")), + Arg::Other("PROFILEMANAGER".into()) + ); + + assert_eq!(Arg::from(&OsString::from("--profile")), Arg::Profile); + assert_eq!(Arg::from(&OsString::from("-profile foo")), Arg::Profile); + + assert_eq!( + Arg::from(&OsString::from("--ProfileManager")), + Arg::ProfileManager + ); + assert_eq!( + Arg::from(&OsString::from("-ProfileManager")), + Arg::ProfileManager + ); + + // TODO: -Ptest is valid + //assert_eq!(Arg::from(&OsString::from("-Ptest")), Arg::NamedProfile); + assert_eq!(Arg::from(&OsString::from("-P")), Arg::NamedProfile); + assert_eq!(Arg::from(&OsString::from("-P test")), Arg::NamedProfile); + + assert_eq!( + Arg::from(&OsString::from("--remote-debugging-port")), + Arg::RemoteDebuggingPort + ); + assert_eq!( + Arg::from(&OsString::from("-remote-debugging-port")), + Arg::RemoteDebuggingPort + ); + assert_eq!( + Arg::from(&OsString::from("--remote-debugging-port 9222")), + Arg::RemoteDebuggingPort + ); + + assert_eq!( + Arg::from(&OsString::from("--remote-allow-hosts")), + Arg::RemoteAllowHosts + ); + assert_eq!( + Arg::from(&OsString::from("-remote-allow-hosts")), + Arg::RemoteAllowHosts + ); + assert_eq!( + Arg::from(&OsString::from("--remote-allow-hosts 9222")), + Arg::RemoteAllowHosts + ); + + assert_eq!( + Arg::from(&OsString::from("--remote-allow-origins")), + Arg::RemoteAllowOrigins + ); + assert_eq!( + Arg::from(&OsString::from("-remote-allow-origins")), + Arg::RemoteAllowOrigins + ); + assert_eq!( + Arg::from(&OsString::from("--remote-allow-origins http://foo")), + Arg::RemoteAllowOrigins + ); + } + + #[test] + fn test_get_arg_value() { + let args = vec!["-P", "ProfileName", "--profile=/path/", "--no-remote"] + .iter() + .map(|x| OsString::from(x)) + .collect::<Vec<OsString>>(); + let parsed_args = parse_args(args.iter()); + assert_eq!( + get_arg_value(parsed_args.iter(), Arg::NamedProfile), + Some("ProfileName".into()) + ); + assert_eq!( + get_arg_value(parsed_args.iter(), Arg::Profile), + Some("/path/".into()) + ); + assert_eq!(get_arg_value(parsed_args.iter(), Arg::NoRemote), None); + + let args = vec!["--profile=", "-P test"] + .iter() + .map(|x| OsString::from(x)) + .collect::<Vec<OsString>>(); + let parsed_args = parse_args(args.iter()); + assert_eq!( + get_arg_value(parsed_args.iter(), Arg::NamedProfile), + Some("test".into()) + ); + assert_eq!( + get_arg_value(parsed_args.iter(), Arg::Profile), + Some("".into()) + ); + } +} diff --git a/testing/mozbase/rust/mozrunner/src/lib.rs b/testing/mozbase/rust/mozrunner/src/lib.rs new file mode 100644 index 0000000000..5634de11bf --- /dev/null +++ b/testing/mozbase/rust/mozrunner/src/lib.rs @@ -0,0 +1,20 @@ +#![forbid(unsafe_code)] +/* 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/. */ + +#[macro_use] +extern crate log; +#[cfg(target_os = "macos")] +extern crate dirs; +extern crate mozprofile; +#[cfg(target_os = "macos")] +extern crate plist; +#[cfg(target_os = "windows")] +extern crate winreg; + +pub mod firefox_args; +pub mod path; +pub mod runner; + +pub use crate::runner::platform::firefox_default_path; diff --git a/testing/mozbase/rust/mozrunner/src/path.rs b/testing/mozbase/rust/mozrunner/src/path.rs new file mode 100644 index 0000000000..bb3308ece6 --- /dev/null +++ b/testing/mozbase/rust/mozrunner/src/path.rs @@ -0,0 +1,61 @@ +/* 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/. */ + +//! Provides utilities for searching the system path. + +use std::env; +use std::path::{Path, PathBuf}; + +#[cfg(target_os = "macos")] +pub fn is_app_bundle(path: &Path) -> bool { + if path.is_dir() { + let mut info_plist = path.to_path_buf(); + info_plist.push("Contents"); + info_plist.push("Info.plist"); + + return info_plist.exists(); + } + + false +} + +#[cfg(unix)] +fn is_executable(path: &Path) -> bool { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + // Permissions are a set of four 4-bit bitflags, represented by a single octal + // digit. The lowest bit of each of the last three values represents the + // executable permission for all, group and user, repsectively. We assume the + // file is executable if any of these are set. + match fs::metadata(path).ok() { + Some(meta) => meta.permissions().mode() & 0o111 != 0, + None => false, + } +} + +#[cfg(not(unix))] +fn is_executable(_: &Path) -> bool { + true +} + +/// Determines if the path is an executable binary. That is, if it exists, is +/// a file, and is executable where applicable. +pub fn is_binary(path: &Path) -> bool { + path.exists() && path.is_file() && is_executable(path) +} + +/// Searches the system path (`PATH`) for an executable binary and returns the +/// first match, or `None` if not found. +pub fn find_binary(binary_name: &str) -> Option<PathBuf> { + env::var_os("PATH").and_then(|path_env| { + for mut path in env::split_paths(&path_env) { + path.push(binary_name); + if is_binary(&path) { + return Some(path); + } + } + None + }) +} diff --git a/testing/mozbase/rust/mozrunner/src/runner.rs b/testing/mozbase/rust/mozrunner/src/runner.rs new file mode 100644 index 0000000000..5d544029a0 --- /dev/null +++ b/testing/mozbase/rust/mozrunner/src/runner.rs @@ -0,0 +1,528 @@ +/* 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/. */ + +use mozprofile::prefreader::PrefReaderError; +use mozprofile::profile::Profile; +use std::collections::HashMap; +use std::ffi::{OsStr, OsString}; +use std::io; +use std::path::{Path, PathBuf}; +use std::process; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time; +use thiserror::Error; + +use crate::firefox_args::Arg; + +pub trait Runner { + type Process; + + fn arg<S>(&mut self, arg: S) -> &mut Self + where + S: AsRef<OsStr>; + + fn args<I, S>(&mut self, args: I) -> &mut Self + where + I: IntoIterator<Item = S>, + S: AsRef<OsStr>; + + fn env<K, V>(&mut self, key: K, value: V) -> &mut Self + where + K: AsRef<OsStr>, + V: AsRef<OsStr>; + + fn envs<I, K, V>(&mut self, envs: I) -> &mut Self + where + I: IntoIterator<Item = (K, V)>, + K: AsRef<OsStr>, + V: AsRef<OsStr>; + + fn stdout<T>(&mut self, stdout: T) -> &mut Self + where + T: Into<Stdio>; + + fn stderr<T>(&mut self, stderr: T) -> &mut Self + where + T: Into<Stdio>; + + fn start(self) -> Result<Self::Process, RunnerError>; +} + +pub trait RunnerProcess { + /// Attempts to collect the exit status of the process if it has already exited. + /// + /// This function will not block the calling thread and will only advisorily check to see if + /// the child process has exited or not. If the process has exited then on Unix the process ID + /// is reaped. This function is guaranteed to repeatedly return a successful exit status so + /// long as the child has already exited. + /// + /// If the process has exited, then `Ok(Some(status))` is returned. If the exit status is not + /// available at this time then `Ok(None)` is returned. If an error occurs, then that error is + /// returned. + fn try_wait(&mut self) -> io::Result<Option<process::ExitStatus>>; + + /// Waits for the process to exit completely, killing it if it does not stop within `timeout`, + /// and returns the status that it exited with. + /// + /// Firefox' integrated background monitor observes long running threads during shutdown and + /// kills these after 63 seconds. If the process fails to exit within the duration of + /// `timeout`, it is forcefully killed. + /// + /// This function will continue to have the same return value after it has been called at least + /// once. + fn wait(&mut self, timeout: time::Duration) -> io::Result<process::ExitStatus>; + + /// Determine if the process is still running. + fn running(&mut self) -> bool; + + /// Forces the process to exit and returns the exit status. This is + /// equivalent to sending a SIGKILL on Unix platforms. + fn kill(&mut self) -> io::Result<process::ExitStatus>; +} + +#[derive(Debug, Error)] +pub enum RunnerError { + #[error("IO Error: {0}")] + Io(#[from] io::Error), + #[error("PrefReader Error: {0}")] + PrefReader(#[from] PrefReaderError), +} + +#[derive(Debug)] +pub struct FirefoxProcess { + process: Child, + // The profile field is not directly used, but it is kept to avoid its + // Drop removing the (temporary) profile directory. + #[allow(dead_code)] + profile: Option<Profile>, +} + +impl RunnerProcess for FirefoxProcess { + fn try_wait(&mut self) -> io::Result<Option<process::ExitStatus>> { + self.process.try_wait() + } + + fn wait(&mut self, timeout: time::Duration) -> io::Result<process::ExitStatus> { + let start = time::Instant::now(); + loop { + match self.try_wait() { + // child has already exited, reap its exit code + Ok(Some(status)) => return Ok(status), + + // child still running and timeout elapsed, kill it + Ok(None) if start.elapsed() >= timeout => return self.kill(), + + // child still running, let's give it more time + Ok(None) => thread::sleep(time::Duration::from_millis(100)), + + Err(e) => return Err(e), + } + } + } + + fn running(&mut self) -> bool { + self.try_wait().unwrap().is_none() + } + + fn kill(&mut self) -> io::Result<process::ExitStatus> { + match self.try_wait() { + // child has already exited, reap its exit code + Ok(Some(status)) => Ok(status), + + // child still running, kill it + Ok(None) => { + debug!("Killing process {}", self.process.id()); + self.process.kill()?; + self.process.wait() + } + + Err(e) => Err(e), + } + } +} + +#[derive(Debug)] +pub struct FirefoxRunner { + path: PathBuf, + profile: Option<Profile>, + args: Vec<OsString>, + envs: HashMap<OsString, OsString>, + stdout: Option<Stdio>, + stderr: Option<Stdio>, +} + +impl FirefoxRunner { + /// Initialize Firefox process runner. + /// + /// On macOS, `path` can optionally point to an application bundle, + /// i.e. _/Applications/Firefox.app_, as well as to an executable program + /// such as _/Applications/Firefox.app/Content/MacOS/firefox_. + pub fn new(path: &Path, profile: Option<Profile>) -> FirefoxRunner { + let mut envs: HashMap<OsString, OsString> = HashMap::new(); + envs.insert("MOZ_NO_REMOTE".into(), "1".into()); + + FirefoxRunner { + path: path.to_path_buf(), + envs, + profile, + args: vec![], + stdout: None, + stderr: None, + } + } +} + +impl Runner for FirefoxRunner { + type Process = FirefoxProcess; + + fn arg<S>(&mut self, arg: S) -> &mut FirefoxRunner + where + S: AsRef<OsStr>, + { + self.args.push((&arg).into()); + self + } + + fn args<I, S>(&mut self, args: I) -> &mut FirefoxRunner + where + I: IntoIterator<Item = S>, + S: AsRef<OsStr>, + { + for arg in args { + self.args.push((&arg).into()); + } + self + } + + fn env<K, V>(&mut self, key: K, value: V) -> &mut FirefoxRunner + where + K: AsRef<OsStr>, + V: AsRef<OsStr>, + { + self.envs.insert((&key).into(), (&value).into()); + self + } + + fn envs<I, K, V>(&mut self, envs: I) -> &mut FirefoxRunner + where + I: IntoIterator<Item = (K, V)>, + K: AsRef<OsStr>, + V: AsRef<OsStr>, + { + for (key, value) in envs { + self.envs.insert((&key).into(), (&value).into()); + } + self + } + + fn stdout<T>(&mut self, stdout: T) -> &mut Self + where + T: Into<Stdio>, + { + self.stdout = Some(stdout.into()); + self + } + + fn stderr<T>(&mut self, stderr: T) -> &mut Self + where + T: Into<Stdio>, + { + self.stderr = Some(stderr.into()); + self + } + + fn start(mut self) -> Result<FirefoxProcess, RunnerError> { + if let Some(ref mut profile) = self.profile { + profile.user_prefs()?.write()?; + } + + let stdout = self.stdout.unwrap_or_else(Stdio::inherit); + let stderr = self.stderr.unwrap_or_else(Stdio::inherit); + + let binary_path = platform::resolve_binary_path(&mut self.path); + let mut cmd = Command::new(binary_path); + cmd.args(&self.args[..]) + .envs(&self.envs) + .stdout(stdout) + .stderr(stderr); + + let mut seen_foreground = false; + let mut seen_no_remote = false; + let mut seen_profile = false; + for arg in self.args.iter() { + match arg.into() { + Arg::Foreground => seen_foreground = true, + Arg::NoRemote => seen_no_remote = true, + Arg::Profile | Arg::NamedProfile | Arg::ProfileManager => seen_profile = true, + Arg::Marionette + | Arg::None + | Arg::Other(_) + | Arg::RemoteAllowHosts + | Arg::RemoteAllowOrigins + | Arg::RemoteDebuggingPort => {} + } + } + // -foreground is only supported on Mac, and shouldn't be passed + // to Firefox on other platforms (bug 1720502). + if cfg!(target_os = "macos") && !seen_foreground { + cmd.arg("-foreground"); + } + if !seen_no_remote { + cmd.arg("-no-remote"); + } + if let Some(ref profile) = self.profile { + if !seen_profile { + cmd.arg("-profile").arg(&profile.path); + } + } + + info!("Running command: {:?}", cmd); + let process = cmd.spawn()?; + Ok(FirefoxProcess { + process, + profile: self.profile, + }) + } +} + +#[cfg(all(not(target_os = "macos"), unix))] +pub mod platform { + use crate::path::find_binary; + use std::path::PathBuf; + + pub fn resolve_binary_path(path: &mut PathBuf) -> &PathBuf { + path + } + + fn running_as_snap() -> bool { + std::env::var("SNAP_INSTANCE_NAME") + .or_else(|_| { + // Compatibility for snapd <= 2.35 + std::env::var("SNAP_NAME") + }) + .map(|name| !name.is_empty()) + .unwrap_or(false) + } + + /// Searches the system path for `firefox`. + pub fn firefox_default_path() -> Option<PathBuf> { + if running_as_snap() { + return Some(PathBuf::from("/snap/firefox/current/firefox.launcher")); + } + find_binary("firefox") + } + + pub fn arg_prefix_char(c: char) -> bool { + c == '-' + } + + #[cfg(test)] + mod tests { + use crate::firefox_default_path; + use std::env; + use std::ops::Drop; + use std::path::PathBuf; + + static SNAP_KEY: &str = "SNAP_INSTANCE_NAME"; + static SNAP_LEGACY_KEY: &str = "SNAP_NAME"; + + struct SnapEnvironment { + initial_environment: (Option<String>, Option<String>), + } + + impl SnapEnvironment { + fn new() -> SnapEnvironment { + SnapEnvironment { + initial_environment: (env::var(SNAP_KEY).ok(), env::var(SNAP_LEGACY_KEY).ok()), + } + } + + fn set(&self, value: Option<String>, legacy_value: Option<String>) { + fn set_env(key: &str, value: Option<String>) { + match value { + Some(value) => env::set_var(key, value), + None => env::remove_var(key), + } + } + set_env(SNAP_KEY, value); + set_env(SNAP_LEGACY_KEY, legacy_value); + } + } + + impl Drop for SnapEnvironment { + fn drop(&mut self) { + self.set( + self.initial_environment.0.clone(), + self.initial_environment.1.clone(), + ) + } + } + + #[test] + fn test_default_path() { + let snap_path = Some(PathBuf::from("/snap/firefox/current/firefox.launcher")); + + let snap_env = SnapEnvironment::new(); + + snap_env.set(None, None); + assert_ne!(firefox_default_path(), snap_path); + + snap_env.set(Some("value".into()), None); + assert_eq!(firefox_default_path(), snap_path); + + snap_env.set(None, Some("value".into())); + assert_eq!(firefox_default_path(), snap_path); + } + } +} + +#[cfg(target_os = "macos")] +pub mod platform { + use crate::path::{find_binary, is_app_bundle, is_binary}; + use dirs; + use plist::Value; + use std::path::PathBuf; + + /// Searches for the binary file inside the path passed as parameter. + /// If the binary is not found, the path remains unaltered. + /// Else, it gets updated by the new binary path. + pub fn resolve_binary_path(path: &mut PathBuf) -> &PathBuf { + if path.as_path().is_dir() { + let mut info_plist = path.clone(); + info_plist.push("Contents"); + info_plist.push("Info.plist"); + if let Ok(plist) = Value::from_file(&info_plist) { + if let Some(dict) = plist.as_dictionary() { + if let Some(Value::String(s)) = dict.get("CFBundleExecutable") { + path.push("Contents"); + path.push("MacOS"); + path.push(s); + } + } + } + } + path + } + + /// Searches the system path for `firefox`, then looks for + /// `Applications/Firefox.app/Contents/MacOS/firefox` as well + /// as `Applications/Firefox Nightly.app/Contents/MacOS/firefox` + /// under both `/` (system root) and the user home directory. + pub fn firefox_default_path() -> Option<PathBuf> { + if let Some(path) = find_binary("firefox") { + return Some(path); + } + + let home = dirs::home_dir(); + for &(prefix_home, trial_path) in [ + (false, "/Applications/Firefox.app"), + (true, "Applications/Firefox.app"), + (false, "/Applications/Firefox Nightly.app"), + (true, "Applications/Firefox Nightly.app"), + ] + .iter() + { + let path = match (home.as_ref(), prefix_home) { + (Some(home_dir), true) => home_dir.join(trial_path), + (None, true) => continue, + (_, false) => PathBuf::from(trial_path), + }; + + if is_binary(&path) || is_app_bundle(&path) { + return Some(path); + } + } + + None + } + + pub fn arg_prefix_char(c: char) -> bool { + c == '-' + } +} + +#[cfg(target_os = "windows")] +pub mod platform { + use crate::path::{find_binary, is_binary}; + use std::io::Error; + use std::path::PathBuf; + use winreg::enums::*; + use winreg::RegKey; + + pub fn resolve_binary_path(path: &mut PathBuf) -> &PathBuf { + path + } + + /// Searches the Windows registry, then the system path for `firefox.exe`. + /// + /// It _does not_ currently check the `HKEY_CURRENT_USER` tree. + pub fn firefox_default_path() -> Option<PathBuf> { + if let Ok(Some(path)) = firefox_registry_path() { + if is_binary(&path) { + return Some(path); + } + }; + find_binary("firefox.exe") + } + + fn firefox_registry_path() -> Result<Option<PathBuf>, Error> { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + for subtree_key in ["SOFTWARE", "SOFTWARE\\WOW6432Node"].iter() { + let subtree = hklm.open_subkey_with_flags(subtree_key, KEY_READ)?; + let mozilla_org = match subtree.open_subkey_with_flags("mozilla.org\\Mozilla", KEY_READ) + { + Ok(val) => val, + Err(_) => continue, + }; + let current_version: String = mozilla_org.get_value("CurrentVersion")?; + let mozilla = subtree.open_subkey_with_flags("Mozilla", KEY_READ)?; + for key_res in mozilla.enum_keys() { + let key = key_res?; + let section_data = mozilla.open_subkey_with_flags(&key, KEY_READ)?; + let version: Result<String, _> = section_data.get_value("GeckoVer"); + if let Ok(ver) = version { + if ver == current_version { + let mut bin_key = key.to_owned(); + bin_key.push_str("\\bin"); + if let Ok(bin_subtree) = mozilla.open_subkey_with_flags(bin_key, KEY_READ) { + let path_to_exe: Result<String, _> = bin_subtree.get_value("PathToExe"); + if let Ok(path_to_exe) = path_to_exe { + let path = PathBuf::from(path_to_exe); + if is_binary(&path) { + return Ok(Some(path)); + } + } + } + } + } + } + } + Ok(None) + } + + pub fn arg_prefix_char(c: char) -> bool { + c == '/' || c == '-' + } +} + +#[cfg(not(any(unix, target_os = "windows")))] +pub mod platform { + use std::path::PathBuf; + + /// Returns an unaltered path for all operating systems other than macOS. + pub fn resolve_binary_path(path: &mut PathBuf) -> &PathBuf { + path + } + + /// Returns `None` for all other operating systems than Linux, macOS, and + /// Windows. + pub fn firefox_default_path() -> Option<PathBuf> { + None + } + + pub fn arg_prefix_char(c: char) -> bool { + c == '-' + } +} diff --git a/testing/mozbase/rust/mozversion/Cargo.toml b/testing/mozbase/rust/mozversion/Cargo.toml new file mode 100644 index 0000000000..192185d163 --- /dev/null +++ b/testing/mozbase/rust/mozversion/Cargo.toml @@ -0,0 +1,18 @@ +[package] +edition = "2021" +name = "mozversion" +version = "0.5.2" +authors = ["Mozilla"] +description = "Utility for accessing Firefox version metadata" +keywords = [ + "firefox", + "mozilla", +] +license = "MPL-2.0" +repository = "https://hg.mozilla.org/mozilla-central/file/tip/testing/mozbase/rust/mozversion" + +[dependencies] +regex = { version = "1", default-features = false, features = ["perf", "std"] } +rust-ini = "0.10" +semver = "1.0" +thiserror = "1" diff --git a/testing/mozbase/rust/mozversion/src/lib.rs b/testing/mozbase/rust/mozversion/src/lib.rs new file mode 100644 index 0000000000..ccb6b01803 --- /dev/null +++ b/testing/mozbase/rust/mozversion/src/lib.rs @@ -0,0 +1,410 @@ +#![forbid(unsafe_code)] +/* 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/. */ + +extern crate ini; +extern crate regex; +extern crate semver; + +use crate::platform::ini_path; +use ini::Ini; +use regex::Regex; +use std::default::Default; +use std::fmt::{self, Display, Formatter}; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::str::{self, FromStr}; +use thiserror::Error; + +/// Details about the version of a Firefox build. +#[derive(Clone, Default)] +pub struct AppVersion { + /// Unique date-based id for a build + pub build_id: Option<String>, + /// Channel name + pub code_name: Option<String>, + /// Version number e.g. 55.0a1 + pub version_string: Option<String>, + /// Url of the respoistory from which the build was made + pub source_repository: Option<String>, + /// Commit ID of the build + pub source_stamp: Option<String>, +} + +impl AppVersion { + pub fn new() -> AppVersion { + Default::default() + } + + fn update_from_application_ini(&mut self, ini_file: &Ini) { + if let Some(section) = ini_file.section(Some("App")) { + if let Some(build_id) = section.get("BuildID") { + self.build_id = Some(build_id.clone()); + } + if let Some(code_name) = section.get("CodeName") { + self.code_name = Some(code_name.clone()); + } + if let Some(version) = section.get("Version") { + self.version_string = Some(version.clone()); + } + if let Some(source_repository) = section.get("SourceRepository") { + self.source_repository = Some(source_repository.clone()); + } + if let Some(source_stamp) = section.get("SourceStamp") { + self.source_stamp = Some(source_stamp.clone()); + } + } + } + + fn update_from_platform_ini(&mut self, ini_file: &Ini) { + if let Some(section) = ini_file.section(Some("Build")) { + if let Some(build_id) = section.get("BuildID") { + self.build_id = Some(build_id.clone()); + } + if let Some(version) = section.get("Milestone") { + self.version_string = Some(version.clone()); + } + if let Some(source_repository) = section.get("SourceRepository") { + self.source_repository = Some(source_repository.clone()); + } + if let Some(source_stamp) = section.get("SourceStamp") { + self.source_stamp = Some(source_stamp.clone()); + } + } + } + + pub fn version(&self) -> Option<Version> { + self.version_string + .as_ref() + .and_then(|x| Version::from_str(x).ok()) + } +} + +#[derive(Default, Clone)] +/// Version number information +pub struct Version { + /// Major version number (e.g. 55 in 55.0) + pub major: u64, + /// Minor version number (e.g. 1 in 55.1) + pub minor: u64, + /// Patch version number (e.g. 2 in 55.1.2) + pub patch: u64, + /// Prerelase information (e.g. Some(("a", 1)) in 55.0a1) + pub pre: Option<(String, u64)>, + /// Is build an ESR build + pub esr: bool, +} + +impl Version { + fn to_semver(&self) -> semver::Version { + // The way the semver crate handles prereleases isn't what we want here + // This should be fixed in the long term by implementing our own comparison + // operators, but for now just act as if prerelease metadata was missing, + // otherwise it is almost impossible to use this with nightly + semver::Version { + major: self.major, + minor: self.minor, + patch: self.patch, + pre: semver::Prerelease::EMPTY, + build: semver::BuildMetadata::EMPTY, + } + } + + pub fn matches(&self, version_req: &str) -> VersionResult<bool> { + let req = semver::VersionReq::parse(version_req)?; + Ok(req.matches(&self.to_semver())) + } +} + +impl FromStr for Version { + type Err = Error; + + fn from_str(version_string: &str) -> VersionResult<Version> { + let mut version: Version = Default::default(); + let version_re = Regex::new(r"^(?P<major>[[:digit:]]+)\.(?P<minor>[[:digit:]]+)(?:\.(?P<patch>[[:digit:]]+))?(?:(?P<esr>esr)|(?P<pre0>\-|[a-z]+)(?P<pre1>[[:digit:]]*))?$").unwrap(); + if let Some(captures) = version_re.captures(version_string) { + match captures + .name("major") + .and_then(|x| u64::from_str(x.as_str()).ok()) + { + Some(x) => version.major = x, + None => return Err(Error::VersionError("No major version number found".into())), + } + match captures + .name("minor") + .and_then(|x| u64::from_str(x.as_str()).ok()) + { + Some(x) => version.minor = x, + None => return Err(Error::VersionError("No minor version number found".into())), + } + if let Some(x) = captures + .name("patch") + .and_then(|x| u64::from_str(x.as_str()).ok()) + { + version.patch = x + } + if captures.name("esr").is_some() { + version.esr = true; + } + if let Some(pre_0) = captures.name("pre0").map(|x| x.as_str().to_string()) { + if captures.name("pre1").is_some() { + if let Some(pre_1) = captures + .name("pre1") + .and_then(|x| u64::from_str(x.as_str()).ok()) + { + version.pre = Some((pre_0, pre_1)) + } else { + return Err(Error::VersionError( + "Failed to convert prelease number to u64".into(), + )); + } + } else { + return Err(Error::VersionError( + "Failed to convert prelease number to u64".into(), + )); + } + } + } else { + return Err(Error::VersionError(format!( + "Failed to parse {} as version string", + version_string + ))); + } + Ok(version) + } +} + +impl Display for Version { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match self.patch { + 0 => write!(f, "{}.{}", self.major, self.minor)?, + _ => write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?, + } + if self.esr { + write!(f, "esr")?; + } + if let Some(ref pre) = self.pre { + write!(f, "{}{}", pre.0, pre.1)?; + }; + Ok(()) + } +} + +/// Determine the version of Firefox using associated metadata files. +/// +/// Given the path to a Firefox binary, read the associated application.ini +/// and platform.ini files to extract information about the version of Firefox +/// at that path. +pub fn firefox_version(binary: &Path) -> VersionResult<AppVersion> { + let mut version = AppVersion::new(); + let mut updated = false; + + if let Some(dir) = ini_path(binary) { + let mut application_ini = dir.clone(); + application_ini.push("application.ini"); + + if Path::exists(&application_ini) { + let ini_file = Ini::load_from_file(application_ini).ok(); + if let Some(ini) = ini_file { + updated = true; + version.update_from_application_ini(&ini); + } + } + + let mut platform_ini = dir; + platform_ini.push("platform.ini"); + + if Path::exists(&platform_ini) { + let ini_file = Ini::load_from_file(platform_ini).ok(); + if let Some(ini) = ini_file { + updated = true; + version.update_from_platform_ini(&ini); + } + } + + if !updated { + return Err(Error::MetadataError( + "Neither platform.ini nor application.ini found".into(), + )); + } + } else { + return Err(Error::MetadataError("Invalid binary path".into())); + } + Ok(version) +} + +/// Determine the version of Firefox by executing the binary. +/// +/// Given the path to a Firefox binary, run firefox --version and extract the +/// version string from the output +pub fn firefox_binary_version(binary: &Path) -> VersionResult<Version> { + let output = Command::new(binary) + .args(["--version"]) + .stdout(Stdio::piped()) + .spawn() + .and_then(|child| child.wait_with_output()) + .ok(); + + if let Some(x) = output { + let output_str = str::from_utf8(&x.stdout) + .map_err(|_| Error::VersionError("Couldn't parse version output as UTF8".into()))?; + parse_binary_version(output_str) + } else { + Err(Error::VersionError("Running binary failed".into())) + } +} + +fn parse_binary_version(version_str: &str) -> VersionResult<Version> { + let version_regexp = Regex::new(r#"Firefox[[:space:]]+(?P<version>.+)"#) + .expect("Error parsing version regexp"); + + let version_match = version_regexp + .captures(version_str) + .and_then(|captures| captures.name("version")) + .ok_or_else(|| Error::VersionError("--version output didn't match expectations".into()))?; + + Version::from_str(version_match.as_str()) +} + +#[derive(Clone, Debug, Error)] +pub enum Error { + /// Error parsing a version string + #[error("VersionError: {0}")] + VersionError(String), + /// Error reading application metadata + #[error("MetadataError: {0}")] + MetadataError(String), + /// Error processing a string as a semver comparator + #[error("SemVerError: {0}")] + SemVerError(String), +} + +impl From<semver::Error> for Error { + fn from(err: semver::Error) -> Error { + Error::SemVerError(err.to_string()) + } +} + +pub type VersionResult<T> = Result<T, Error>; + +#[cfg(target_os = "macos")] +mod platform { + use std::path::{Path, PathBuf}; + + pub fn ini_path(binary: &Path) -> Option<PathBuf> { + binary + .canonicalize() + .ok() + .as_ref() + .and_then(|dir| dir.parent()) + .and_then(|dir| dir.parent()) + .map(|dir| dir.join("Resources")) + } +} + +#[cfg(not(target_os = "macos"))] +mod platform { + use std::path::{Path, PathBuf}; + + pub fn ini_path(binary: &Path) -> Option<PathBuf> { + binary + .canonicalize() + .ok() + .as_ref() + .and_then(|dir| dir.parent()) + .map(|dir| dir.to_path_buf()) + } +} + +#[cfg(test)] +mod test { + use super::{parse_binary_version, Version}; + use std::str::FromStr; + + fn parse_version(input: &str) -> String { + Version::from_str(input).unwrap().to_string() + } + + fn compare(version: &str, comparison: &str) -> bool { + let v = Version::from_str(version).unwrap(); + v.matches(comparison).unwrap() + } + + #[test] + fn test_parser() { + assert!(parse_version("50.0a1") == "50.0a1"); + assert!(parse_version("50.0.1a1") == "50.0.1a1"); + assert!(parse_version("50.0.0") == "50.0"); + assert!(parse_version("78.0.11esr") == "78.0.11esr"); + } + + #[test] + fn test_matches() { + assert!(compare("50.0", "=50")); + assert!(compare("50.1", "=50")); + assert!(compare("50.1", "=50.1")); + assert!(compare("50.1.1", "=50.1")); + assert!(compare("50.0.0", "=50.0.0")); + assert!(compare("51.0.0", ">50")); + assert!(compare("49.0", "<50")); + assert!(compare("50.0", "<50.1")); + assert!(compare("50.0.0", "<50.0.1")); + assert!(!compare("50.1.0", ">50")); + assert!(!compare("50.1.0", "<50")); + assert!(compare("50.1.0", ">=50,<51")); + assert!(compare("50.0a1", ">49.0")); + assert!(compare("50.0a2", "=50")); + assert!(compare("78.1.0esr", ">=78")); + assert!(compare("78.1.0esr", "<79")); + assert!(compare("78.1.11esr", "<79")); + // This is the weird one + assert!(!compare("50.0a2", ">50.0")); + } + + #[test] + fn test_binary_parser() { + assert!( + parse_binary_version("Mozilla Firefox 50.0a1") + .unwrap() + .to_string() + == "50.0a1" + ); + assert!( + parse_binary_version("Mozilla Firefox 50.0.1a1") + .unwrap() + .to_string() + == "50.0.1a1" + ); + assert!( + parse_binary_version("Mozilla Firefox 50.0.0") + .unwrap() + .to_string() + == "50.0" + ); + assert!( + parse_binary_version("Mozilla Firefox 78.0.11esr") + .unwrap() + .to_string() + == "78.0.11esr" + ); + assert!( + parse_binary_version("Mozilla Firefox 78.0esr") + .unwrap() + .to_string() + == "78.0esr" + ); + assert!( + parse_binary_version("Mozilla Firefox 78.0") + .unwrap() + .to_string() + == "78.0" + ); + assert!( + parse_binary_version("Foo Firefox 113.0.2-1") + .unwrap() + .to_string() + == "113.0.2-1" + ); + } +} diff --git a/testing/mozbase/setup_development.py b/testing/mozbase/setup_development.py new file mode 100755 index 0000000000..73aba7aaee --- /dev/null +++ b/testing/mozbase/setup_development.py @@ -0,0 +1,290 @@ +#!/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/. + +""" +Setup mozbase packages for development. + +Packages may be specified as command line arguments. +If no arguments are given, install all packages. + +See https://wiki.mozilla.org/Auto-tools/Projects/Mozbase +""" + +import os +import subprocess +import sys +from optparse import OptionParser +from subprocess import PIPE + +try: + from subprocess import check_call as call +except ImportError: + from subprocess import call + + +# directory containing this file +here = os.path.dirname(os.path.abspath(__file__)) + +# all python packages +mozbase_packages = [ + i for i in os.listdir(here) if os.path.exists(os.path.join(here, i, "setup.py")) +] + +# testing: https://wiki.mozilla.org/Auto-tools/Projects/Mozbase#Tests +test_packages = ["mock"] + +# documentation: https://wiki.mozilla.org/Auto-tools/Projects/Mozbase#Documentation +extra_packages = ["sphinx"] + + +def cycle_check(order, dependencies): + """ensure no cyclic dependencies""" + order_dict = dict([(j, i) for i, j in enumerate(order)]) + for package, deps in dependencies.items(): + index = order_dict[package] + for d in deps: + assert index > order_dict[d], "Cyclic dependencies detected" + + +def info(directory): + "get the package setup.py information" + + assert os.path.exists(os.path.join(directory, "setup.py")) + + # setup the egg info + try: + call([sys.executable, "setup.py", "egg_info"], cwd=directory, stdout=PIPE) + except subprocess.CalledProcessError: + print("Error running setup.py in %s" % directory) + raise + + # get the .egg-info directory + egg_info = [entry for entry in os.listdir(directory) if entry.endswith(".egg-info")] + assert len(egg_info) == 1, "Expected one .egg-info directory in %s, got: %s" % ( + directory, + egg_info, + ) + egg_info = os.path.join(directory, egg_info[0]) + assert os.path.isdir(egg_info), "%s is not a directory" % egg_info + + # read the package information + pkg_info = os.path.join(egg_info, "PKG-INFO") + info_dict = {} + for line in open(pkg_info).readlines(): + if not line or line[0].isspace(): + continue # XXX neglects description + assert ":" in line + key, value = [i.strip() for i in line.split(":", 1)] + info_dict[key] = value + + return info_dict + + +def get_dependencies(directory): + "returns the package name and dependencies given a package directory" + + # get the package metadata + info_dict = info(directory) + + # get the .egg-info directory + egg_info = [ + entry for entry in os.listdir(directory) if entry.endswith(".egg-info") + ][0] + + # read the dependencies + requires = os.path.join(directory, egg_info, "requires.txt") + dependencies = [] + if os.path.exists(requires): + for line in open(requires): + line = line.strip() + # in requires.txt file, a dependency is a non empty line + # Also lines like [device] are sections to mark optional + # dependencies, we don't want those sections. + if line and not (line.startswith("[") and line.endswith("]")): + dependencies.append(line) + + # return the information + return info_dict["Name"], dependencies + + +def dependency_info(dep): + "return dictionary of dependency information from a dependency string" + retval = dict(Name=None, Type=None, Version=None) + for joiner in ("==", "<=", ">="): + if joiner in dep: + retval["Type"] = joiner + name, version = [i.strip() for i in dep.split(joiner, 1)] + retval["Name"] = name + retval["Version"] = version + break + else: + retval["Name"] = dep.strip() + return retval + + +def unroll_dependencies(dependencies): + """ + unroll a set of dependencies to a flat list + + dependencies = {'packageA': set(['packageB', 'packageC', 'packageF']), + 'packageB': set(['packageC', 'packageD', 'packageE', 'packageG']), + 'packageC': set(['packageE']), + 'packageE': set(['packageF', 'packageG']), + 'packageF': set(['packageG']), + 'packageX': set(['packageA', 'packageG'])} + """ + + order = [] + + # flatten all + packages = set(dependencies.keys()) + for deps in dependencies.values(): + packages.update(deps) + + while len(order) != len(packages): + for package in packages.difference(order): + if set(dependencies.get(package, set())).issubset(order): + order.append(package) + break + else: + raise AssertionError("Cyclic dependencies detected") + + cycle_check(order, dependencies) # sanity check + + return order + + +def main(args=sys.argv[1:]): + # parse command line options + usage = "%prog [options] [package] [package] [...]" + parser = OptionParser(usage=usage, description=__doc__) + parser.add_option( + "-d", + "--dependencies", + dest="list_dependencies", + action="store_true", + default=False, + help="list dependencies for the packages", + ) + parser.add_option( + "--list", action="store_true", default=False, help="list what will be installed" + ) + parser.add_option( + "--extra", + "--install-extra-packages", + action="store_true", + default=False, + help="installs extra supporting packages as well as core mozbase ones", + ) + options, packages = parser.parse_args(args) + + if not packages: + # install all packages + packages = sorted(mozbase_packages) + + # ensure specified packages are in the list + assert set(packages).issubset( + mozbase_packages + ), "Packages should be in %s (You gave: %s)" % (mozbase_packages, packages) + + if options.list_dependencies: + # list the package dependencies + for package in packages: + print("%s: %s" % get_dependencies(os.path.join(here, package))) + parser.exit() + + # gather dependencies + # TODO: version conflict checking + deps = {} + alldeps = {} + mapping = {} # mapping from subdir name to package name + # core dependencies + for package in packages: + key, value = get_dependencies(os.path.join(here, package)) + deps[key] = [dependency_info(dep)["Name"] for dep in value] + mapping[package] = key + + # keep track of all dependencies for non-mozbase packages + for dep in value: + alldeps[dependency_info(dep)["Name"]] = "".join(dep.split()) + + # indirect dependencies + flag = True + while flag: + flag = False + for value in deps.values(): + for dep in value: + if dep in mozbase_packages and dep not in deps: + key, value = get_dependencies(os.path.join(here, dep)) + deps[key] = [dep for dep in value] + + for dep in value: + alldeps[dep] = "".join(dep.split()) + mapping[package] = key + flag = True + break + if flag: + break + + # get the remaining names for the mapping + for package in mozbase_packages: + if package in mapping: + continue + key, value = get_dependencies(os.path.join(here, package)) + mapping[package] = key + + # unroll dependencies + unrolled = unroll_dependencies(deps) + + # make a reverse mapping: package name -> subdirectory + reverse_mapping = dict([(j, i) for i, j in mapping.items()]) + + # we only care about dependencies in mozbase + unrolled = [package for package in unrolled if package in reverse_mapping] + + if options.list: + # list what will be installed + for package in unrolled: + print(package) + parser.exit() + + # set up the packages for development + for package in unrolled: + call( + [sys.executable, "setup.py", "develop", "--no-deps"], + cwd=os.path.join(here, reverse_mapping[package]), + ) + + # add the directory of sys.executable to path to aid the correct + # `easy_install` getting called + # https://bugzilla.mozilla.org/show_bug.cgi?id=893878 + os.environ["PATH"] = "%s%s%s" % ( + os.path.dirname(os.path.abspath(sys.executable)), + os.path.pathsep, + os.environ.get("PATH", "").strip(os.path.pathsep), + ) + + # install non-mozbase dependencies + # these need to be installed separately and the --no-deps flag + # subsequently used due to a bug in setuptools; see + # https://bugzilla.mozilla.org/show_bug.cgi?id=759836 + pypi_deps = dict([(i, j) for i, j in alldeps.items() if i not in unrolled]) + for package, version in pypi_deps.items(): + # easy_install should be available since we rely on setuptools + call(["easy_install", version]) + + # install packages required for unit testing + for package in test_packages: + call(["easy_install", package]) + + # install extra non-mozbase packages if desired + if options.extra: + for package in extra_packages: + call(["easy_install", package]) + + +if __name__ == "__main__": + main() diff --git a/testing/mozbase/versioninfo.py b/testing/mozbase/versioninfo.py new file mode 100755 index 0000000000..499449f87b --- /dev/null +++ b/testing/mozbase/versioninfo.py @@ -0,0 +1,153 @@ +#!/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/. + +""" +List mozbase package dependencies or generate changelogs +from commit messages. +""" + +import argparse +import os +import subprocess +import sys +from collections.abc import Iterable + +from packaging.version import Version + +here = os.path.abspath(os.path.dirname(__file__)) +sys.path.insert(0, here) + +import setup_development + + +def run_hg(command): + command = command[:] + if not isinstance(command, Iterable): + command = command.split() + command.insert(0, "hg") + try: + output = subprocess.check_output(command, cwd=here, universal_newlines=True) + except subprocess.CalledProcessError: + sys.exit(1) + return output + + +def changelog(args): + setup = os.path.join(args.module, "setup.py") + + def get_version_rev(v=None): + revisions = run_hg(["log", setup, "--template={rev},"]).split(",")[:-1] + for rev in revisions: + diff = run_hg(["diff", "-c", rev, setup, "-U0"]) + minus_version = None + plus_version = None + for line in diff.splitlines(): + if line.startswith("-PACKAGE_VERSION"): + try: + minus_version = Version(line.split()[-1].strip("\"'")) + except ValueError: + pass + elif line.startswith("+PACKAGE_VERSION"): + try: + plus_version = Version(line.split()[-1].strip("\"'")) + except ValueError: + break + + # make sure the change isn't a backout + if not minus_version or plus_version > minus_version: + if not v: + return rev + + if Version(v) == plus_version: + return rev + + print( + "Could not find %s revision for version %s." % (args.module, v or "latest") + ) + sys.exit(1) + + from_ref = args.from_ref or get_version_rev() + to_ref = args.to_ref or "tip" + + if "." in from_ref: + from_ref = get_version_rev(from_ref) + if "." in to_ref: + to_ref = get_version_rev(to_ref) + + delim = "\x12\x59\x52\x99\x05" + changelog = run_hg( + [ + "log", + "-r", + "%s:children(%s)" % (to_ref, from_ref), + "--template={desc}%s" % delim, + "-M", + args.module, + ] + ).split(delim)[:-1] + + def prettify(desc): + lines = desc.splitlines() + lines = [("* %s" if i == 0 else " %s") % l for i, l in enumerate(lines)] + return "\n".join(lines) + + # pylint --py3k: W1636 + changelog = list(map(prettify, changelog)) + print("\n".join(changelog)) + + +def dependencies(args): + # get package information + info = {} + dependencies = {} + for package in setup_development.mozbase_packages: + directory = os.path.join(setup_development.here, package) + info[directory] = setup_development.info(directory) + name, _dependencies = setup_development.get_dependencies(directory) + assert name == info[directory]["Name"] + dependencies[name] = _dependencies + + # print package version information + for value in info.values(): + print( + "%s %s : %s" + % (value["Name"], value["Version"], ", ".join(dependencies[value["Name"]])) + ) + + +def main(args=sys.argv[1:]): + parser = argparse.ArgumentParser() + subcommands = parser.add_subparsers(help="Sub-commands") + + p_deps = subcommands.add_parser("dependencies", help="Print dependencies.") + p_deps.set_defaults(func=dependencies) + + p_changelog = subcommands.add_parser("changelog", help="Print a changelog.") + p_changelog.add_argument("module", help="Module to get changelog from.") + p_changelog.add_argument( + "--from", + dest="from_ref", + default=None, + help="Starting version or revision to list " + "changes from. [defaults to latest version]", + ) + p_changelog.add_argument( + "--to", + dest="to_ref", + default=None, + help="Ending version or revision to list " "changes to. [defaults to tip]", + ) + p_changelog.set_defaults(func=changelog) + + # default to showing dependencies + if args == []: + args.append("dependencies") + args = parser.parse_args(args) + args.func(args) + + +if __name__ == "__main__": + main() |