| #!/usr/bin/env python3 |
| # |
| # Copyright (C) 2021 Igalia S.L. |
| # |
| # This program is free software; you can redistribute it and/or |
| # modify it under the terms of the GNU Lesser General Public |
| # License as published by the Free Software Foundation; either |
| # version 2.1 of the License, or (at your option) any later version. |
| # |
| # This program is distributed in the hope that it will be useful, |
| # but WITHOUT ANY WARRANTY; without even the implied warranty of |
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
| # Lesser General Public License for more details. |
| # |
| # You should have received a copy of the GNU Lesser General Public |
| # License along with this program; if not, write to the |
| # Free Software Foundation, Inc., 51 Franklin St, Fifth Floor, |
| # Boston, MA 02110-1301, USA. |
| # pylint: disable=missing-docstring,invalid-name |
| |
| """Command-line interface to the Webkit Results Database""" |
| |
| import argparse |
| from datetime import datetime |
| import json |
| import logging |
| import os |
| import pathlib |
| import sys |
| import textwrap |
| from urllib.parse import urljoin, urlencode, parse_qs |
| from urllib.request import urlopen |
| from urllib.error import HTTPError |
| |
| BASE_URL = "https://results.webkit.org/" |
| |
| |
| def red(msg): |
| """Returns a shell-aware red string""" |
| return "\033[91m" + msg + "\033[0m" |
| |
| |
| def green(msg): |
| """Returns a shell-aware green string""" |
| return "\033[92m" + msg + "\033[0m" |
| |
| |
| # Roughly ordered from the most desired to the most unwanted. |
| OUTCOMES = [ |
| "PASS", |
| "TEXT", |
| "IMAGE", |
| "SKIP", |
| "FAIL", |
| "ERROR", |
| "TIMEOUT", # Put timeout as more severe than ERROR as they often are harder to debug |
| "CRASH", |
| ] |
| |
| |
| def is_improvement(baseline, current): |
| """Returns true if the current outcome is more favourable.""" |
| best_baseline = min(OUTCOMES.index(baseline_token) for baseline_token in baseline.split()) |
| return best_baseline > OUTCOMES.index(current) |
| |
| |
| def is_regression(baseline, current): |
| """Returns true if the current outcome downgraded the result.""" |
| worst_baseline = max(OUTCOMES.index(baseline_token) for baseline_token in baseline.split()) |
| return worst_baseline < OUTCOMES.index(current) |
| |
| |
| def decorate(msg, baseline, current): |
| """Colors the message if the status change represents an improvement or regression.""" |
| if is_improvement(baseline, current): |
| msg = green(msg) |
| elif is_regression(baseline, current): |
| msg = red(msg) |
| return msg |
| |
| |
| class Query: |
| """Wrapper around the most common query parameters for a given request""" |
| |
| def __init__(self, **kwargs): |
| """Just stores the passed kwargs as parameters to be forwarded to the actual query""" |
| self._params = kwargs.copy() |
| |
| @classmethod |
| def from_query_string(cls, query_str): |
| fields = parse_qs(query_str) |
| # parse_qs forces the values to be lists, even when they are single-valued |
| for k, v in fields.items(): |
| if len(v) == 1: |
| fields[k] = v[0] |
| return cls(**fields) |
| |
| def as_query_string(self): |
| """Escape and build the actual query string""" |
| return urlencode(self._params) |
| |
| def add_param(self, **kwargs): |
| """Extends the current set of parameters. |
| |
| Beware that it'll overwrite existing values.""" |
| self._params.update(kwargs) |
| |
| |
| BOTS = { |
| "gtk-release-x11": Query( |
| platform="GTK", style="release", version_name="Xvfb", version="5.5.0" |
| ), |
| "gtk-release-gtk4": Query( |
| platform="GTK", style="release", version_name="Xvfb", version="4.19.0" |
| ), |
| "gtk-release-wayland": Query( |
| platform="GTK", style="release", version_name="Wayland" |
| ), |
| "wpe-release": Query(platform="WPE", style="release"), |
| "wpe-debug": Query(platform="WPE", style="release"), |
| } |
| |
| |
| def get_commit_cache_filename(): |
| """Returns the cache filename, ensuring it's parent folder exists""" |
| app_name = "webkit-test-results" |
| xdg_config_home = os.getenv("XDG_CACHE_HOME") |
| if xdg_config_home: |
| directory = os.path.join(xdg_config_home, app_name) |
| else: |
| directory = os.path.join(os.path.expanduser("~"), ".cache", app_name) |
| |
| if not os.path.isdir(directory): |
| pathlib.Path(directory).mkdir(parents=True, exist_ok=True) |
| |
| return os.path.join(directory, "commits.json") |
| |
| |
| def load_commit_cache(force_reset=False): |
| """Loads the stored commits, fetching again if needed.""" |
| filename = get_commit_cache_filename() |
| if force_reset or not os.path.isfile(filename): |
| return reset_commit_cache() |
| |
| logging.info("Opening commit cache %s", filename) |
| with open(filename, encoding="utf-8") as handle: |
| return json.load(handle) |
| |
| |
| def reset_commit_cache(): |
| """Fetches the last 5000 commits and store their info to be reused in later calls.""" |
| # TODO Append to existing instead of resetting |
| logging.info("Resetting commit cache") |
| limit = 5000 |
| command = "/api/commits" |
| |
| query = {"limit": limit, "branch": "main"} |
| |
| url = urljoin(BASE_URL, command) + "?" + urlencode(query) |
| |
| filename = get_commit_cache_filename() |
| logging.info("Fetching commits from %s", url) |
| with urlopen(url) as response: |
| raw_data = json.load(response) |
| |
| uuids = {} |
| for commit in raw_data: |
| # Order is usually zero for single commits pushed to the repo. For example, |
| # commits in SVN. When moving to git, branchs with multiple commits will |
| # make use of it. |
| uuid = str(commit["timestamp"] * 100 + commit["order"]) |
| commit["uuid"] = uuid |
| uuids[uuid] = commit |
| |
| logging.info("Saving commits to %s", filename) |
| with open(filename, "w", encoding="utf-8") as output: |
| logging.info("Saving downloaded cache") |
| json.dump(uuids, output) |
| |
| return uuids |
| |
| |
| def last_run(args): |
| """Shows data about the last test run registered for the selected bot""" |
| endpoint = "api/results/layout-tests" |
| if args.only_changes: |
| logging.info("--only-changes ignored in this command") |
| if args.only_unexpected: |
| logging.info("--only-unexpected ignored in this command") |
| |
| configuration = BOTS.get(args.bot, Query.from_query_string(args.bot)) |
| configuration.add_param(limit=1) |
| query = configuration.as_query_string() |
| |
| url = urljoin(BASE_URL, endpoint) + "?" + query |
| |
| logging.info("Loading test data from %s", url) |
| try: |
| with urlopen(url) as response: |
| data = json.load(response) |
| print(json.dumps(data, sort_keys=True, indent=4, separators=(",", ": "))) |
| except (HTTPError, json.JSONDecodeError) as e: |
| print(e) |
| return 1 |
| |
| return 0 |
| |
| |
| def get_latest_commit(commits): |
| """Returns the newest commit from a set of commits""" |
| |
| latest = max(commits.values(), key=lambda x: x["uuid"]) |
| |
| return latest["identifier"], latest["timestamp"] |
| |
| |
| def report_test(args): |
| """Reports the test history for a single testcase in a single configuration""" |
| endpoint = "api/results/layout-tests" |
| configuration = BOTS.get(args.bot, Query.from_query_string(args.bot)) |
| if args.limit > 0: |
| configuration.add_param(limit=args.limit) |
| query = configuration.as_query_string() |
| |
| url = urljoin(BASE_URL, endpoint + "/" + args.test) + "?" + query |
| |
| logging.info("Loading test data from %s", url) |
| try: |
| with urlopen(url) as response: |
| data = json.load(response)[0] |
| except IndexError: |
| logging.error("No results returned. Exiting.") |
| return 1 |
| |
| results = sorted(data["results"], key=lambda result: result["uuid"]) |
| |
| commits = load_commit_cache(force_reset=args.reset_cache) |
| logging.info("Found %d cached commits", len(commits)) |
| |
| previous = None |
| |
| matched = False |
| |
| for result in results: |
| actual = result["actual"] |
| expected = result["expected"] |
| start_time = datetime.fromtimestamp(result["start_time"]) |
| uuid = result["uuid"] |
| |
| try: |
| commit = commits[str(uuid)] |
| matched = True |
| except KeyError: |
| logging.info("Could not find commit with uuid %s", uuid) |
| continue |
| |
| if args.only_changes: |
| if previous == actual: |
| continue |
| elif args.only_unexpected: |
| if actual == expected: |
| continue |
| |
| msg = "commit {} expected {} actual {} time {}".format( |
| commit["identifier"], expected, actual, start_time |
| ) |
| |
| if args.color: |
| print(decorate(msg, expected, actual)) |
| else: |
| print(msg) |
| previous = actual |
| |
| if not matched: |
| latest_revision, latest_timestamp = get_latest_commit(commits) |
| latest_date = datetime.fromtimestamp(latest_timestamp) |
| print( |
| f"No commits matched. The latest commit in cache is {latest_revision} from {latest_date}", |
| file=sys.stderr, |
| ) |
| return 1 |
| |
| return 0 |
| |
| |
| def parse_args(): |
| """Parse command line arguments and switches""" |
| parser = argparse.ArgumentParser( |
| description=textwrap.dedent( |
| """ |
| Command line tool to query https://results.webkit.org for test history. |
| |
| Passing a test case in the command line will show one entry for each |
| test run registered, alongside commit information, expected and actual outcomes, |
| and timestamp of the run. For example: |
| |
| $ wk-test-result --bot wpe-release ietestcenter/css3/text/textshadow-001.htm --limit=5 |
| commit 244781@main expected PASS actual TEXT time 2021-12-02 18:02:13 |
| commit 244787@main expected PASS actual TEXT time 2021-12-02 19:43:10 |
| commit 244796@main expected PASS actual TEXT time 2021-12-02 20:41:52 |
| commit 244797@main expected PASS actual TEXT time 2021-12-02 21:40:29 |
| commit 244803@main expected PASS actual TEXT time 2021-12-02 23:13:19 |
| |
| To avoid querying the commit data at each invocation, the last 5000 commits |
| are cached (by default, to $XDG_CACHE_HOME/wk-gardening-tools/commits.json). |
| |
| Currently, only GLIB-based bots and layout-test suite are supported. |
| """ |
| ), |
| formatter_class=argparse.RawTextHelpFormatter, |
| ) |
| |
| bots_group = parser.add_mutually_exclusive_group() |
| bots_group.add_argument("-b", "--bot", help="Bot to query") |
| bots_group.add_argument("--list-bots", action="store_true", help="List predefined bots") |
| |
| # Common options |
| parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") |
| parser.add_argument( |
| "-r", |
| "--reset-cache", |
| action="store_true", |
| help="Reset the commit cache if needed", |
| ) |
| parser.add_argument( |
| "-c", |
| "--color", |
| action="store_true", |
| help="Use colors to highlight unexpected results", |
| ) |
| |
| parser.add_argument( |
| "--last-run", |
| action="store_true", |
| help="Show status of the last registered run and exit.", |
| ) |
| |
| parser.add_argument( |
| "--limit", |
| type=int, |
| default=100, |
| help="""Limit the number of results. Defaults to 100, use -1 to ask the server for |
| its default value. Use together with --test""", |
| ) |
| |
| |
| # Output options |
| output_group = parser.add_mutually_exclusive_group() |
| output_group.add_argument( |
| "--only-changes", |
| action="store_true", |
| help="Display only revisions where the state changed. Use with --test", |
| ) |
| output_group.add_argument( |
| "--only-unexpected", |
| action="store_true", |
| help="Display only unexpected results. Use with --test", |
| ) |
| |
| parser.add_argument( |
| "test", help="Test case to be searched", nargs="?", default=None |
| ) |
| |
| return parser.parse_args() |
| |
| |
| def main(): |
| """Main entry point""" |
| args = parse_args() |
| |
| if args.verbose: |
| logging.basicConfig(level=logging.DEBUG) |
| |
| if args.list_bots: |
| for bot in sorted(BOTS.keys()): |
| print(bot) |
| elif args.last_run: |
| sys.exit(last_run(args)) |
| elif args.test: |
| if not args.bot: |
| print("Test history requires a bot to be specified with -b or --bot. Exiting.") |
| sys.exit(1) |
| sys.exit(report_test(args)) |
| else: |
| print("No test case provided. Did you forget to pass it?") |
| sys.exit(1) |
| |
| |
| if __name__ == "__main__": |
| main() |