common 20.6 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
#!/bin/false /bin/bash
##
# Common functions used by the test code.
#

set -eo pipefail

scripts="$(cd "$(dirname "${BASH_SOURCE[0]}")" > /dev/null && pwd -P)"
root="$(cd "$(dirname "${scripts}")" > /dev/null && pwd -P)"


##
# Convert a string to a boolean return code
function bool() {
    local value
    value="$(tr '[:upper:]' '[:lower:]' <<<"$1")"

    if [[ '-yes-true-on-1-enable-enabled-' =~ -${value}- ]] ; then
        return 0
    elif [[ '-no-false-off-0-disable-disabled-' =~ -${value}- ]] ; then
        return 1
    fi

    # Put a warning out on stderr so that we know.
    echo "Boolean parameter '$1' not recognised" >&2

    return 1
}

30

31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
##
# Trim leading and trailing whitespace from a string.
#
# @param $1     String to trim
function trim() {
    local var="$1"

    # remove leading whitespace characters
    var="${var#"${var%%[![:space:]]*}"}"

    # remove trailing whitespace characters
    var="${var%"${var##*[![:space:]]}"}"

    echo -n "$var"
}


48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
##
# Read a configuration parameter from the project's config.
#
# Option '-e' can be used to expand wildcards.
# Option '-b' can be used to return a boolean state for the string.
#
# @param $1 The key to read from the configuration file
# @param $2 The default value to return if the config is not set
function config() {
    if [ "$1" = '-e' ] ; then
        shift
        expand_filenames "$(config "$@")"
        return
    elif [ "$1" = '-b' ] ; then
        shift
        bool "$(config "$@")"
        return
    fi

    local key="$1"
    local default="${2:-}"

70
    if [[ ! -f "${config_file}" ]] ; then
71 72
        # No configuration file
        echo "${default}"
73
    elif grep -q "^${key}: \?" "${config_file}" ; then
74
        # Key found, so we can return it
75
        grep "^${key}: \?" "${config_file}" | sed "s/^${key}: *//" || true
76 77 78 79 80 81 82 83 84 85
    else
        # Key not found in configuration file
        echo "${default}"
    fi
}


##
# Expand a set of filenames from wildcards.
#
86 87 88 89 90 91 92
# This can be either:
#   * A wildcarded filename in shell style.
#     Examples: *.py foo/*.py
#
#   * A language name in braces to restrict to that type, followed by a directory to search within
#     Examples: {python} {python}foo
#
93
# @param $@     filenames with wildcards
94 95
#
# @return filename per line on stdout
96
function expand_filenames() {
97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156
    local specs="$*"
    local lang=''
    local dir=''
    local exts
    local magic
    local find_match
    local spec

    for spec in $specs ; do
        if [[ "${spec:0:1}" == '{' ]] ; then
            # This is a language specification
            lang="${spec:1}"
            lang="${lang%%\}*}"
            dir="${spec#*\}}"
            exts="$(call_if_function "${lang}_match_extensions")"
            magic="$(call_if_function "${lang}_match_magic")"

            # Construct the specification for find
            find_match=()
            for ext in $exts ; do
                if [[ "${#find_match[@]}" != '0' ]] ; then
                    find_match+=('-o')
                fi
                find_match+=('-name' "*.$ext")
            done

            # List all the requested extensions
            if [[ "${#find_match[@]}" != '0' ]] ; then
                find "./$dir" -type f "${find_match[@]}" \
                    | (grep -E -v '(/\.|^\.//*ci/)' || true)
            fi

            # List all the files matching requested magic
            if [[ "${magic}" != '' ]] ; then
                if [[ "${#find_match[@]}" != '0' ]] ; then
                    find_match=('!' '(' "${find_match[@]}" ')')
                else
                    find_match=()
                fi

                # Find files that do not match the extensions, are not in the
                # special or hidden directories, and which match the magic
                # output.
                find "./$dir" -type f -size -100k "${find_match[@]}" -print0 \
                    | (grep -E -Z -z -v '(/\.|^\.//*ci/)' || true) \
                    | xargs -0 file -N -n -F':::' /dev/null \
                    | sed -E "/${magic}/! d; s/::: .*//"
            fi
        else
            (
                # If nothing matches a wildcard string, return empty
                shopt -s nullglob

                # Note: Cannot use 'shopt -s globstar' to allow '**' to be subdirectory match.
                #       It isn't supported on OSX.

                eval echo "$spec"
            )
        fi
    done | sed '/^$/d ; s!//*!/!g ; s!^\./!!' | sort -u
157 158
}

159

160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254
##
# Find the tool configuration for a given action.
#
# Most actions require a particular tool to be used.
# These can be configured in the project configuration file and will
# fall back to the language configuration.#
#
# project.config:
#   <action>_<language>_tool
# common.<language> variable:
#   <language>_tool_<action>
#
# The ordering of names it in line with each file's definitions,
# based on how the files are to be used.
#
# Commonly, the tool configuration will contain either:
#   <module-name>
# OR
#   <module-name> <version>
#
# The expansion of these names can be performed with the function
# 'tool_config_expansion'.
function tool_expansion() {
    local lang="$1"
    local action="$2"
    local toolvar="${lang}_tool_${action}"
    local default="${!toolvar}"
    config "${action}_${lang}_tool" "$default"
}


##
# Return the configuration for the tool in use for a given action.
#
# The actions may use different tools (eg 'nose' or 'unittest' for
# the 'test' action, in python), with different versions. Each version
# requires a different configuration.
#
# This function will expand the configuration given by the user (or the
# default) and return configuration entries.
#
# Given the configuration value for a <language>/<action> combination
# (see the 'tool_expansion' function), this function expands the variables
# to give the specific configuration.
#
# If the configuration contains a string previxed by equal ('='), the
# following content is considered the literal configuration to return.
#
# If only a single module name was given in the configuration, then the
# default tool version is used.
# This default tool version is looked up in the environment variable
#   <language>_toolversion_<module>
#
# If the version number was given in the configuration, it is used
# verbatim.
#
# The configuration itself resides in:
#   <language>_tool_<module>_<version-with-underscores>
# The version has periods ('.') replaced with underscores ('_').
# The content of this variable is returned.
function tool_config_expansion() {
    local lang="$1"
    local action="$2"
    local toolpair="$(tool_expansion "$lang" "$action")"

    if [[ "$toolpair" == '' ]] ; then
        echo "Tool configuration for language '${lang}', action '${action}' is not known" >&2
        exit 1
    fi
    if [[ "${toolpair:0:1}" == '=' ]] ; then
        # They specified a literal configuration
        echo "${toolpair:1}"
        return 0
    fi

    local tool
    local version
    if [[ "$toolpair" =~ \  ]] ; then
        tool="${toolpair%% *}"
        version="${toolpair#* }"
    else
        tool="$toolpair"
        local var="${lang}_toolversion_${tool}"
        version="${!var}"
        if [[ "${version}" == '' ]] ; then
            echo "Default version for language '${lang}' tool '${tool}' is not known" >&2
            exit 1
        fi
    fi

    local confvar="${lang}_tool_${tool}_${version//./_}"
    echo "${!confvar}"
    return 0
}

255 256 257 258 259 260 261 262 263 264 265 266 267 268
##
# Delete an entire directory tree, as safely as possible.
#
# @param $@     Directories to remove.
function rmtree() {
    local dirs="$@"
    if [[ "$(uname -s)" == 'Darwin' ]] ; then
        # On OSX, '--one-file-system' does not exist.
        rm -rf "${dirs[@]}"
    else
        rm -rf --one-file-system "${dirs[@]}"
    fi
}

269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294
##
# Check if a named function exists
#
# @param $1     function name to check
function is_function() {
    local funcname="$1"
    if [ "$(type -t "$funcname")" == 'function' ] ; then
        return 0
    fi
    return 1
}

##
# Call the named function if it exists.
#
# @param $1     function name to call
function call_if_function() {
    local funcname="$1"
    shift

    if is_function "$funcname" ; then
        "$funcname" "$@"
    fi
}


295 296 297 298 299 300 301 302 303
##
# Report that we have started processing a file.
#
# @param $1     filename being looked at
# @param $2     message to display
function report_file() {
    local filename="$1"
    local message="$2"

304
    echo "$filename: $message"
305 306 307 308 309 310 311 312 313 314
}


##
# Report that we have failed for some reason.
#
# @param $1     filename being looked at
# @param $2     message to display
function report_failure() {
    local filename="$1"
315 316
    local message="${2:-FAILED}"

317
    echo "$filename: $message"
318 319 320 321 322 323 324 325
}


##
# Report that we have succeeded
#
# @param $1     filename being looked at
# @param $2     message to display
326
function report_success() {
327 328
    local filename="$1"
    local message="${2:-Success}"
329

330
    echo "$filename: $message"
331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353
}


##
# Operation output filter - a pipe that reports the status of the operations.
#
# @param $1     message for starting output (or '+' to indent with no message)
# @param $2     message for ending output (when something printed)
# @param $3     message for ending output (when nothing printed)
function output_filter() {
    local start="$1"
    local end_failure="$2"
    local end_success="$3"
    local line
    local nlines=0
    local indent

    if [[ "$start" == '' ]] ; then
        indent='  '
    else
        indent='    '
    fi

354
    while IFS= read -r line ; do
355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374
        if [[ "$nlines" == '0' ]] ; then
            if [[ "$start" != '' && "$start" != '+' ]] ; then
              echo "  $start"
            fi
        fi
        echo "$indent$line"
        nlines=$(( nlines+1 ))
    done
    if [[ "$nlines" == '0' ]] ; then
        if [[ "$end_success" != '' ]] ; then
            echo "  $end_success"
        fi
    else
        if [[ "$end_failure" != '' ]] ; then
            echo "  $end_failure"
        fi
    fi
}


375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407
function all_setup_test() {
    local running="${1:-tests}"

    echo ">>> Running ${running}"

    tests_passed=0
    tests_failed=0
    tests_total=0
}

function all_setup_coverage() {
    all_setup_test coverage

    coverage_percentage='<unknown>'
    coverage_limit=$(config coverage_limit 0)
}

function all_setup_lint() {
    echo '>>> Lint code'

    lint_passed=0
    lint_failed=0
    lint_total=0
}

function all_setup_docs() {
    echo '>>> Building documentation'

    docs_passed=0
    docs_failed=0
    docs_total=0
}

408 409 410 411
function all_setup_clean() {
    echo '>>> Cleaning code'
}

412

413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432
##
# Merge all the test result files into a single file,
# and clear out the original files.
function merge_test_results() {
    local n=0
    local test_files=()
    local tf
    while [[ "$n" -lt "$tests_total" ]] ; do
        tf="${artifact_dir}/test-results-$n.xml"
        if [[ -f "$tf" ]] ; then
            test_files+=("$tf")
        fi
        n=$(( n + 1 ))
    done

    # Merge all those files together
    if [[ "${#test_files[@]}" == 0 ]]; then
        echo "No JUnit XML files generated"
    else
        echo "Merging ${#test_files[@]} files into '${artifact_dir}/test-results.xml'"
433 434 435
        "${scripts}/junit-xml" --fix-nosetests \
                               --output "${artifact_dir}/test-results.xml" \
                               "${test_files[@]}"
436

Charles Ferguson's avatar
Charles Ferguson committed
437 438 439
        # And clear up the files that we now no-longer need.
        rm "${test_files[@]}"
    fi
440 441
}

442 443 444
function all_run_clean() {
    rmtree "${artifact_dir:-ARTIFACT_DIR_UNSET}"
}
445

446 447

function all_process_tests() {
448
    merge_test_results
449 450 451 452 453
}
function all_process_coverage() {
    # If the file writing fails, it isn't fatal
    merge_test_results || true
}
454

455 456 457 458 459 460 461 462 463 464 465 466

function all_process_clean() {
    # Cleaning the log and artifact directory must come last so that
    # if the language needs to do something, it can use the existing
    # environment (if it needs to), and write logs (although they
    # would be cleared away).
    rmtree "${log_dir:-LOG_DIR_UNSET}"
    rmtree "${environment_dir:-ENVIRONMENT_DIR_UNSET}"
}


function all_end_test() {
467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507
    if [[ "$tests_failed" != "0" ]] ; then
        echo "<<< Tests FAILED ($tests_failed test files failed, $tests_passed test files passed)"
        exit 1
    fi

    echo "<<< Tests passed ($tests_passed test files)"
    exit 0
}

function all_end_coverage() {
    echo "Overall Coverage: ${coverage_percentage}% (limit is ${coverage_limit}%)"
    if [[ "${coverage_percentage}" != '<unknown>' && \
          "${coverage_percentage%.*}" -lt "${coverage_limit}" ]] ; then
        echo "<<< Coverage failed (${coverage_percentage%.*}% < ${coverage_limit}%)"
        exit 1
    fi

    echo "<<< Coverage passed"
    exit 0
}

function all_end_lint() {
    if [[ "$lint_failed" != "0" ]] ; then
        echo "<<< Lint FAILED ($lint_failed checks failed, $lint_passed checks passed)"
        exit 1
    fi

    echo "<<< Lint passed ($lint_passed checks)"
    exit 0
}

function all_end_docs() {
    if [[ "$docs_failed" != "0" ]] ; then
        echo '<<< Documentation failed'
        exit 1
    fi

    echo '<<< Documentation built'
    exit 0
}

508 509 510 511 512
function all_end_clean() {
    echo '<<< Cleaned'
    exit 0
}

513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681

##
# Provide help messages.
#
# @param $1     Name of the action, or '' if none
function help_message() {
    local action="$1"
    local global_message
    local action_message

    if [[ "$action" == '' ]] ; then
        echo "Syntax: $0 <global-options> {${actions// /|}} <action-options>"
    else
        echo "Syntax: $0 <global-options> $action <action-options>"
    fi

    global_message=$'Global options:\n'
    for lang in $languages ; do
        if ! active "${lang}" "${action}" ; then
            continue
        fi

        if is_function "${lang}_help_any" ; then
            echo -n "$global_message"
            global_message=''
            "${lang}_help_any" "$action" | sed 's/^/  /'
        fi
    done

    if [[ "$action" != '' ]] ; then
        action_message=$'\nOptions for action '"'$action'"$':\n'
        for lang in $languages ; do
            if ! active "${lang}" "${action}" ; then
                continue
            fi
            if is_function "${lang}_help_${action}" ; then
                echo -n "$action_message"
                action_message=''
                "${lang}_help_${action}" "$action" | sed 's/^/  /'
            fi
        done
    fi
    exit 0
}


##
# Check if a language/action combination is active or not
#
# @param $1     Language to check
# @param $2     Action to check, or '' to check if any active
function active() {
    local lang="$1"
    local action="$2"

    if ! config -b "${lang}_enabled" 'true' ; then
        return 1
    fi

    if [[ "$action" != '' ]] ; then
        "${lang}_active" "$action"
        return
    fi
    for action in $actions ; do
        if "${lang}_active" "$action" ; then
            return 0
        fi
    done

    return 1
}


##
# Execute one of the actions based on the languages and activations
# we have.
#
# @param $1     Name of the action ('test', 'coverage', 'docs', 'lint')
function run_action() {
    local action
    local opt
    local value
    local optfunc
    local handled

    # Process any additional arguments that we were passed.
    local next_param=false
    for param in "$@" ; do
        if "$next_param" "$param" ; then
            next_param=false
            continue
        fi

        if [[ "${param:0:1}" == '-' ]] ; then
            if [[ "${param:0:2}" == '--' ]] ; then
                param="${param:2}"
            else
                param="${param:1}"
            fi
            if [[ "${param}" == 'h' || "${param}" == 'help' ]] ; then
                # Help is special, as we'll call each language's functions
                help_message "$action"
                exit 0
            else
                handled=false
                if [[ "$param" =~ = ]] ; then
                    value=${param#*=}
                    opt=${param%%=*}
                else
                    value=''
                    opt=${param}
                fi
                optfunc=${opt//-/_}
                for lang in $languages ; do
                    if ! active "$lang" "$action" ; then
                        continue
                    fi

                    if [[ -z "$value" ]] ; then
                        if is_function "${lang}_switch_any_${optfunc}" ; then
                            "${lang}_switch_any_${optfunc}" "$action"
                            handled=true
                        elif is_function "${lang}_switch_${action}_${optfunc}" ; then
                            "${lang}_switch_${action}_${optfunc}" "$action"
                            handled=true
                        elif is_function "${lang}_param_any_${optfunc}" ; then
                            next_param="${lang}_param_any_${optfunc}"
                            handled=true
                        elif is_function "${lang}_param_${action}_${optfunc}" ; then
                            next_param="${lang}_param_${action}_${optfunc}"
                            handled=true
                        fi
                    else
                        # We were given a parameter, so we handle this now
                        if is_function "${lang}_param_any_${optfunc}" ; then
                            "${lang}_param_any_${optfunc}" "${value}"
                            handled=true
                        elif is_function "${lang}_param_${action}_${optfunc}" ; then
                            "${lang}_param_${action}_${optfunc}" "${value}"
                            handled=true
                        fi
                    fi
                done

                if ! "$handled" ; then
                    echo "Option '$opt' is not understood" >&2
                    exit 1
                fi
            fi
        else
            if [[ "$action" == '' ]] ; then
                action="$param"
            else
                echo "Positional parameters are not supported ('$param')" >&2
                exit 1
            fi
        fi
    done

    if [[ "$action" == '' || "$action" == 'help' ]] ; then
        help_message
    fi

    # Begin the action, and set up any variables needed.
    if ! is_function "all_setup_${action}" ; then
        echo "Action '${action}' not known" >&2
        exit 1
    fi

682 683
    # Ensure the directories we'll use are present and (where necessary)
    # cleaned.
684
    rmtree "${artifact_dir:-ARTIFACT_DIR_UNSET}" "${log_dir:-LOG_DIR_UNSET}"
685 686
    mkdir -p "${environment_dir}" "${artifact_dir}" "${log_dir}"

687
    "all_setup_${action}"
688 689
    call_if_function "all_run_${action}"

690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709
    for lang in $languages ; do
        if active "$lang" "$action" ; then

            # 1. Build and use an environment in which the tests can function.
            # This may be CPAN, VirtualEnv, or some other specialism.
            call_if_function "${lang}_environment_${action}"

            # 2. Configure the tests, with environment variables, clear up
            # any left-over resources that we do not want.
            call_if_function "${lang}_setup_${action}"

            # 3. Execute the action, and update the state variables used by
            # the 'all_setup_*' functions.
            call_if_function "${lang}_run_${action}"

            # 4. Process the results of the action, generating reports,
            # compressing files, or other operations that are needed.
            call_if_function "${lang}_process_${action}"
        fi
    done
710
    call_if_function "all_process_${action}"
711

712 713 714 715 716 717 718
    # Remove any directories we did not use
    for dir in "${environment_dir}" "${artifact_dir}" "${log_dir}" ; do
        if [[ -d "$dir" ]] ; then
            rmdir "$dir" 2> /dev/null || true
        fi
    done

719 720 721 722 723 724 725 726 727 728 729 730
    # Report the results of the action, and call exit appropriately.
    "all_end_${action}"
}


# Load the scripts for the languages
languages=""
for file in $(expand_filenames "${scripts}"'/common.*') ; do
    languages="$languages ${file##*.}"
    source "${scripts}/common.${file##*.}"
done

731
actions="test coverage lint docs clean"
732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752

# General configuration for all tools.
config_file="${root}/project.config"

# The 'environment' directory is where we store the necessary tools and
# resources to make it possible to isolate the actions.
export environment_dir="${root:-ROOT_UNSET}/$(config environment_dir '.env')"

# The 'artifact' directory is where the output of the action is stored.
# Results of tests, or built objects, would go here.
# This directory might be the content that was preserved.
export artifact_dir="${root:-ROOT_UNSET}/$(config artifact_dir 'artifacts')"

# The 'log' directory is where files related to the action, but not
# its direct output, would be placed.
# Transient logs, and data used as part of the operation, but not its
# direct output, would go here.
# This directory might be the content that would be used for diagnosing
# problems, but not retained once the lifetime of the action had been
# reached.
export log_dir="${root:-ROOT_UNSET}/$(config log_dir 'ci-logs')"