#!/usr/bin/env python3

# Copyright 2014 The Crashpad Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import argparse
import os
import posixpath
import re
import shlex
import subprocess
import sys
import tempfile
import uuid

CRASHPAD_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                            os.pardir)
IS_WINDOWS_HOST = sys.platform.startswith('win')


def _FindGNFromBinaryDir(binary_dir):
    """Attempts to determine the path to a GN binary used to generate the build
    files in the given binary_dir. This is necessary because `gn` might not be
    in the path or might be in a non-standard location, particularly on build
    machines."""

    build_ninja = os.path.join(binary_dir, 'build.ninja')
    if os.path.isfile(build_ninja):
        with open(build_ninja, 'r') as f:
            # Look for the always-generated regeneration rule of the form:
            #
            # rule gn
            #   command = <gn binary> ... arguments ...
            #
            # to extract the gn binary's full path.
            found_rule_gn = False
            for line in f:
                if line.strip() == 'rule gn':
                    found_rule_gn = True
                    continue
                if found_rule_gn:
                    if len(line) == 0 or line[0] != ' ':
                        return None
                    if line.startswith('  command = '):
                        gn_command_line_parts = line.strip().split(' ')
                        if len(gn_command_line_parts) > 2:
                            return os.path.join(binary_dir,
                                                gn_command_line_parts[2])

    return None


def _GetGNArgument(argument_name, binary_dir):
    """Returns the value of a given GN argument, or None if it is not
    explicitly specified."""

    gn_path = _FindGNFromBinaryDir(binary_dir)
    if gn_path:
        # Look for a GN “target_os”.
        popen = subprocess.Popen([
            gn_path, '--root=' + CRASHPAD_DIR, 'args', binary_dir,
            '--list=%s' % argument_name, '--short'
        ],
                                 shell=IS_WINDOWS_HOST,
                                 stdout=subprocess.PIPE,
                                 stderr=open(os.devnull),
                                 text=True)
        value = popen.communicate()[0]
        if popen.returncode == 0:
            match = re.match(r'%s = "(.*)"$' % argument_name, value)
            if match:
                return match.group(1)
    return None


def _EnableVTProcessingOnWindowsConsole():
    """Enables virtual terminal processing for ANSI/VT100-style escape sequences
    on a Windows console attached to standard output. Returns True on success.
    Returns False if standard output is not a console or if virtual terminal
    processing is not supported. The feature was introduced in Windows 10.
    """

    import pywintypes
    import win32console
    import winerror

    stdout_console = win32console.GetStdHandle(win32console.STD_OUTPUT_HANDLE)
    try:
        console_mode = stdout_console.GetConsoleMode()
    except pywintypes.error as e:
        if e.winerror == winerror.ERROR_INVALID_HANDLE:
            # Standard output is not a console.
            return False
        raise

    try:
        # From <wincon.h>. This would be
        # win32console.ENABLE_VIRTUAL_TERMINAL_PROCESSING, but it’s too new to
        # be defined there.
        ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004

        stdout_console.SetConsoleMode(console_mode |
                                      ENABLE_VIRTUAL_TERMINAL_PROCESSING)
    except pywintypes.error as e:
        if e.winerror == winerror.ERROR_INVALID_PARAMETER:
            # ANSI/VT100-style escape sequence processing isn’t supported before
            # Windows 10.
            return False
        raise

    return True


def _RunOnAndroidTarget(binary_dir, test, android_device, extra_command_line):
    local_test_path = os.path.join(binary_dir, test)
    MAYBE_UNSUPPORTED_TESTS = (
        'crashpad_client_test',
        'crashpad_handler_test',
        'crashpad_minidump_test',
        'crashpad_snapshot_test',
    )
    if not os.path.exists(local_test_path) and test in MAYBE_UNSUPPORTED_TESTS:
        print('This test is not present and may not be supported, skipping')
        return

    def _adb(*args):
        # Flush all of this script’s own buffered stdout output before running
        # adb, which will likely produce its own output on stdout.
        sys.stdout.flush()

        adb_command = ['adb', '-s', android_device]
        adb_command.extend(args)
        subprocess.check_call(adb_command, shell=IS_WINDOWS_HOST)

    def _adb_push(sources, destination):
        args = list(sources)
        args.append(destination)
        _adb('push', *args)

    def _adb_shell(command_args, env={}):
        # Build a command to execute via “sh -c” instead of invoking it
        # directly. Here’s why:
        #
        # /system/bin/env isn’t normally present prior to Android 6.0 (M), where
        # toybox was introduced (Android platform/manifest 9a2c01e8450b).
        # Instead, set environment variables by using the shell’s internal
        # “export” command.
        #
        # adbd prior to Android 7.0 (N), and the adb client prior to SDK
        # platform-tools version 24, don’t know how to communicate a shell
        # command’s exit status. This was added in Android platform/system/core
        # 606835ae5c4b). With older adb servers and clients, adb will “exit 0”
        # indicating success even if the command failed on the device. This
        # makes subprocess.check_call() semantics difficult to implement
        # directly. As a workaround, have the device send the command’s exit
        # status over stdout and pick it back up in this function.
        #
        # Both workarounds are implemented by giving the device a simple script,
        # which adbd will run as an “sh -c” argument.
        adb_command = ['adb', '-s', android_device, 'shell']
        script_commands = []
        for k, v in env.items():
            script_commands.append('export %s=%s' %
                                   (shlex.quote(k), shlex.quote(v)))
        script_commands.extend([
            ' '.join(shlex.quote(x) for x in command_args), 'status=${?}',
            'echo "status=${status}"', 'exit ${status}'
        ])
        adb_command.append('; '.join(script_commands))
        child = subprocess.Popen(adb_command,
                                 shell=IS_WINDOWS_HOST,
                                 stdin=open(os.devnull),
                                 stdout=subprocess.PIPE,
                                 text=True)

        FINAL_LINE_RE = re.compile(r'status=(\d+)$')
        final_line = None
        while True:
            # Use readline so that the test output appears “live” when running.
            data = child.stdout.readline()
            if data == '':
                break
            if final_line is not None:
                # It wasn’t really the final line.
                print(final_line, end='')
                final_line = None
            if FINAL_LINE_RE.match(data.rstrip()):
                final_line = data
            else:
                print(data, end='')

        if final_line is None:
            # Maybe there was some stderr output after the end of stdout. Old
            # versions of adb, prior to when the exit status could be
            # communicated, smush the two together.
            raise subprocess.CalledProcessError(-1, adb_command)
        status = int(FINAL_LINE_RE.match(final_line.rstrip()).group(1))
        if status != 0:
            raise subprocess.CalledProcessError(status, adb_command)

        child.wait()
        if child.returncode != 0:
            raise subprocess.CalledProcessError(subprocess.returncode,
                                                adb_command)

    # /system/bin/mktemp isn’t normally present prior to Android 6.0 (M), where
    # toybox was introduced (Android platform/manifest 9a2c01e8450b). Fake it
    # with a host-generated name. This won’t retry if the name is in use, but
    # with 122 bits of randomness, it should be OK. This uses “mkdir” instead of
    # “mkdir -p”because the latter will not indicate failure if the directory
    # already exists.
    device_temp_dir = '/data/local/tmp/%s.%s' % (test, uuid.uuid4().hex)
    _adb_shell(['mkdir', device_temp_dir])

    try:
        # Specify test dependencies that must be pushed to the device. This
        # could be determined automatically in a GN build, following the example
        # used for Fuchsia. Since nothing like that exists for GYP, hard-code it
        # for supported tests.
        test_build_artifacts = [test, 'crashpad_handler']
        test_data = ['test/test_paths_test_data_root.txt']

        if test == 'crashpad_test_test':
            test_build_artifacts.append(
                'crashpad_test_test_multiprocess_exec_test_child')
        elif test == 'crashpad_util_test':
            test_data.append('util/net/testdata/')

        # Establish the directory structure on the device.
        device_out_dir = posixpath.join(device_temp_dir, 'out')
        device_mkdirs = [device_out_dir]
        for source_path in test_data:
            # A trailing slash could reasonably mean to copy an entire
            # directory, but will interfere with what’s needed from the path
            # split. All parent directories of any source_path need to be be
            # represented in device_mkdirs, but it’s important that no
            # source_path itself wind up in device_mkdirs, even if source_path
            # names a directory, because that would cause the “adb push” of the
            # directory below to behave incorrectly.
            if source_path.endswith(posixpath.sep):
                source_path = source_path[:-1]

            device_source_path = posixpath.join(device_temp_dir, source_path)
            device_mkdir = posixpath.split(device_source_path)[0]
            if device_mkdir not in device_mkdirs:
                device_mkdirs.append(device_mkdir)
        adb_mkdir_command = ['mkdir', '-p']
        adb_mkdir_command.extend(device_mkdirs)
        _adb_shell(adb_mkdir_command)

        # Push the test binary and any other build output to the device.
        local_test_build_artifacts = []
        for artifact in test_build_artifacts:
            local_test_build_artifacts.append(os.path.join(
                binary_dir, artifact))
        _adb_push(local_test_build_artifacts, device_out_dir)

        # Push test data to the device.
        for source_path in test_data:
            _adb_push([os.path.join(CRASHPAD_DIR, source_path)],
                      posixpath.join(device_temp_dir, source_path))

        # Run the test on the device. Pass the test data root in the
        # environment.
        #
        # Because the test will not run with its standard output attached to a
        # pseudo-terminal device, Google Test will not normally enable colored
        # output, so mimic Google Test’s own logic for deciding whether to
        # enable color by checking this script’s own standard output connection.
        # The list of TERM values comes from Google Test’s
        # googletest/src/gtest.cc testing::internal::ShouldUseColor().
        env = {'CRASHPAD_TEST_DATA_ROOT': device_temp_dir}
        gtest_color = os.environ.get('GTEST_COLOR')
        if gtest_color in ('auto', None):
            if (sys.stdout.isatty() and
                (os.environ.get('TERM')
                 in ('xterm', 'xterm-color', 'xterm-256color', 'screen',
                     'screen-256color', 'tmux', 'tmux-256color', 'rxvt-unicode',
                     'rxvt-unicode-256color', 'linux', 'cygwin') or
                 (IS_WINDOWS_HOST and _EnableVTProcessingOnWindowsConsole()))):
                gtest_color = 'yes'
            else:
                gtest_color = 'no'
        env['GTEST_COLOR'] = gtest_color
        _adb_shell([posixpath.join(device_out_dir, test)] + extra_command_line,
                   env)
    finally:
        _adb_shell(['rm', '-rf', device_temp_dir])


def _RunOnIOSTarget(binary_dir,
                    test,
                    target_platform,
                    is_xcuitest=False,
                    gtest_filter=None):
    """Runs the given iOS |test| app on a simulator with the default OS version."""

    target_platform = target_platform or 'iphoneos'
    if target_platform == 'iphoneos':
        dyld_insert_libraries = (
            '__PLATFORMS__/iPhoneSimulator.platform/Developer/usr/lib/'
            'libXCTestBundleInject.dylib')
        xcodebuild_platform = 'iOS Simulator'
        xcodebuild_device_name = 'iPhone 15'
    elif target_platform == 'tvos':
        dyld_insert_libraries = (
            '__PLATFORMS__/AppleTVSimulator.platform/Developer/usr/lib/'
            'libXCTestBundleInject.dylib')
        xcodebuild_platform = 'tvOS Simulator'
        xcodebuild_device_name = 'Apple TV 4K (3rd generation)'
    else:
        raise ValueError(f'Unexpected target_platform: {target_platform}')

    # E.g. __TESTROOT__/Debug-iphonesimulator.
    dyld_framework_path = '__TESTROOT__/%s' % os.path.basename(binary_dir)

    def xctest(binary_dir, test, gtest_filter=None):
        """Returns a dict containing the xctestrun data needed to run an
        XCTest-based test app."""
        test_path = os.path.join(CRASHPAD_DIR, binary_dir)
        module_data = {
            'TestBundlePath': os.path.join(test_path, test + '_module.xctest'),
            'TestHostPath': os.path.join(test_path, test + '.app'),
            'TestingEnvironmentVariables': {
                'DYLD_FRAMEWORK_PATH': dyld_framework_path + ':',
                'DYLD_INSERT_LIBRARIES': dyld_insert_libraries,
                'DYLD_LIBRARY_PATH': dyld_framework_path,
                'IDEiPhoneInternalTestBundleName': test + '.app',
                'XCInjectBundleInto': '__TESTHOST__/' + test,
            }
        }
        if gtest_filter:
            module_data['CommandLineArguments'] = [
                '--gtest_filter=' + gtest_filter
            ]
        return {test: module_data}

    def xcuitest(binary_dir, test):
        """Returns a dict containing the xctestrun data needed to run an
        XCUITest-based test app."""

        test_path = os.path.join(CRASHPAD_DIR, binary_dir)
        runner_path = os.path.join(test_path, test + '_module-Runner.app')
        bundle_path = os.path.join(runner_path, 'PlugIns',
                                   test + '_module.xctest')
        target_app_path = os.path.join(test_path, test + '.app')
        module_data = {
            'IsUITestBundle': True,
            'SystemAttachmentLifetime': 'deleteOnSuccess',
            'IsXCTRunnerHostedTestBundle': True,
            'TestBundlePath': bundle_path,
            'TestHostPath': runner_path,
            'UITargetAppPath': target_app_path,
            'DependentProductPaths': [
                bundle_path, runner_path, target_app_path
            ],
            'TestingEnvironmentVariables': {
                'DYLD_FRAMEWORK_PATH': dyld_framework_path + ':',
                'DYLD_INSERT_LIBRARIES': dyld_insert_libraries,
                'DYLD_LIBRARY_PATH': dyld_framework_path,
                'XCInjectBundleInto': '__TESTHOST__/' + test + '_module-Runner',
            },
        }
        return {test: module_data}

    with tempfile.NamedTemporaryFile() as f:
        import plistlib

        xctestrun_path = f.name + ".xctestrun"
        print(xctestrun_path)
        command = [
            'xcodebuild',
            'test-without-building',
            '-xctestrun',
            xctestrun_path,
            '-destination',
            f'platform={xcodebuild_platform},name={xcodebuild_device_name}',
        ]
        with open(xctestrun_path, 'wb') as fp:
            if is_xcuitest:
                plistlib.dump(xcuitest(binary_dir, test), fp)
                if gtest_filter:
                    command.append('-only-testing:' + test + '/' + gtest_filter)
            else:
                plistlib.dump(xctest(binary_dir, test, gtest_filter), fp)
        subprocess.check_call(command)


# This script is primarily used from the waterfall so that the list of tests
# that are run is maintained in-tree, rather than in a separate infrastructure
# location in the recipe.
def main(args):
    parser = argparse.ArgumentParser(description='Run Crashpad unittests.')
    parser.add_argument('binary_dir', help='Root of build dir')
    parser.add_argument('test', nargs='*', help='Specific test(s) to run.')
    parser.add_argument(
        '--gtest_filter',
        help='Google Test filter applied to Google Test binary runs.')
    args = parser.parse_args()

    # Tell 64-bit Windows tests where to find 32-bit test executables, for
    # cross-bitted testing. This relies on the fact that the GYP build by
    # default uses {Debug,Release} for the 32-bit build and {Debug,Release}_x64
    # for the 64-bit build. This is not a universally valid assumption, and if
    # it’s not met, 64-bit tests that require 32-bit build output will disable
    # themselves dynamically.
    if (sys.platform == 'win32' and args.binary_dir.endswith('_x64') and
            'CRASHPAD_TEST_32_BIT_OUTPUT' not in os.environ):
        binary_dir_32 = args.binary_dir[:-4]
        if os.path.isdir(binary_dir_32):
            os.environ['CRASHPAD_TEST_32_BIT_OUTPUT'] = binary_dir_32

    target_os = _GetGNArgument('target_os', args.binary_dir)
    is_android = target_os == 'android'
    is_ios = target_os == 'ios'
    # |target_platform| is only set for iOS-based platforms.
    target_platform = _GetGNArgument('target_platform', args.binary_dir)

    tests = [
        'crashpad_client_test',
        'crashpad_handler_test',
        'crashpad_minidump_test',
        'crashpad_snapshot_test',
        'crashpad_test_test',
        'crashpad_util_test',
    ]

    if is_android:
        android_device = os.environ.get('ANDROID_DEVICE')
        if not android_device:
            adb_devices = subprocess.check_output(['adb', 'devices'],
                                                  shell=IS_WINDOWS_HOST,
                                                  text=True)
            devices = []
            for line in adb_devices.splitlines():
                line = line
                if (line == 'List of devices attached' or
                        re.match(r'^\* daemon .+ \*$', line) or line == ''):
                    continue
                (device, ignore) = line.split('\t')
                devices.append(device)
            if len(devices) != 1:
                print("Please set ANDROID_DEVICE to your device's id",
                      file=sys.stderr)
                return 2
            android_device = devices[0]
            print('Using autodetected Android device:', android_device)
    elif is_ios:
        tests.append('ios_crash_xcuitests')
    elif IS_WINDOWS_HOST:
        tests.append('snapshot/win/end_to_end_test.py')

    if args.test:
        for t in args.test:
            if t not in tests:
                print('Unrecognized test:', t, file=sys.stderr)
                return 3
        tests = args.test

    for test in tests:
        print('-' * 80)
        print(test)
        print('-' * 80)
        if test.endswith('.py'):
            subprocess.check_call([
                sys.executable,
                os.path.join(CRASHPAD_DIR, test), args.binary_dir
            ])
        else:
            extra_command_line = []
            if args.gtest_filter:
                extra_command_line.append('--gtest_filter=' + args.gtest_filter)
            if is_android:
                _RunOnAndroidTarget(args.binary_dir, test, android_device,
                                    extra_command_line)
            elif is_ios:
                _RunOnIOSTarget(args.binary_dir,
                                test,
                                target_platform,
                                is_xcuitest=test.startswith('ios'),
                                gtest_filter=args.gtest_filter)
            else:
                subprocess.check_call([os.path.join(args.binary_dir, test)] +
                                      extra_command_line)

    return 0


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