普通文本  |  593行  |  26.09 KB

#!/usr/bin/env python3
#  Copyright 2016 Google Inc. All Rights Reserved.
#
# 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 os
import tempfile
import unittest
import textwrap
import re
import sys

import itertools

import subprocess

import pytest

from fruit_test_config import *

run_under_valgrind = RUN_TESTS_UNDER_VALGRIND.lower() not in ('false', 'off', 'no', '0', '')

def pretty_print_command(command):
    return ' '.join('"' + x + '"' for x in command)

class CommandFailedException(Exception):
    def __init__(self, command, stdout, stderr, error_code):
        self.command = command
        self.stdout = stdout
        self.stderr = stderr
        self.error_code = error_code

    def __str__(self):
        return textwrap.dedent('''\
        Ran command: {command}
        Exit code {error_code}
        Stdout:
        {stdout}

        Stderr:
        {stderr}
        ''').format(command=pretty_print_command(self.command), error_code=self.error_code, stdout=self.stdout, stderr=self.stderr)

def run_command(executable, args=[], modify_env=lambda env: env):
    command = [executable] + args
    modified_env = modify_env(os.environ)
    print('Executing command:', pretty_print_command(command))
    try:
        p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, env=modified_env)
        (stdout, stderr) = p.communicate()
    except Exception as e:
        raise Exception("While executing: %s" % command)
    if p.returncode != 0:
        raise CommandFailedException(command, stdout, stderr, p.returncode)
    print('Execution successful.')
    print('stdout:')
    print(stdout)
    print('')
    print('stderr:')
    print(stderr)
    print('')
    return (stdout, stderr)

def run_compiled_executable(executable):
    if run_under_valgrind:
        args = VALGRIND_FLAGS.split() + [executable]
        run_command('valgrind', args = args, modify_env = modify_env_for_compiled_executables)
    else:
        run_command(executable, modify_env = modify_env_for_compiled_executables)

class CompilationFailedException(Exception):
    def __init__(self, command, error_message):
        self.command = command
        self.error_message = error_message

    def __str__(self):
        return textwrap.dedent('''\
        Ran command: {command}
        Error message:
        {error_message}
        ''').format(command=pretty_print_command(self.command), error_message=self.error_message)

class PosixCompiler:
    def __init__(self):
        self.executable = CXX
        self.name = CXX_COMPILER_NAME

    def compile_discarding_output(self, source, include_dirs, args=[]):
        try:
            args = args + ['-c', source, '-o', os.path.devnull]
            self._compile(include_dirs, args=args)
        except CommandFailedException as e:
            raise CompilationFailedException(e.command, e.stderr)

    def compile_and_link(self, source, include_dirs, output_file_name, args=[]):
        self._compile(
            include_dirs,
            args = (
                [source]
                + ADDITIONAL_LINKER_FLAGS.split()
                + args
                + ['-o', output_file_name]
            ))

    def _compile(self, include_dirs, args):
        include_flags = ['-I%s' % include_dir for include_dir in include_dirs]
        args = (
            FRUIT_COMPILE_FLAGS.split()
            + include_flags
            + ['-g0', '-Werror']
            + args
        )
        run_command(self.executable, args)

    def get_disable_deprecation_warning_flags(self):
        return ['-Wno-deprecated-declarations']

    def get_disable_all_warnings_flags(self):
        return ['-Wno-error']

class MsvcCompiler:
    def __init__(self):
        self.executable = CXX
        self.name = CXX_COMPILER_NAME

    def compile_discarding_output(self, source, include_dirs, args=[]):
        try:
            args = args + ['/c', source]
            self._compile(include_dirs, args = args)
        except CommandFailedException as e:
            # Note that we use stdout here, unlike above. MSVC reports compilation warnings and errors on stdout.
            raise CompilationFailedException(e.command, e.stdout)

    def compile_and_link(self, source, include_dirs, output_file_name, args=[]):
        self._compile(
            include_dirs,
            args = (
                [source]
                + ADDITIONAL_LINKER_FLAGS.split()
                + args
                + ['/Fe' + output_file_name]
            ))

    def _compile(self, include_dirs, args):
        include_flags = ['-I%s' % include_dir for include_dir in include_dirs]
        args = (
            FRUIT_COMPILE_FLAGS.split()
            + include_flags
            + ['/WX']
            + args
        )
        run_command(self.executable, args)

    def get_disable_deprecation_warning_flags(self):
        return ['/wd4996']

    def get_disable_all_warnings_flags(self):
        return ['/WX:NO']

if CXX_COMPILER_NAME == 'MSVC':
    compiler = MsvcCompiler()
    if PATH_TO_COMPILED_FRUIT_LIB.endswith('.dll'):
        path_to_fruit_lib = PATH_TO_COMPILED_FRUIT_LIB[:-4] + '.lib'
    else:
        path_to_fruit_lib = PATH_TO_COMPILED_FRUIT_LIB
    fruit_tests_linker_flags = [path_to_fruit_lib]
    fruit_error_message_extraction_regex = 'error C2338: (.*)'
else:
    compiler = PosixCompiler()
    fruit_tests_linker_flags = [
        '-lfruit',
        '-L' + PATH_TO_COMPILED_FRUIT,
        '-Wl,-rpath,' + PATH_TO_COMPILED_FRUIT,
    ]
    fruit_error_message_extraction_regex = 'static.assert(.*)'

fruit_tests_include_dirs = ADDITIONAL_INCLUDE_DIRS.splitlines() + [
    PATH_TO_FRUIT_TEST_HEADERS,
    PATH_TO_FRUIT_STATIC_HEADERS,
    PATH_TO_FRUIT_GENERATED_HEADERS,
]

_assert_helper = unittest.TestCase()

def modify_env_for_compiled_executables(env):
    env = env.copy()
    path_to_fruit_lib_dir = os.path.dirname(PATH_TO_COMPILED_FRUIT_LIB)
    print('PATH_TO_COMPILED_FRUIT_LIB:', PATH_TO_COMPILED_FRUIT_LIB)
    print('Adding directory to PATH:', path_to_fruit_lib_dir)
    env["PATH"] += os.pathsep + path_to_fruit_lib_dir
    return env

def _create_temporary_file(file_content, file_name_suffix=''):
    file_descriptor, file_name = tempfile.mkstemp(text=True, suffix=file_name_suffix)
    file = os.fdopen(file_descriptor, mode='w')
    file.write(file_content)
    file.close()
    return file_name

def _cap_to_lines(s, n):
    lines = s.splitlines()
    if len(lines) <= n:
        return s
    else:
        return '\n'.join(lines[0:n] + ['...'])

def _replace_using_test_params(s, test_params):
    for var_name, value in test_params.items():
        if isinstance(value, str):
            s = re.sub(r'\b%s\b' % var_name, value, s)
    return s

def _construct_final_source_code(setup_source_code, source_code, test_params):
    setup_source_code = textwrap.dedent(setup_source_code)
    source_code = textwrap.dedent(source_code)
    source_code = _replace_using_test_params(source_code, test_params)
    return setup_source_code + source_code

def try_remove_temporary_file(filename):
    try:
        os.remove(filename)
    except:
        # When running Fruit tests on Windows using Appveyor, the remove command fails for temporary files sometimes.
        # This shouldn't cause the tests to fail, so we ignore the exception and go ahead.
        pass

def normalize_error_message_lines(lines):
    # Different compilers output a different number of spaces when pretty-printing types.
    # When using libc++, sometimes std::foo identifiers are reported as std::__1::foo.
    return [line.replace(' ', '').replace('std::__1::', 'std::') for line in lines]

def expect_compile_error_helper(
        check_error_fun,
        setup_source_code,
        source_code,
        test_params={},
        ignore_deprecation_warnings=False,
        ignore_warnings=False):
    source_code = _construct_final_source_code(setup_source_code, source_code, test_params)

    source_file_name = _create_temporary_file(source_code, file_name_suffix='.cpp')

    try:
        args = []
        if ignore_deprecation_warnings:
            args += compiler.get_disable_deprecation_warning_flags()
        if ignore_warnings:
            args += compiler.get_disable_all_warnings_flags()
        if ENABLE_COVERAGE:
            # When collecting coverage these arguments are enabled by default; however we must disable them in tests
            # expected to fail at compile-time because GCC would otherwise fail with an error like:
            # /tmp/tmp4m22cey7.cpp:1:0: error: cannot open /dev/null.gcno
            args += ['-fno-profile-arcs', '-fno-test-coverage']
        compiler.compile_discarding_output(
            source=source_file_name,
            include_dirs=fruit_tests_include_dirs,
            args=args)
        raise Exception('The test should have failed to compile, but it compiled successfully')
    except CompilationFailedException as e1:
        e = e1

    error_message = e.error_message
    error_message_lines = error_message.splitlines()
    error_message_lines = error_message.splitlines()
    error_message_head = _cap_to_lines(error_message, 40)

    check_error_fun(e, error_message_lines, error_message_head)

    try_remove_temporary_file(source_file_name)

def apply_any_error_context_replacements(error_string, following_lines):
    if CXX_COMPILER_NAME == 'MSVC':
        # MSVC errors are of the form:
        #
        # C:\Path\To\header\foo.h(59): note: see reference to class template instantiation 'fruit::impl::NoBindingFoundError<fruit::Annotated<Annotation,U>>' being compiled
        #         with
        #         [
        #              Annotation=Annotation1,
        #              U=std::function<std::unique_ptr<ScalerImpl,std::default_delete<ScalerImpl>> (double)>
        #         ]
        #
        # So we need to parse the following few lines and use them to replace the placeholder types in the Fruit error type.
        replacement_lines = []
        if len(following_lines) >= 4 and following_lines[0].strip() == 'with':
            assert following_lines[1].strip() == '[', 'Line was: ' + following_lines[1]
            for line in itertools.islice(following_lines, 2, None):
                line = line.strip()
                if line == ']':
                    break
                if line.endswith(','):
                    line = line[:-1]
                replacement_lines.append(line)

        for replacement_line in replacement_lines:
            match = re.search('([A-Za-z0-9_-]*)=(.*)', replacement_line)
            if not match:
                raise Exception('Failed to parse replacement line: %s' % replacement_line)
            (type_variable, type_expression) = match.groups()
            error_string = re.sub(r'\b' + type_variable + r'\b', type_expression, error_string)
    return error_string

def expect_generic_compile_error(expected_error_regex, setup_source_code, source_code, test_params={}):
    """
    Tests that the given source produces the expected error during compilation.

    :param expected_fruit_error_regex: A regex used to match the Fruit error type,
           e.g. 'NoBindingFoundForAbstractClassError<ScalerImpl>'.
           Any identifiers contained in the regex will be replaced using test_params (where a replacement is defined).
    :param expected_fruit_error_desc_regex: A regex used to match the Fruit error description,
           e.g. 'No explicit binding was found for C, and C is an abstract class'.
    :param setup_source_code: The first part of the source code. This is dedented separately from source_code and it's
           *not* subject to test_params, unlike source_code.
    :param source_code: The second part of the source code. Any identifiers will be replaced using test_params
           (where a replacement is defined). This will be dedented.
    :param test_params: A dict containing the definition of some identifiers. Each identifier in
           expected_fruit_error_regex and source_code will be replaced (textually) with its definition (if a definition
           was provided).
    """

    expected_error_regex = _replace_using_test_params(expected_error_regex, test_params)
    expected_error_regex = expected_error_regex.replace(' ', '')

    def check_error(e, error_message_lines, error_message_head):
        error_message_lines_with_replacements = [
            apply_any_error_context_replacements(line, error_message_lines[line_number + 1:])
            for line_number, line in enumerate(error_message_lines)]

        normalized_error_message_lines = normalize_error_message_lines(error_message_lines_with_replacements)

        for line in normalized_error_message_lines:
            if re.search(expected_error_regex, line):
                return
        raise Exception(textwrap.dedent('''\
            Expected error {expected_error} but the compiler output did not contain that.
            Compiler command line: {compiler_command}
            Error message was:
            {error_message}
            ''').format(expected_error = expected_error_regex, compiler_command=e.command, error_message = error_message_head))

    expect_compile_error_helper(check_error, setup_source_code, source_code, test_params)

def expect_compile_error(
        expected_fruit_error_regex,
        expected_fruit_error_desc_regex,
        setup_source_code,
        source_code,
        test_params={},
        ignore_deprecation_warnings=False,
        ignore_warnings=False,
        disable_error_line_number_check=False):
    """
    Tests that the given source produces the expected error during compilation.

    :param expected_fruit_error_regex: A regex used to match the Fruit error type,
           e.g. 'NoBindingFoundForAbstractClassError<ScalerImpl>'.
           Any identifiers contained in the regex will be replaced using test_params (where a replacement is defined).
    :param expected_fruit_error_desc_regex: A regex used to match the Fruit error description,
           e.g. 'No explicit binding was found for C, and C is an abstract class'.
    :param setup_source_code: The first part of the source code. This is dedented separately from source_code and it's
           *not* subject to test_params, unlike source_code.
    :param source_code: The second part of the source code. Any identifiers will be replaced using test_params
           (where a replacement is defined). This will be dedented.
    :param test_params: A dict containing the definition of some identifiers. Each identifier in
           expected_fruit_error_regex and source_code will be replaced (textually) with its definition (if a definition
           was provided).
    :param ignore_deprecation_warnings: A boolean. If True, deprecation warnings will be ignored.
    :param ignore_warnings: A boolean. If True, all warnings will be ignored.
    :param disable_error_line_number_check: A boolean. If True, the test will not fail if there are other diagnostic
           lines before the expected error.
    """
    if '\n' in expected_fruit_error_regex:
        raise Exception('expected_fruit_error_regex should not contain newlines')
    if '\n' in expected_fruit_error_desc_regex:
        raise Exception('expected_fruit_error_desc_regex should not contain newlines')

    expected_fruit_error_regex = _replace_using_test_params(expected_fruit_error_regex, test_params)
    expected_fruit_error_regex = expected_fruit_error_regex.replace(' ', '')

    def check_error(e, error_message_lines, error_message_head):
        normalized_error_message_lines = normalize_error_message_lines(error_message_lines)

        for line_number, line in enumerate(normalized_error_message_lines):
            match = re.search('fruit::impl::(.*Error<.*>)', line)
            if match:
                actual_fruit_error_line_number = line_number
                actual_fruit_error = match.groups()[0]
                actual_fruit_error = apply_any_error_context_replacements(actual_fruit_error, normalized_error_message_lines[line_number + 1:])
                break
        else:
            raise Exception(textwrap.dedent('''\
                Expected error {expected_error} but the compiler output did not contain user-facing Fruit errors.
                Compiler command line: {compiler_command}
                Error message was:
                {error_message}
                ''').format(expected_error = expected_fruit_error_regex, compiler_command = e.command, error_message = error_message_head))

        for line_number, line in enumerate(error_message_lines):
            match = re.search(fruit_error_message_extraction_regex, line)
            if match:
                actual_static_assert_error_line_number = line_number
                actual_static_assert_error = match.groups()[0]
                break
        else:
            raise Exception(textwrap.dedent('''\
                Expected error {expected_error} but the compiler output did not contain static_assert errors.
                Compiler command line: {compiler_command}
                Error message was:
                {error_message}
                ''').format(expected_error = expected_fruit_error_regex, compiler_command=e.command, error_message = error_message_head))

        try:
            regex_search_result = re.search(expected_fruit_error_regex, actual_fruit_error)
        except Exception as e:
            raise Exception('re.search() failed for regex \'%s\'' % expected_fruit_error_regex) from e
        if not regex_search_result:
            raise Exception(textwrap.dedent('''\
                The compilation failed as expected, but with a different error type.
                Expected Fruit error type:    {expected_fruit_error_regex}
                Error type was:               {actual_fruit_error}
                Expected static assert error: {expected_fruit_error_desc_regex}
                Static assert was:            {actual_static_assert_error}
                Error message was:
                {error_message}
                '''.format(
                expected_fruit_error_regex = expected_fruit_error_regex,
                actual_fruit_error = actual_fruit_error,
                expected_fruit_error_desc_regex = expected_fruit_error_desc_regex,
                actual_static_assert_error = actual_static_assert_error,
                error_message = error_message_head)))
        try:
            regex_search_result = re.search(expected_fruit_error_desc_regex, actual_static_assert_error)
        except Exception as e:
            raise Exception('re.search() failed for regex \'%s\'' % expected_fruit_error_desc_regex) from e
        if not regex_search_result:
            raise Exception(textwrap.dedent('''\
                The compilation failed as expected, but with a different error message.
                Expected Fruit error type:    {expected_fruit_error_regex}
                Error type was:               {actual_fruit_error}
                Expected static assert error: {expected_fruit_error_desc_regex}
                Static assert was:            {actual_static_assert_error}
                Error message:
                {error_message}
                '''.format(
                expected_fruit_error_regex = expected_fruit_error_regex,
                actual_fruit_error = actual_fruit_error,
                expected_fruit_error_desc_regex = expected_fruit_error_desc_regex,
                actual_static_assert_error = actual_static_assert_error,
                error_message = error_message_head)))

        # 6 is just a constant that works for both g++ (<=6.0.0 at least) and clang++ (<=4.0.0 at least).
        # It might need to be changed.
        if not disable_error_line_number_check and (actual_fruit_error_line_number > 6 or actual_static_assert_error_line_number > 6):
            raise Exception(textwrap.dedent('''\
                The compilation failed with the expected message, but the error message contained too many lines before the relevant ones.
                The error type was reported on line {actual_fruit_error_line_number} of the message (should be <=6).
                The static assert was reported on line {actual_static_assert_error_line_number} of the message (should be <=6).
                Error message:
                {error_message}
                '''.format(
                actual_fruit_error_line_number = actual_fruit_error_line_number,
                actual_static_assert_error_line_number = actual_static_assert_error_line_number,
                error_message = error_message_head)))

        for line in error_message_lines[:max(actual_fruit_error_line_number, actual_static_assert_error_line_number)]:
            if re.search('fruit::impl::meta', line):
                raise Exception(
                    'The compilation failed with the expected message, but the error message contained some metaprogramming types in the output (besides Error). Error message:\n%s' + error_message_head)

    expect_compile_error_helper(check_error, setup_source_code, source_code, test_params, ignore_deprecation_warnings, ignore_warnings)


def expect_runtime_error(
        expected_error_regex,
        setup_source_code,
        source_code,
        test_params={},
        ignore_deprecation_warnings=False):
    """
    Tests that the given source (compiles successfully and) produces the expected error at runtime.

    :param expected_error_regex: A regex used to match the content of stderr.
           Any identifiers contained in the regex will be replaced using test_params (where a replacement is defined).
    :param setup_source_code: The first part of the source code. This is dedented separately from source_code and it's
           *not* subject to test_params, unlike source_code.
    :param source_code: The second part of the source code. Any identifiers will be replaced using test_params
           (where a replacement is defined). This will be dedented.
    :param test_params: A dict containing the definition of some identifiers. Each identifier in
           expected_error_regex and source_code will be replaced (textually) with its definition (if a definition
           was provided).
    """
    expected_error_regex = _replace_using_test_params(expected_error_regex, test_params)
    source_code = _construct_final_source_code(setup_source_code, source_code, test_params)

    source_file_name = _create_temporary_file(source_code, file_name_suffix='.cpp')
    executable_suffix = {'posix': '', 'nt': '.exe'}[os.name]
    output_file_name = _create_temporary_file('', executable_suffix)

    args = fruit_tests_linker_flags.copy()
    if ignore_deprecation_warnings:
        args += compiler.get_disable_deprecation_warning_flags()
    compiler.compile_and_link(
        source=source_file_name,
        include_dirs=fruit_tests_include_dirs,
        output_file_name=output_file_name,
        args=args)

    try:
        run_compiled_executable(output_file_name)
        raise Exception('The test should have failed at runtime, but it ran successfully')
    except CommandFailedException as e1:
        e = e1

    stderr = e.stderr
    stderr_head = _cap_to_lines(stderr, 40)

    if '\n' in expected_error_regex:
        regex_flags = re.MULTILINE
    else:
        regex_flags = 0

    try:
        regex_search_result = re.search(expected_error_regex, stderr, flags=regex_flags)
    except Exception as e:
        raise Exception('re.search() failed for regex \'%s\'' % expected_error_regex) from e
    if not regex_search_result:
        raise Exception(textwrap.dedent('''\
            The test failed as expected, but with a different message.
            Expected: {expected_error_regex}
            Was:
            {stderr}
            '''.format(expected_error_regex = expected_error_regex, stderr = stderr_head)))

    # Note that we don't delete the temporary files if the test failed. This is intentional, keeping them around helps debugging the failure.
    if not ENABLE_COVERAGE:
        try_remove_temporary_file(source_file_name)
        try_remove_temporary_file(output_file_name)


def expect_success(setup_source_code, source_code, test_params={}, ignore_deprecation_warnings=False):
    """
    Tests that the given source compiles and runs successfully.

    :param setup_source_code: The first part of the source code. This is dedented separately from source_code and it's
           *not* subject to test_params, unlike source_code.
    :param source_code: The second part of the source code. Any identifiers will be replaced using test_params
           (where a replacement is defined). This will be dedented.
    :param test_params: A dict containing the definition of some identifiers. Each identifier in
           source_code will be replaced (textually) with its definition (if a definition was provided).
    """
    source_code = _construct_final_source_code(setup_source_code, source_code, test_params)

    if 'main(' not in source_code:
        source_code += textwrap.dedent('''
            int main() {
            }
            ''')

    source_file_name = _create_temporary_file(source_code, file_name_suffix='.cpp')
    executable_suffix = {'posix': '', 'nt': '.exe'}[os.name]
    output_file_name = _create_temporary_file('', executable_suffix)

    args = fruit_tests_linker_flags.copy()
    if ignore_deprecation_warnings:
        args += compiler.get_disable_deprecation_warning_flags()
    compiler.compile_and_link(
        source=source_file_name,
        include_dirs=fruit_tests_include_dirs,
        output_file_name=output_file_name,
        args=args)

    run_compiled_executable(output_file_name)

    # Note that we don't delete the temporary files if the test failed. This is intentional, keeping them around helps debugging the failure.
    if not ENABLE_COVERAGE:
        try_remove_temporary_file(source_file_name)
        try_remove_temporary_file(output_file_name)


# Note: this is not the main function of this file, it's meant to be used as main function from test_*.py files.
def main(file):
    code = pytest.main(args = sys.argv + [os.path.realpath(file)])
    exit(code)