#!/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/.

# run-tests.py -- Python harness for GDB SpiderMonkey support

import os
import re
import subprocess
import sys

# From this directory:
import progressbar
from taskpool import TaskPool, get_cpu_count


def _relpath(path, start=None):
    # Backported from Python 3.1 posixpath.py
    """Return a relative version of a path"""

    if not path:
        raise ValueError("no path specified")

    if start is None:
        start = os.curdir

    start_list = os.path.abspath(start).split(os.sep)
    path_list = os.path.abspath(path).split(os.sep)

    # Work out how much of the filepath is shared by start and path.
    i = len(os.path.commonprefix([start_list, path_list]))

    rel_list = [os.pardir] * (len(start_list) - i) + path_list[i:]
    if not rel_list:
        return os.curdir
    return os.path.join(*rel_list)


os.path.relpath = _relpath

# Characters that need to be escaped when used in shell words.
shell_need_escapes = re.compile("[^\w\d%+,-./:=@'\"]", re.DOTALL)
# Characters that need to be escaped within double-quoted strings.
shell_dquote_escapes = re.compile('[^\w\d%+,-./:=@"]', re.DOTALL)


def make_shell_cmd(l):
    def quote(s):
        if shell_need_escapes.search(s):
            if s.find("'") < 0:
                return "'" + s + "'"
            return '"' + shell_dquote_escapes.sub("\\g<0>", s) + '"'
        return s

    return " ".join([quote(_) for _ in l])


# An instance of this class collects the lists of passing, failing, and
# timing-out tests, runs the progress bar, and prints a summary at the end.
class Summary(object):
    class SummaryBar(progressbar.ProgressBar):
        def __init__(self, limit):
            super(Summary.SummaryBar, self).__init__("", limit, 24)

        def start(self):
            self.label = "[starting           ]"
            self.update(0)

        def counts(self, run, failures, timeouts):
            self.label = "[%4d|%4d|%4d|%4d]" % (run - failures, failures, timeouts, run)
            self.update(run)

    def __init__(self, num_tests):
        self.run = 0
        self.failures = []  # kind of judgemental; "unexpecteds"?
        self.timeouts = []
        if not OPTIONS.hide_progress:
            self.bar = Summary.SummaryBar(num_tests)

    # Progress bar control.
    def start(self):
        if not OPTIONS.hide_progress:
            self.bar.start()

    def update(self):
        if not OPTIONS.hide_progress:
            self.bar.counts(self.run, len(self.failures), len(self.timeouts))

    # Call 'thunk' to show some output, while getting the progress bar out of the way.

    def interleave_output(self, thunk):
        if not OPTIONS.hide_progress:
            self.bar.clear()
        thunk()
        self.update()

    def passed(self, test):
        self.run += 1
        self.update()

    def failed(self, test):
        self.run += 1
        self.failures.append(test)
        self.update()

    def timeout(self, test):
        self.run += 1
        self.timeouts.append(test)
        self.update()

    def finish(self):
        if not OPTIONS.hide_progress:
            self.bar.finish()

        if self.failures:

            print("tests failed:")
            for test in self.failures:
                test.show(sys.stdout)

            if OPTIONS.worklist:
                try:
                    with open(OPTIONS.worklist) as out:
                        for test in self.failures:
                            out.write(test.name + "\n")
                except IOError as err:
                    sys.stderr.write(
                        "Error writing worklist file '%s': %s" % (OPTIONS.worklist, err)
                    )
                    sys.exit(1)

            if OPTIONS.write_failures:
                try:
                    with open(OPTIONS.write_failures, "w") as out:
                        for test in self.failures:
                            test.show(out)
                except IOError as err:
                    sys.stderr.write(
                        "Error writing worklist file '%s': %s"
                        % (OPTIONS.write_failures, err)
                    )
                    sys.exit(1)

        if self.timeouts:
            print("tests timed out:")
            for test in self.timeouts:
                test.show(sys.stdout)

        if self.failures or self.timeouts:
            sys.exit(2)


class Test(TaskPool.Task):
    def __init__(self, path, summary):
        super(Test, self).__init__()
        self.test_path = path  # path to .py test file
        self.summary = summary

        # test.name is the name of the test relative to the top of the test
        # directory. This is what we use to report failures and timeouts,
        # and when writing test lists.
        self.name = os.path.relpath(self.test_path, OPTIONS.testdir)

        self.stdout = ""
        self.stderr = ""
        self.returncode = None

    def cmd(self):
        testlibdir = os.path.normpath(
            os.path.join(OPTIONS.testdir, "..", "lib-for-tests")
        )
        return [
            OPTIONS.gdb_executable,
            "-nw",  # Don't create a window (unnecessary?)
            "-nx",  # Don't read .gdbinit.
            "--ex",
            "add-auto-load-safe-path %s" % (OPTIONS.bindir,),
            "--ex",
            "set env LD_LIBRARY_PATH %s" % (OPTIONS.bindir,),
            "--ex",
            "file %s" % (os.path.join(OPTIONS.bindir, "gdb-tests"),),
            "--eval-command",
            "python testlibdir=%r" % (testlibdir,),
            "--eval-command",
            "python testscript=%r" % (self.test_path,),
            "--eval-command",
            "python exec(open(%r).read())" % os.path.join(testlibdir, "catcher.py"),
        ]

    def start(self, pipe, deadline):
        super(Test, self).start(pipe, deadline)
        if OPTIONS.show_cmd:
            self.summary.interleave_output(lambda: self.show_cmd(sys.stdout))

    def onStdout(self, text):
        self.stdout += text

    def onStderr(self, text):
        self.stderr += text

    def onFinished(self, returncode):
        self.returncode = returncode
        if OPTIONS.show_output:
            self.summary.interleave_output(lambda: self.show_output(sys.stdout))
        if returncode != 0:
            self.summary.failed(self)
        else:
            self.summary.passed(self)

    def onTimeout(self):
        self.summary.timeout(self)

    def show_cmd(self, out):
        out.write("Command: %s\n" % (make_shell_cmd(self.cmd()),))

    def show_output(self, out):
        if self.stdout:
            out.write("Standard output:")
            out.write("\n" + self.stdout + "\n")
        if self.stderr:
            out.write("Standard error:")
            out.write("\n" + self.stderr + "\n")

    def show(self, out):
        out.write(self.name + "\n")
        if OPTIONS.write_failure_output:
            self.show_cmd(out)
            self.show_output(out)
            out.write("GDB exit code: %r\n" % (self.returncode,))


def find_tests(dir, substring=None):
    ans = []
    for dirpath, _, filenames in os.walk(dir):
        if dirpath == ".":
            continue
        for filename in filenames:
            if not filename.endswith(".py"):
                continue
            test = os.path.join(dirpath, filename)
            if substring is None or substring in os.path.relpath(test, dir):
                ans.append(test)
    return ans


def build_test_exec(builddir):
    subprocess.check_call(["make"], cwd=builddir)


def run_tests(tests, summary):
    jobs = OPTIONS.workercount
    # python 3.3 fixed a bug with concurrently writing .pyc files.
    # https://bugs.python.org/issue13146
    embedded_version = (
        subprocess.check_output(
            [
                OPTIONS.gdb_executable,
                "--batch",
                "--ex",
                "python import sys; print(sys.hexversion)",
            ]
        )
        .decode("ascii")
        .strip()
    )
    if hex(int(embedded_version)) < "0x3030000":
        jobs = 1

    pool = TaskPool(tests, job_limit=jobs, timeout=OPTIONS.timeout)
    pool.run_all()


OPTIONS = None


def main(argv):
    global OPTIONS
    script_path = os.path.abspath(__file__)
    script_dir = os.path.dirname(script_path)

    # OBJDIR is a standalone SpiderMonkey build directory. This is where we
    # find the SpiderMonkey shared library to link against.
    #
    # The [TESTS] optional arguments are paths of test files relative
    # to the jit-test/tests directory.
    from optparse import OptionParser

    op = OptionParser(usage="%prog [options] OBJDIR [TESTS...]")
    op.add_option(
        "-s",
        "--show-cmd",
        dest="show_cmd",
        action="store_true",
        help="show GDB shell command run",
    )
    op.add_option(
        "-o",
        "--show-output",
        dest="show_output",
        action="store_true",
        help="show output from GDB",
    )
    op.add_option(
        "-x",
        "--exclude",
        dest="exclude",
        action="append",
        help="exclude given test dir or path",
    )
    op.add_option(
        "-t",
        "--timeout",
        dest="timeout",
        type=float,
        default=150.0,
        help="set test timeout in seconds",
    )
    op.add_option(
        "-j",
        "--worker-count",
        dest="workercount",
        type=int,
        help="Run [WORKERCOUNT] tests at a time",
    )
    op.add_option(
        "--no-progress",
        dest="hide_progress",
        action="store_true",
        help="hide progress bar",
    )
    op.add_option(
        "--worklist",
        dest="worklist",
        metavar="FILE",
        help="Read tests to run from [FILE] (or run all if [FILE] not found);\n"
        "write failures back to [FILE]",
    )
    op.add_option(
        "-r",
        "--read-tests",
        dest="read_tests",
        metavar="FILE",
        help="Run test files listed in [FILE]",
    )
    op.add_option(
        "-w",
        "--write-failures",
        dest="write_failures",
        metavar="FILE",
        help="Write failing tests to [FILE]",
    )
    op.add_option(
        "--write-failure-output",
        dest="write_failure_output",
        action="store_true",
        help="With --write-failures=FILE, additionally write the output of failed "
        "tests to [FILE]",
    )
    op.add_option(
        "--gdb",
        dest="gdb_executable",
        metavar="EXECUTABLE",
        default="gdb",
        help="Run tests with [EXECUTABLE], rather than plain 'gdb'.",
    )
    op.add_option(
        "--srcdir",
        dest="srcdir",
        default=os.path.abspath(os.path.join(script_dir, "..")),
        help="Use SpiderMonkey sources in [SRCDIR].",
    )
    op.add_option(
        "--testdir",
        dest="testdir",
        default=os.path.join(script_dir, "tests"),
        help="Find tests in [TESTDIR].",
    )
    op.add_option(
        "--builddir", dest="builddir", help="Build test executable from [BUILDDIR]."
    )
    op.add_option("--bindir", dest="bindir", help="Run test executable from [BINDIR].")
    (OPTIONS, args) = op.parse_args(argv)
    if len(args) < 1:
        op.error("missing OBJDIR argument")
    OPTIONS.objdir = os.path.abspath(args[0])

    test_args = args[1:]

    if not OPTIONS.workercount:
        OPTIONS.workercount = get_cpu_count()

    # Compute defaults for OPTIONS.builddir and OPTIONS.bindir now, since we've
    # computed OPTIONS.objdir.
    if not OPTIONS.builddir:
        OPTIONS.builddir = os.path.join(OPTIONS.objdir, "js", "src", "gdb")
    if not OPTIONS.bindir:
        OPTIONS.bindir = os.path.join(OPTIONS.objdir, "dist", "bin")

    test_set = set()

    # All the various sources of test names accumulate.
    if test_args:
        for arg in test_args:
            test_set.update(find_tests(OPTIONS.testdir, arg))
    if OPTIONS.worklist:
        try:
            with open(OPTIONS.worklist) as f:
                for line in f:
                    test_set.update(os.path.join(OPTIONS.testdir, line.strip("\n")))
        except IOError:
            # With worklist, a missing file means to start the process with
            # the complete list of tests.
            sys.stderr.write(
                "Couldn't read worklist file '%s'; running all tests\n"
                % (OPTIONS.worklist,)
            )
            test_set = set(find_tests(OPTIONS.testdir))
    if OPTIONS.read_tests:
        try:
            with open(OPTIONS.read_tests) as f:
                for line in f:
                    test_set.update(os.path.join(OPTIONS.testdir, line.strip("\n")))
        except IOError as err:
            sys.stderr.write(
                "Error trying to read test file '%s': %s\n" % (OPTIONS.read_tests, err)
            )
            sys.exit(1)

    # If none of the above options were passed, and no tests were listed
    # explicitly, use the complete set.
    if not test_args and not OPTIONS.worklist and not OPTIONS.read_tests:
        test_set = set(find_tests(OPTIONS.testdir))

    if OPTIONS.exclude:
        exclude_set = set()
        for exclude in OPTIONS.exclude:
            exclude_set.update(find_tests(OPTIONS.testdir, exclude))
        test_set -= exclude_set

    if not test_set:
        sys.stderr.write("No tests found matching command line arguments.\n")
        sys.exit(1)

    summary = Summary(len(test_set))
    test_list = [Test(_, summary) for _ in sorted(test_set)]

    # Build the test executable from all the .cpp files found in the test
    # directory tree.
    try:
        build_test_exec(OPTIONS.builddir)
    except subprocess.CalledProcessError as err:
        sys.stderr.write("Error building test executable: %s\n" % (err,))
        sys.exit(1)

    # Run the tests.
    try:
        summary.start()
        run_tests(test_list, summary)
        summary.finish()
    except OSError as err:
        sys.stderr.write("Error running tests: %s\n" % (err,))
        sys.exit(1)

    sys.exit(0)


if __name__ == "__main__":
    main(sys.argv[1:])
