Source code
Revision control
Copy as Markdown
Other Tools
# 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
import io
import json
import os
import re
import textwrap
import traceback
from collections import defaultdict
from enum import Enum
from html.parser import HTMLParser
from pathlib import Path
import esprima
# list of metadata, each item is the name and if the field is mandatory
METADATA = [
("setUp", False),
("tearDown", False),
("test", True),
("owner", True),
("author", False),
("name", True),
("description", True),
("longDescription", False),
("options", False),
("supportedBrowsers", False),
("supportedPlatforms", False),
("filename", True),
("tags", False),
]
_INFO = """\
%(filename)s
%(filename_underline)s
:owner: %(owner)s
:name: %(name)s
"""
XPCSHELL_FUNCS = "add_task", "run_test", "run_next_test"
class BadOptionTypeError(Exception):
"""Raised when an option defined in a test has an incorrect type."""
pass
class MissingFieldError(Exception):
def __init__(self, script, field):
super().__init__(f"Missing metadata {field}")
self.script = script
self.field = field
class MissingPerfMetadataError(Exception):
def __init__(self, script):
super().__init__("Missing `perfMetadata` variable")
self.script = script
class ParseError(Exception):
def __init__(self, script, exception):
super().__init__(f"Cannot parse {script}")
self.script = script
self.exception = exception
def __str__(self):
output = io.StringIO()
traceback.print_exception(
type(self.exception),
self.exception,
self.exception.__traceback__,
file=output,
)
return f"{self.args[0]}\n{output.getvalue()}"
class ScriptType(Enum):
xpcshell = 1
browsertime = 2
mochitest = 3
custom = 4
alert = 5
class HTMLScriptParser(HTMLParser):
def handle_data(self, data):
if self.script_content is None:
self.script_content = []
if "perfMetadata" in data:
self.script_content.append(data)
if any(func_name in data for func_name in XPCSHELL_FUNCS):
self.script_content.append(data)
class ScriptInfo(defaultdict):
"""Loads and parses a Browsertime test script."""
def __init__(self, path):
super(ScriptInfo, self).__init__()
try:
self.script = Path(path).resolve()
if self.script.suffix == ".html":
self._parse_html_file()
elif self.script.suffix == ".sh":
self._parse_shell_script()
elif str(path).isdigit():
self._parse_alert_test(int(path))
else:
self._parse_js_file()
except Exception as e:
raise ParseError(path, e)
# If the fields found, don't match our known ones, then an error is raised
for field, required in METADATA:
if not required or self.script_type == ScriptType.alert:
continue
if field not in self:
raise MissingFieldError(path, field)
def _parse_alert_test(self, alert_summary_id):
self.script = int(alert_summary_id)
self.script_content = ""
self.script_type = ScriptType.alert
def _set_script_content(self):
self["filename"] = str(self.script)
self.script_content = self.script.read_text()
def _parse_js_file(self):
self.script_type = ScriptType.browsertime
self._set_script_content()
self._parse_script_content()
if self.get("options", {}).get("default", {}).get("manifest_flavor"):
# Only mochitest tests have a manifest flavor
self.script_type = ScriptType.mochitest
def _parse_script_content(self):
self.parsed = esprima.parseScript(self.script_content)
# looking for the exports statement
found_perfmetadata = False
for stmt in self.parsed.body:
# detecting if the script has add_task()
if (
stmt.type == "ExpressionStatement"
and stmt.expression is not None
and stmt.expression.callee is not None
and stmt.expression.callee.type == "Identifier"
and stmt.expression.callee.name in XPCSHELL_FUNCS
):
self["test"] = "xpcshell"
self.script_type = ScriptType.xpcshell
continue
# plain xpcshell tests functions markers
if stmt.type == "FunctionDeclaration" and stmt.id.name in XPCSHELL_FUNCS:
self["test"] = "xpcshell"
self.script_type = ScriptType.xpcshell
continue
# is this the perfMetdatata plain var ?
if stmt.type == "VariableDeclaration":
for decl in stmt.declarations:
if (
decl.type != "VariableDeclarator"
or decl.id.type != "Identifier"
or decl.id.name != "perfMetadata"
or decl.init is None
):
continue
found_perfmetadata = True
self.scan_properties(decl.init.properties)
continue
# or the module.exports map ?
if (
stmt.type != "ExpressionStatement"
or stmt.expression.left is None
or stmt.expression.left.property is None
or stmt.expression.left.property.name != "exports"
or stmt.expression.right is None
or stmt.expression.right.properties is None
):
continue
# now scanning the properties
found_perfmetadata = True
self.scan_properties(stmt.expression.right.properties)
if not found_perfmetadata:
raise MissingPerfMetadataError(self.script)
def _parse_html_file(self):
self._set_script_content()
html_parser = HTMLScriptParser()
html_parser.script_content = None
html_parser.feed(self.script_content)
if not html_parser.script_content:
raise MissingPerfMetadataError(self.script)
# Pass through all the scripts and gather up the data such as
# the test itself, and the perfMetadata. These can be in separate
# scripts, but later scripts override earlier ones if there
# are redefinitions.
found_perfmetadata = False
for script_content in html_parser.script_content:
self.script_content = script_content
try:
self._parse_script_content()
found_perfmetadata = True
except MissingPerfMetadataError:
pass
if not found_perfmetadata:
raise MissingPerfMetadataError()
# Mochitest gets detected as xpcshell during parsing
# since they use similar methods to run tests
self.script_type = ScriptType.mochitest
def _parse_shell_script(self):
self._set_script_content()
for line in self.script_content.split(os.linesep):
if not line.startswith("#"):
continue
stripped_line = line[1:].strip()
if stripped_line.lower().startswith("name:"):
self._parse_shell_property("name", stripped_line)
elif stripped_line.lower().startswith("owner:"):
self._parse_shell_property("owner", stripped_line)
elif stripped_line.lower().startswith("description:"):
self._parse_shell_property("description", stripped_line)
elif stripped_line.lower().startswith("options:"):
self._parse_shell_property(
"options", stripped_line.replace("#noqa", "")
)
self["options"] = json.loads(self["options"])
self["test"] = "custom-script"
self.script_type = ScriptType.custom
def _parse_shell_property(self, prop, line):
self[prop] = line[len(prop) + 1 :].strip()
def parse_value(self, value):
if value.type == "Identifier":
return value.name
if value.type == "Literal":
return value.value
if value.type == "TemplateLiteral":
# ugly
value = value.quasis[0].value.cooked.replace("\n", " ")
return re.sub(r"\s+", " ", value).strip()
if value.type == "ArrayExpression":
return [self.parse_value(e) for e in value.elements]
if value.type == "ObjectExpression":
elements = {}
for prop in value.properties:
sub_name, sub_value = self.parse_property(prop)
elements[sub_name] = sub_value
return elements
raise ValueError(value.type)
def parse_property(self, property):
return property.key.name, self.parse_value(property.value)
def scan_properties(self, properties):
for prop in properties:
name, value = self.parse_property(prop)
self[name] = value
def __str__(self):
"""Used to generate docs."""
def _render(value, level=0):
if not isinstance(value, (list, tuple, dict)):
if not isinstance(value, str):
value = str(value)
# line wrapping
return "\n".join(textwrap.wrap(value, break_on_hyphens=False))
# options
if isinstance(value, dict):
if level > 0:
return ",".join([f"{k}:{v}" for k, v in value.items()])
res = []
for key, val in value.items():
if isinstance(val, bool):
res.append(f" --{key.replace('_', '-')}")
else:
val = _render(val, level + 1) # noqa
res.append(f" --{key.replace('_', '-')} {val}")
return "\n".join(res)
# simple flat list
return ", ".join([_render(v, level + 1) for v in value])
options = ""
d = defaultdict(lambda: "N/A")
for field, value in self.items():
if field == "longDescription":
continue
if field == "filename":
d[field] = self.script.name
continue
if field == "options":
for plat in "default", "linux", "mac", "win":
if plat not in value:
continue
options += f":{plat.capitalize()} options:\n\n::\n\n{_render(value[plat])}\n"
else:
d[field] = _render(value)
d["filename_underline"] = "=" * len(d["filename"])
info = _INFO % d
if "tags" in self:
info += f":tags: {','.join(self['tags'])}\n"
info += options
info += f"\n**{self['description']}**\n"
if "longDescription" in self:
info += f"\n{self['longDescription']}\n"
return info
def __missing__(self, key):
return "N/A"
@classmethod
def detect_type(cls, path):
return cls(path).script_type
def update_args(self, **args):
"""Updates arguments with options from the script."""
from mozperftest.utils import simple_platform
# Order of precedence:
# cli options > platform options > default options
options = self.get("options", {})
result = options.get("default", {})
result.update(options.get(simple_platform(), {}))
result.update(args)
for opt, val in result.items():
if opt.startswith("visualmetrics") or "metrics" not in opt:
continue
if not isinstance(val, list):
raise BadOptionTypeError("Metrics should be defined within a list")
for metric in val:
if not isinstance(metric, dict):
raise BadOptionTypeError(
"Each individual metrics must be defined within a JSON-like object"
)
if self.script_type == ScriptType.xpcshell:
result["flavor"] = "xpcshell"
if self.script_type == ScriptType.mochitest:
result["flavor"] = "mochitest"
if self.script_type == ScriptType.custom:
result["flavor"] = "custom-script"
return result