├── .github └── workflows │ ├── compute.yml │ └── test.yml ├── .gitignore ├── .php_cs.dist ├── .styleci.yml ├── ChangeLog ├── README.md ├── bin └── phpunit-merger ├── composer.json ├── phpunit.xml.dist ├── sonar-project.properties ├── src └── PhpunitMerger │ └── Command │ ├── CoverageCommand.php │ └── LogCommand.php └── tests └── PhpunitMerger └── Command ├── AbstractCommandTestCase.php ├── Coverage └── CoverageCommandTest.php └── Log └── LogCommandTest.php /.github/workflows/compute.yml: -------------------------------------------------------------------------------- 1 | name: ✏️ matrix 2 | 3 | on: 4 | workflow_call: 5 | outputs: 6 | os: 7 | value: ${{ jobs.compute.outputs.os }} 8 | coverage: 9 | value: ${{ jobs.compute.outputs.coverage }} 10 | major: 11 | value: ${{ jobs.compute.outputs.major }} 12 | php: 13 | value: ${{ jobs.compute.outputs.php }} 14 | exclude: 15 | value: ${{ jobs.compute.outputs.exclude }} 16 | 17 | env: 18 | OS: '[ "ubuntu-latest" ]' 19 | COVERAGE: '[ "~9.0.0", "~9.1.0", "~9.2.0", "~10.0.0", "~10.1.0" ]' 20 | PHP: '[ "8.0", "8.1", "8.2", "8.3" ]' 21 | EXCLUDE: '[ { "coverage": "10", "php": "8.0" }, { "coverage": "~10.0.0", "php": "8.0" }, { "coverage": "~10.1.0", "php": "8.0" } ]' 22 | 23 | jobs: 24 | compute: 25 | name: Compute outputs 26 | 27 | runs-on: ubuntu-latest 28 | 29 | outputs: 30 | os: ${{ env.OS }} 31 | coverage: ${{ env.COVERAGE }} 32 | major: ${{ steps.major-version.outputs.major }} 33 | php: ${{ env.PHP }} 34 | exclude: ${{ env.EXCLUDE }} 35 | 36 | steps: 37 | - name: Compute major versions 38 | id: major-version 39 | run: | 40 | echo -e "COVERAGE\n" 41 | echo $COVERAGE 42 | echo -e "\n\nSplit by comma\n" 43 | echo $COVERAGE | tr "," "\n" 44 | echo -e "\n\nParse numbers\n" 45 | echo $COVERAGE | tr "," "\n" | tr -cd "\n0-9" 46 | echo -e "\n\nCut last 2 characters\n" 47 | echo $COVERAGE | tr "," "\n" | tr -cd "\n0-9" | sed "s/.\{2\}$//" 48 | echo -e "\n\nSort by version\n" 49 | echo $COVERAGE | tr "," "\n" | tr -cd "\n0-9" | sed "s/.\{2\}$//" | sort -V 50 | echo -e "\n\nUnique values only\n" 51 | echo $COVERAGE | tr "," "\n" | tr -cd "\n0-9" | sed "s/.\{2\}$//" | sort -V | uniq 52 | echo -e "\n\nCovert to JSON\n" 53 | echo $COVERAGE | tr "," "\n" | tr -cd "\n0-9" | sed "s/.\{2\}$//" | sort -V | uniq | jq --compact-output --raw-input --slurp 'split("\n") | map(select(. != ""))' 54 | echo "major=$(echo $COVERAGE | tr "," "\n" | tr -cd "\n0-9" | sed "s/.\{2\}$//" | sort -V | uniq | jq --compact-output --raw-input --slurp 'split("\n") | map(select(. != ""))')" >> $GITHUB_OUTPUT 55 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: 🏃 tests 2 | 3 | on: [ push, pull_request, workflow_call ] 4 | 5 | jobs: 6 | compute: 7 | uses: ./.github/workflows/compute.yml 8 | 9 | build: 10 | name: 'Build COVERAGE: ${{ matrix.coverage }} - PHP: ${{ matrix.php }}' 11 | 12 | needs: [ compute ] 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: ${{ fromJson(needs.compute.outputs.os) }} 18 | coverage: ${{ fromJson(needs.compute.outputs.coverage) }} 19 | php: ${{ fromJson(needs.compute.outputs.php) }} 20 | exclude: ${{ fromJson(needs.compute.outputs.exclude) }} 21 | 22 | runs-on: ${{ matrix.os }} 23 | 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | 28 | - name: Store Composer cache directory 29 | id: composer-cache 30 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 31 | 32 | - name: Store PHP code coverage version 33 | id: version-cache 34 | env: 35 | COVERAGE: ${{ matrix.coverage }} 36 | run: | 37 | echo "version=$(echo $COVERAGE | tr -d -c 0-9)" >> $GITHUB_OUTPUT 38 | echo "major=$(echo $COVERAGE | tr -d -c 0-9 | sed 's/.\{2\}$//')" >> $GITHUB_OUTPUT 39 | 40 | - uses: actions/cache/restore@v4 41 | id: restore-composer-cache 42 | with: 43 | path: ${{ steps.composer-cache.outputs.dir }} 44 | key: ${{ runner.os }}-${{ matrix.php }}-${{ steps.version-cache.outputs.major }}-${{ matrix.coverage }} 45 | restore-keys: | 46 | ${{ runner.os }}-${{ matrix.php }}-${{ steps.version-cache.outputs.major }}- 47 | ${{ runner.os }}-${{ matrix.php }}- 48 | ${{ runner.os }}- 49 | 50 | - name: Set up PHP Version ${{ matrix.php }} 51 | uses: shivammathur/setup-php@v2 52 | with: 53 | php-version: ${{ matrix.php }} 54 | coverage: xdebug 55 | tools: composer:v2 56 | 57 | - name: Environment Check 58 | run: | 59 | php --version 60 | composer --version 61 | mkdir -p .Log/coverage/ .Log/log/ 62 | 63 | - name: Validate composer.json 64 | run: composer validate 65 | 66 | - name: Composer install 67 | run: composer update --with "phpunit/php-code-coverage:${{ matrix.coverage }}" --no-interaction 68 | 69 | - name: Save composer cache 70 | uses: actions/cache/save@v4 71 | with: 72 | path: ${{ steps.composer-cache.outputs.dir }} 73 | key: ${{ steps.restore-composer-cache.outputs.cache-primary-key }} 74 | 75 | - name: Lint PHP 76 | run: php .Build/bin/parallel-lint --exclude .Build . 77 | 78 | - name: Run PHPUnit 79 | if: ${{ success() || failure() }} 80 | run: find 'tests' -wholename '*Test.php' | parallel --gnu 'echo -e "\n\nRunning test {}"; HASH=${{ steps.version-cache.outputs.version }}_$( echo {} | md5sum | cut -d " " -f 1); .Build/bin/phpunit --log-junit .Log/log/junit_$HASH.xml --coverage-php .Log/coverage/coverage_$HASH.cov --coverage-filter src/ {}' 81 | 82 | - name: Archive PHPUnit logs 83 | uses: actions/upload-artifact@v4 84 | with: 85 | name: phpunit-logs-${{ runner.os }}-${{ matrix.php }}-${{ steps.version-cache.outputs.major }}-${{ matrix.coverage }} 86 | path: .Log/* 87 | retention-days: 1 88 | 89 | merge: 90 | name: 'Merge COVERAGE: ${{ matrix.coverage }} - PHP: ${{ matrix.php }}' 91 | 92 | needs: [ compute, build ] 93 | 94 | strategy: 95 | fail-fast: false 96 | matrix: 97 | os: ${{ fromJson(needs.compute.outputs.os) }} 98 | coverage: ${{ fromJson(needs.compute.outputs.major) }} 99 | php: ${{ fromJson(needs.compute.outputs.php) }} 100 | exclude: ${{ fromJson(needs.compute.outputs.exclude) }} 101 | 102 | runs-on: ${{ matrix.os }} 103 | 104 | steps: 105 | - name: Checkout 106 | uses: actions/checkout@v4 107 | 108 | - name: Download PHPUnit logs 109 | uses: actions/download-artifact@v4 110 | with: 111 | path: .Log 112 | pattern: phpunit-logs-${{ runner.os }}-${{ matrix.php }}-${{ matrix.coverage }}-* 113 | merge-multiple: true 114 | 115 | - name: Store Composer cache directory 116 | id: composer-cache 117 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 118 | 119 | - uses: actions/cache/restore@v4 120 | id: restore-composer-cache 121 | with: 122 | path: ${{ steps.composer-cache.outputs.dir }} 123 | key: ${{ runner.os }}-${{ matrix.php }}-${{ matrix.coverage }} 124 | restore-keys: | 125 | ${{ runner.os }}-${{ matrix.php }}- 126 | ${{ runner.os }}- 127 | 128 | - name: Set up PHP Version ${{ matrix.php }} 129 | uses: shivammathur/setup-php@v2 130 | with: 131 | php-version: ${{ matrix.php }} 132 | coverage: xdebug 133 | tools: composer:v2 134 | 135 | - name: Environment Check 136 | run: | 137 | php --version 138 | composer --version 139 | 140 | - name: Validate composer.json 141 | run: composer validate 142 | 143 | - name: Composer install 144 | run: composer update --with "phpunit/php-code-coverage:^${{ matrix.coverage }}.0" --no-interaction 145 | 146 | - name: Save composer cache 147 | uses: actions/cache/save@v4 148 | with: 149 | path: ${{ steps.composer-cache.outputs.dir }} 150 | key: ${{ steps.restore-composer-cache.outputs.cache-primary-key }} 151 | 152 | - name: Merge log files 153 | run: bin/phpunit-merger log .Log/log/ .Log/junit.xml 154 | 155 | - name: Merge coverage files 156 | run: bin/phpunit-merger coverage .Log/coverage/ .Log/coverage.xml 157 | 158 | - name: Archive PHPUnit logs 159 | uses: actions/upload-artifact@v4 160 | with: 161 | name: phpunit-logs-merged-${{ runner.os }}-${{ matrix.php }}-${{ matrix.coverage }} 162 | path: .Log/* 163 | retention-days: 1 164 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.Build 2 | /.idea 3 | /.Log 4 | composer.lock 5 | .phpunit.result.cache 6 | -------------------------------------------------------------------------------- /.php_cs.dist: -------------------------------------------------------------------------------- 1 | 6 | (c) Helmut Hummel 7 | EOF; 8 | 9 | return PhpCsFixer\Config::create() 10 | ->setRiskyAllowed(true) 11 | ->setRules([ 12 | '@PSR2' => true, 13 | 'array_syntax' => [ 14 | 'syntax' => 'short', 15 | ], 16 | 'binary_operator_spaces' => true, 17 | 'blank_line_after_opening_tag' => true, 18 | 'blank_line_before_return' => true, 19 | 'cast_spaces' => [ 20 | 'space' => 'none', 21 | ], 22 | 'compact_nullable_typehint' => true, 23 | 'concat_space' => [ 24 | 'spacing' => 'one', 25 | ], 26 | 'declare_equal_normalize' => [ 27 | 'space' => 'none' 28 | ], 29 | 'declare_strict_types' => true, 30 | 'function_typehint_space' => true, 31 | 'hash_to_slash_comment' => true, 32 | 'linebreak_after_opening_tag' => true, 33 | 'lowercase_cast' => true, 34 | 'lowercase_constants' => true, 35 | 'lowercase_static_reference' => true, 36 | 'method_separation' => true, 37 | 'native_function_casing' => true, 38 | 'new_with_braces' => true, 39 | 'no_alias_functions' => true, 40 | 'no_blank_lines_after_class_opening' => true, 41 | 'no_blank_lines_after_phpdoc' => true, 42 | 'no_empty_comment' => true, 43 | 'no_empty_phpdoc' => true, 44 | 'no_empty_statement' => true, 45 | 'no_extra_consecutive_blank_lines' => [ 46 | 'continue', 47 | 'curly_brace_block', 48 | 'extra', 49 | 'parenthesis_brace_block', 50 | 'square_brace_block', 51 | 'throw', 52 | ], 53 | 'no_leading_import_slash' => true, 54 | 'no_leading_namespace_whitespace' => true, 55 | 'no_multiline_whitespace_around_double_arrow' => true, 56 | 'no_multiline_whitespace_before_semicolons' => true, 57 | 'no_short_bool_cast' => true, 58 | 'no_singleline_whitespace_before_semicolons' => true, 59 | 'no_trailing_comma_in_list_call' => true, 60 | 'no_trailing_comma_in_singleline_array' => true, 61 | 'no_unneeded_control_parentheses' => [ 62 | 'break', 63 | 'clone', 64 | 'continue', 65 | 'echo_print', 66 | 'return', 67 | 'switch_case', 68 | ], 69 | 'no_unreachable_default_argument_value' => true, 70 | 'no_unused_imports' => true, 71 | 'no_useless_else' => true, 72 | 'no_useless_return' => true, 73 | 'no_whitespace_before_comma_in_array' => true, 74 | 'no_whitespace_in_blank_line' => true, 75 | 'non_printable_character' => true, 76 | 'normalize_index_brace' => true, 77 | 'object_operator_without_whitespace' => true, 78 | 'ordered_imports' => true, 79 | 'phpdoc_add_missing_param_annotation' => true, 80 | 'phpdoc_annotation_without_dot' => true, 81 | 'phpdoc_indent' => true, 82 | 'phpdoc_no_access' => true, 83 | 'phpdoc_no_package' => true, 84 | 'phpdoc_order' => true, 85 | 'phpdoc_scalar' => true, 86 | 'phpdoc_single_line_var_spacing' => true, 87 | 'phpdoc_trim' => true, 88 | 'phpdoc_types' => true, 89 | 'self_accessor' => true, 90 | 'phpdoc_var_without_name' => true, 91 | 'return_type_declaration' => ['space_before' => 'none'], 92 | 'short_scalar_cast' => true, 93 | 'single_blank_line_before_namespace' => true, 94 | 'single_quote' => true, 95 | 'single_trait_insert_per_statement' => true, 96 | 'standardize_not_equals' => true, 97 | 'ternary_operator_spaces' => true, 98 | 'trailing_comma_in_multiline_array' => true, 99 | 'whitespace_after_comma_in_array' => true, 100 | ]) 101 | ->setFinder( 102 | PhpCsFixer\Finder::create() 103 | ->in(__DIR__) 104 | ->exclude('.Build') 105 | ); 106 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | risky: true 2 | 3 | preset: psr12 4 | 5 | disabled: 6 | - binary_operator_at_least_one_space 7 | 8 | enabled: 9 | - alpha_ordered_imports 10 | - binary_operator_exactly_one_space 11 | - blank_line_before_return 12 | - hash_to_slash_comment 13 | - linebreak_after_opening_tag 14 | - method_separation 15 | - native_function_casing 16 | - no_alias_functions 17 | - no_blank_lines_after_phpdoc 18 | - no_empty_comment 19 | - no_empty_phpdoc 20 | - no_empty_statement 21 | - no_extra_block_blank_lines 22 | - no_extra_consecutive_blank_lines 23 | - no_multiline_whitespace_around_double_arrow 24 | - no_multiline_whitespace_before_semicolons 25 | - no_short_bool_cast 26 | - no_singleline_whitespace_before_semicolons 27 | - no_trailing_comma_in_list_call 28 | - no_trailing_comma_in_singleline_array 29 | - no_unneeded_control_parentheses 30 | - no_unused_imports 31 | - no_useless_else 32 | - no_useless_return 33 | - no_whitespace_before_comma_in_array 34 | - non_printable_character 35 | - normalize_index_brace 36 | - object_operator_without_whitespace 37 | - phpdoc_add_missing_param_annotation 38 | - phpdoc_annotation_without_dot 39 | - phpdoc_indent 40 | - phpdoc_no_access 41 | - phpdoc_no_package 42 | - phpdoc_order 43 | - phpdoc_scalar 44 | - phpdoc_single_line_var_spacing 45 | - phpdoc_trim 46 | - phpdoc_types 47 | - phpdoc_var_without_name 48 | - self_accessor 49 | - short_array_syntax 50 | - single_quote 51 | - standardize_not_equals 52 | - trailing_comma_in_multiline_array 53 | - whitespace_after_comma_in_array 54 | 55 | finder: 56 | name: 57 | - "*.php" 58 | exclude: 59 | - ".Build" 60 | -------------------------------------------------------------------------------- /ChangeLog: -------------------------------------------------------------------------------- 1 | 2024-08-05 [RELEASE] Release of phpunit-merger 2.0.1 (Nicole Cordes) 2 | 2024-01-26 790ea36 Allow Symfony ^7.0 as dependency (Kennard Vermeiren) 3 | 2024-08-05 15ca05c [TASK] Remove Travis CI configuration (Nicole Cordes) 4 | 2024-08-05 9d32123 [TASK] Increase log files (Nicole Cordes) 5 | 2024-08-05 722e1eb [FEATURE] Add merge testing job (Nicole Cordes) 6 | 2024-08-04 261efdf [FEATURE] Add major version processing (Nicole Cordes) 7 | 2024-08-04 6ee2a5e [BUGFIX] Streamline PHPUnit configuration (Nicole Cordes) 8 | 2024-08-03 d193bd5 [BUGFIX] Re-add compatibility to phpunit/php-code-coverage ^9.0 (Nicole Cordes) 9 | 2024-08-03 f096c24 [FEATURE] Run PHPUnit tests and create artifacts (Nicole Cordes) 10 | 2024-08-02 1522a51 [FEATURE] Compute matrix values (Nicole Cordes) 11 | 2024-08-02 a715056 [FEATURE] Introduce GitHub Actions PHP linting (Nicole Cordes) 12 | 2024-08-02 4b361a0 [BUGFIX] Fix StyleCI configuration (Nicole Cordes) 13 | 14 | 2023-10-02 [RELEASE] Release of phpunit-merger 2.0.0 (Nicole Cordes) 15 | 2023-10-02 ff24d85 [FEATURE] Add support for phpunit/php-code-coverage ^10.0 (Nicole Cordes) 16 | 17 | 2023-10-02 [RELEASE] Release of phpunit-merger 1.1.3 (Nicole Cordes) 18 | 2023-10-02 0ed7988 [BUGFIX] Require phpspec/prophecy as dev dependency (Nicole Cordes) 19 | 2023-10-02 d767f1e [TASK] Rename deprecated AbstractTest file (Nicole Cordes) 20 | 2023-10-02 40835f0 [BUGFIX] Ensure .Log folder exists during installation (Nicole Cordes) 21 | 2023-10-02 abb7932 [FEATURE] Allow latest symfony dependencies (Nicole Cordes) 22 | 2023-10-02 bd4ecd4 [TASK] Ignore .Log folder in .gitignore (Nicole Cordes) 23 | 24 | 2022-01-18 [RELEASE] Release of phpunit-merger 1.1.2 (Nicole Cordes) 25 | 2022-01-17 706e758 [TASK] Adjust PHP dependency in composer.json (Nicole Cordes) 26 | 2022-01-17 9ad3487 [BUGFIX] Use correct PHP version for Travis CI (Nicole Cordes) 27 | 2022-01-14 62ccfcd [TASK] Raise PHP compatibility to 8.1 (Tomas Norre Mikkelsen) 28 | 29 | 2021-10-08 [RELEASE] Release of nimut/phpunit-merger 1.1.1 (Nicole Cordes) 30 | 2021-10-07 76e1b50 [BUGFIX] Normalize CodeCoverage objects (Nicole Cordes) 31 | 2021-10-07 62cbc53 [TASK] Raise PHP compatibility to 8.0 (Nicole Cordes) 32 | 33 | 2020-10-06 [RELEASE] Release of nimut/phpunit-merger 1.1.0 (Nicole Cordes) 34 | 2020-10-02 f42f8cc Ensure unit test checks own boundaries usage (Nicole Cordes) 35 | 2020-10-02 e64b83b Prefer int casting to intval function (Nicole Cordes) 36 | 2020-10-02 e0217b1 Parameters should be ints (carlos granados) 37 | 2020-10-02 bb52378 Adds options for lowUpperBound and highLowerBound for HTML reports (carlos granados) 38 | 39 | 2020-08-18 [RELEASE] Release of nimut/phpunit-merger 1.0.0 (Nicole Cordes) 40 | 2020-08-18 16fb969 [FEATURE] Add support for phpunit/php-code-coverage ^9.0 (Nicole Cordes) 41 | 2020-08-18 b9526f2 [TASK] Update StyleCI and PHP CS Fixer configuration (Nicole Cordes) 42 | 2020-08-18 0d64720 [FEATURE] Add merge support for multiple testsuites (Nicole Cordes) 43 | 2020-05-08 caf1079 [BUGFIX] Raise PHP version for SonarQube scanner (Nicole Cordes) 44 | 45 | 2020-05-08 [RELEASE] Release of nimut/phpunit-merger 0.3.5 (Nicole Cordes) 46 | 2020-05-08 b3b25ea Update Travis CI configuration (Nicole Cordes) 47 | 2020-05-08 423219b Allow compatible PHPUnit version (Nicole Cordes) 48 | 2020-05-07 589f982 adds support for php-code-coverage 8 (Deniz Sokullu) 49 | 50 | 2019-12-19 [RELEASE] Release of nimut/phpunit-merger 0.3.4 (Nicole Cordes) 51 | 2019-12-19 d94aedd [TASK] Raise Symfony dependencies to include 5.0 (Nicole Cordes) 52 | 53 | 2019-12-17 [RELEASE] Release of nimut/phpunit-merger 0.3.3 (Nicole Cordes) 54 | 2019-12-17 79d5057 [TASK] Allow lower symfony dependencies (Nicole Cordes) 55 | 56 | 2019-12-06 [RELEASE] Release of nimut/phpunit-merger 0.3.2 (Nicole Cordes) 57 | 2019-12-06 6d2e7be [TASK] Raise PHP compatibility to 7.4 (Nicole Cordes) 58 | 59 | 2019-09-13 [RELEASE] Release of nimut/phpunit-merger 0.3.1 (Nicole Cordes) 60 | 2019-09-13 f4f2407 Replace get_class with instanceof comparrision (Nicole Cordes) 61 | 2019-08-09 48b1a3d Show error if the scanned file is not a valid PHP script. (Thomas Eimers) 62 | 63 | 2019-05-15 [RELEASE] Release of nimut/phpunit-merger 0.3.0 (Nicole Cordes) 64 | 2019-03-09 52e6874 [FEATURE] Add more unit tests to cover new CoverageCommand option (Nicole Cordes) 65 | 2019-03-09 e2daa30 [FEATURE] Add option to CoverageCommand to generate HTML report (Nicole Cordes) 66 | 2019-03-09 9d9d409 [BUGFIX] Ensure existing output directory for LogCommand file (Nicole Cordes) 67 | 68 | 2019-03-08 [RELEASE] Release of nimut/phpunit-merger 0.2.0 (Nicole Cordes) 69 | 2019-03-08 d2cf8b3 [BUGFIX] Rewrite unit tests for phpunit/phpunit ^8.0 (Nicole Cordes) 70 | 2019-03-08 279810f [TASK] Raise phpunit/php-code-coverage compatibility to ^7.0 (Nicole Cordes) 71 | 2019-03-08 0435799 [TASK] Remove PHP 7.3 from allowed failures (Nicole Cordes) 72 | 2019-03-08 65a1d5b [TASK] Add tests for latest phpunit/php-code-coverage versions (Nicole Cordes) 73 | 2018-12-09 b900c90 [TASK] Raise phpunit/php-code-coverage compatibility to ^6.0 (Nicole Cordes) 74 | 2018-12-09 4e0c949 [TASK] Raise PHP compatibility to 7.3 (Nicole Cordes) 75 | 2018-12-09 e2fe04a [BUGFIX] Use correct file paths in Travis CI configuration (Nicole Cordes) 76 | 2018-12-09 52521cd [BUGFIX] Make bin/phpunit-merger executable (Nicole Cordes) 77 | 2018-12-09 f939cd2 [FEATURE] Add Travis CI and SonarQube configuration (Nicole Cordes) 78 | 2018-12-09 5970c37 [FEATURE] Add unit tests (Nicole Cordes) 79 | 2018-12-09 56c657f [BUGFIX] Catch exceptions from SimpleXMLElement (Nicole Cordes) 80 | 2018-12-09 c918343 [TASK] Add missing PHP extension dependencies to composer.json (Nicole Cordes) 81 | 2018-12-09 dc491bf [TASK] Ignore composer.lock in .gitignore (Nicole Cordes) 82 | 2017-12-19 aeaaf4c [TASK] Add README.txt (Nicole Cordes) 83 | 2017-12-19 13fb73d [FEATURE] Add StyleCI Coding Style Service (Nicole Cordes) 84 | 2017-12-19 ec96dcf [FEATURE] Provide friendsofphp/php-cs-fixer default configuration (Nicole Cordes) 85 | 86 | 2017-12-17 [RELEASE] Release of nimut/phpunit-merger 0.1.0 (Nicole Cordes) 87 | 2017-12-17 470b791 [FEATURE] Add (very) basic log command (Nicole Cordes) 88 | 2017-12-17 0db9a98 [FEATURE] Add (very) basic coverage command (Nicole Cordes) 89 | 2017-12-17 34fa7d7 [TASK] Add composer.json (Nicole Cordes) 90 | 2017-12-17 ae3355f [TASK] Ignore .Build and .idea folders in .gitignore (Nicole Cordes) 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Merge multiple PHPUnit reports into one file 2 | 3 | [![Latest Stable Version](https://img.shields.io/packagist/v/nimut/phpunit-merger.svg)](https://packagist.org/packages/nimut/phpunit-merger) 4 | [![StyleCI](https://styleci.io/repos/114540931/shield?branch=main)](https://styleci.io/repos/114540931) 5 | ![GitHub Actions](https://github.com/Nimut/phpunit-merger/actions/workflows/test.yml/badge.svg?event=push) 6 | 7 | Sometimes it is necessary to run multiple PHPUnit instances to execute all tests of a project. Unfortunately each run 8 | writes its own coverage and log reports. There is no support in PHPUnit to merge the reports of multiple runs. 9 | 10 | This project provides two commands to merge coverage files as well as log files. It was designed to provide merged 11 | reports to e.g. SonarQube Scanner for further processing. 12 | 13 | ## Installation 14 | 15 | Use [Composer](https://getcomposer.org/) to install the testing framework. 16 | 17 | ```bash 18 | $ composer require --dev nimut/phpunit-merger 19 | ``` 20 | 21 | Composer will add the package as a dev requirement to your composer.json and install the package with its dependencies. 22 | 23 | ## Usage 24 | 25 | ### Coverage 26 | 27 | The coverage command merges files containing PHP_CodeCoverage objects into one file in Clover XML format. 28 | 29 | ```bash 30 | $ vendor/bin/phpunit-merger coverage [--html=] [] 31 | ``` 32 | 33 | **Arguments** 34 | 35 | - `directory`: Directory containing one or multiple files with PHP_CodeCoverage objects 36 | - `file`: File where the merged result should be stored. Default: Standard output 37 | 38 | **Options** 39 | 40 | - `html`: Directory where the HTML report should be stored 41 | - `lowUpperBound`: (optional) The lowUpperBound value to be used for HTML format 42 | - `highLowerBound`: (optional) The highLowerBound value to be used for HTML format 43 | 44 | ### Log 45 | 46 | The log command merges files in JUnit XML format into one file in JUnit XML format. 47 | 48 | ```bash 49 | $ vendor/bin/phpunit-merger log 50 | ``` 51 | 52 | **Arguments** 53 | 54 | - `directory`: Provides the directory containing one or multiple files in JUnit XML format 55 | - `file`: File where the merged result should be stored 56 | -------------------------------------------------------------------------------- /bin/phpunit-merger: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | addCommands( 24 | [ 25 | new \Nimut\PhpunitMerger\Command\CoverageCommand(), 26 | new \Nimut\PhpunitMerger\Command\LogCommand(), 27 | ] 28 | ); 29 | $app->run(); 30 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nimut/phpunit-merger", 3 | "description": "Merge multiple PHPUnit reports into one file", 4 | "keywords": [ 5 | "PHPUnit", 6 | "Coverage", 7 | "SonarQube", 8 | "TYPO3 CMS" 9 | ], 10 | "homepage": "https://github.com/Nimut/phpunit-merger", 11 | "license": [ 12 | "GPL-2.0+" 13 | ], 14 | "authors": [ 15 | { 16 | "name": "Nicole Cordes", 17 | "email": "typo3@cordes.co", 18 | "role": "Developer", 19 | "homepage": "https://www.biz-design.biz" 20 | }, 21 | { 22 | "name": "Helmut Hummel", 23 | "email": "info@helhum.io", 24 | "role": "Developer", 25 | "homepage": "https://helhum.io" 26 | } 27 | ], 28 | "require": { 29 | "php": "^8.0", 30 | "ext-dom": "*", 31 | "ext-json": "*", 32 | "ext-simplexml": "*", 33 | "phpunit/php-code-coverage": "^9.0 || ^10.0", 34 | "symfony/console": ">=2.7 <8.0", 35 | "symfony/finder": ">=2.7 <8.0" 36 | }, 37 | "require-dev": { 38 | "phpunit/phpunit": "^9.3 || ^10.0", 39 | "symfony/filesystem": ">=2.7 <8.0", 40 | "phpspec/prophecy": "^1.0", 41 | "php-parallel-lint/php-parallel-lint": "^1.4" 42 | }, 43 | "suggest": { 44 | "friendsofphp/php-cs-fixer": "Tool to automatically fix PHP coding standards issues" 45 | }, 46 | "autoload": { 47 | "psr-4": { 48 | "Nimut\\PhpunitMerger\\": "src/PhpunitMerger/" 49 | } 50 | }, 51 | "autoload-dev": { 52 | "psr-4": { 53 | "Nimut\\PhpunitMerger\\Tests\\": "tests/PhpunitMerger/" 54 | } 55 | }, 56 | "bin": [ 57 | "bin/phpunit-merger" 58 | ], 59 | "config": { 60 | "vendor-dir": ".Build/vendor", 61 | "bin-dir": ".Build/bin" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | tests/PhpunitMerger/Command/Coverage 9 | 10 | 11 | tests/PhpunitMerger/Command/Log 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=phpunit-merger 2 | sonar.projectName=nimut/phpunit-merger 3 | sonar.projectVersion=1.x 4 | sonar.sources=. 5 | sonar.exclusions=.Build/**, .Log/**, tests/** 6 | 7 | # Set Database Cleaner limits 8 | sonar.dbcleaner.hoursBeforeKeepingOnlyOneSnapshotByDay=24 9 | sonar.dbcleaner.weeksBeforeKeepingOnlyOneSnapshotByWeek=12 10 | sonar.dbcleaner.weeksBeforeKeepingOnlyOneSnapshotByMonth=52 11 | 12 | # Ignore issues on multiple criteria 13 | sonar.issue.ignore.multicriteria = e1 14 | 15 | # Exclude "String literals should not be duplicated" 16 | sonar.issue.ignore.multicriteria.e1.ruleKey=php:S1192 17 | sonar.issue.ignore.multicriteria.e1.resourceKey=**/*.php 18 | 19 | # PHPUnit test and coverage results import 20 | sonar.php.tests.reportPath=.Log/junit.xml 21 | sonar.php.coverage.reportPaths=.Log/coverage.xml 22 | -------------------------------------------------------------------------------- /src/PhpunitMerger/Command/CoverageCommand.php: -------------------------------------------------------------------------------- 1 | setName('coverage') 26 | ->setDescription('Merges multiple PHPUnit coverage php files into one') 27 | ->addArgument( 28 | 'directory', 29 | InputArgument::REQUIRED, 30 | 'The directory containing PHPUnit coverage php files' 31 | ) 32 | ->addArgument( 33 | 'file', 34 | InputArgument::OPTIONAL, 35 | 'The file where to write the merged result. Default: Standard output' 36 | ) 37 | ->addOption( 38 | 'html', 39 | null, 40 | InputOption::VALUE_REQUIRED, 41 | 'The directory where to write the code coverage report in HTML format' 42 | ) 43 | ->addOption( 44 | 'lowUpperBound', 45 | null, 46 | InputOption::VALUE_REQUIRED, 47 | 'The lowUpperBound value to be used for HTML format' 48 | ) 49 | ->addOption( 50 | 'highLowerBound', 51 | null, 52 | InputOption::VALUE_REQUIRED, 53 | 'The highLowerBound value to be used for HTML format' 54 | ); 55 | } 56 | 57 | protected function execute(InputInterface $input, OutputInterface $output): int 58 | { 59 | $finder = new Finder(); 60 | $finder->files() 61 | ->in(realpath($input->getArgument('directory'))); 62 | 63 | $codeCoverage = $this->getCodeCoverage(); 64 | 65 | foreach ($finder as $file) { 66 | $coverage = require $file->getRealPath(); 67 | if (!$coverage instanceof CodeCoverage) { 68 | throw new \RuntimeException($file->getRealPath() . ' doesn\'t return a valid ' . CodeCoverage::class . ' object!'); 69 | } 70 | $this->normalizeCoverage($coverage); 71 | $codeCoverage->merge($coverage); 72 | } 73 | 74 | $this->writeCodeCoverage($codeCoverage, $output, $input->getArgument('file')); 75 | $html = $input->getOption('html'); 76 | if ($html !== null) { 77 | $lowUpperBound = (int)($input->getOption('lowUpperBound') ?: 50); 78 | $highLowerBound = (int)($input->getOption('highLowerBound') ?: 90); 79 | $this->writeHtmlReport($codeCoverage, $html, $lowUpperBound, $highLowerBound); 80 | } 81 | 82 | return 0; 83 | } 84 | 85 | private function getCodeCoverage() 86 | { 87 | $filter = new Filter(); 88 | 89 | if (method_exists(Driver::class, 'forLineCoverage')) { 90 | $driver = Driver::forLineCoverage($filter); 91 | 92 | return new CodeCoverage($driver, $filter); 93 | } 94 | 95 | return new CodeCoverage((new Selector())->forLineCoverage($filter), $filter); 96 | } 97 | 98 | private function normalizeCoverage(CodeCoverage $coverage) 99 | { 100 | $tests = $coverage->getTests(); 101 | foreach ($tests as &$test) { 102 | $test['fromTestcase'] = $test['fromTestcase'] ?? false; 103 | } 104 | $coverage->setTests($tests); 105 | } 106 | 107 | private function writeCodeCoverage(CodeCoverage $codeCoverage, OutputInterface $output, $file = null) 108 | { 109 | $writer = new Clover(); 110 | $buffer = $writer->process($codeCoverage, $file); 111 | if ($file === null) { 112 | $output->write($buffer); 113 | } 114 | } 115 | 116 | private function writeHtmlReport(CodeCoverage $codeCoverage, string $destination, int $lowUpperBound, int $highLowerBound) 117 | { 118 | if (class_exists('SebastianBergmann\\CodeCoverage\\Report\\Thresholds')) { 119 | $writer = new Facade('', null, Thresholds::from($lowUpperBound, $highLowerBound)); 120 | } else { 121 | $writer = new Facade($lowUpperBound, $highLowerBound); 122 | } 123 | 124 | $writer->process($codeCoverage, $destination); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/PhpunitMerger/Command/LogCommand.php: -------------------------------------------------------------------------------- 1 | setName('log') 28 | ->setDescription('Merges multiple PHPUnit JUnit xml files into one') 29 | ->addArgument( 30 | 'directory', 31 | InputArgument::REQUIRED, 32 | 'The directory containing PHPUnit JUnit xml files' 33 | ) 34 | ->addArgument( 35 | 'file', 36 | InputArgument::REQUIRED, 37 | 'The file where to write the merged result' 38 | ); 39 | } 40 | 41 | protected function execute(InputInterface $input, OutputInterface $output): int 42 | { 43 | $finder = new Finder(); 44 | $finder->files() 45 | ->in(realpath($input->getArgument('directory'))); 46 | 47 | $this->document = new \DOMDocument('1.0', 'UTF-8'); 48 | $this->document->formatOutput = true; 49 | 50 | $root = $this->document->createElement('testsuites'); 51 | $this->document->appendChild($root); 52 | 53 | foreach ($finder as $file) { 54 | try { 55 | $xml = new \SimpleXMLElement(file_get_contents($file->getRealPath())); 56 | $xmlArray = json_decode(json_encode($xml), true); 57 | if (!empty($xmlArray)) { 58 | $this->addTestSuites($root, $xmlArray); 59 | } 60 | } catch (\Exception $exception) { 61 | // Initial fallthrough 62 | } 63 | } 64 | 65 | foreach ($this->domElements as $domElement) { 66 | if ($domElement->hasAttribute('parent')) { 67 | $domElement->removeAttribute('parent'); 68 | } 69 | } 70 | 71 | $file = $input->getArgument('file'); 72 | if (!is_dir(dirname($file))) { 73 | @mkdir(dirname($file), 0777, true); 74 | } 75 | $this->document->save($input->getArgument('file')); 76 | 77 | return 0; 78 | } 79 | 80 | private function addTestSuites(\DOMElement $parent, array $testSuites) 81 | { 82 | foreach ($testSuites as $testSuite) { 83 | if (empty($testSuite['@attributes']['name'])) { 84 | if (!empty($testSuite['testsuite'])) { 85 | $this->addTestSuites($parent, $testSuite['testsuite']); 86 | } 87 | continue; 88 | } 89 | $name = $testSuite['@attributes']['name']; 90 | 91 | if (isset($this->domElements[$name])) { 92 | $element = $this->domElements[$name]; 93 | } else { 94 | $element = $this->document->createElement('testsuite'); 95 | $element->setAttribute('parent', $parent->getAttribute('name')); 96 | $attributes = $testSuite['@attributes'] ?? []; 97 | foreach ($attributes as $key => $value) { 98 | $value = $key === 'name' ? $value : 0; 99 | $element->setAttribute($key, (string)$value); 100 | } 101 | $parent->appendChild($element); 102 | $this->domElements[$name] = $element; 103 | } 104 | 105 | if (!empty($testSuite['testsuite'])) { 106 | $children = isset($testSuite['testsuite']['@attributes']) ? [$testSuite['testsuite']] : $testSuite['testsuite']; 107 | $this->addTestSuites($element, $children); 108 | } 109 | 110 | if (!empty($testSuite['testcase'])) { 111 | $children = isset($testSuite['testcase']['@attributes']) ? [$testSuite['testcase']] : $testSuite['testcase']; 112 | $this->addTestCases($element, $children); 113 | } 114 | } 115 | } 116 | 117 | private function addTestCases(\DOMElement $parent, array $testCases) 118 | { 119 | foreach ($testCases as $testCase) { 120 | $attributes = $testCase['@attributes'] ?? []; 121 | if (empty($testCase['@attributes']['name'])) { 122 | continue; 123 | } 124 | $name = $testCase['@attributes']['name']; 125 | 126 | if (isset($this->domElements[$name])) { 127 | continue; 128 | } 129 | 130 | $element = $this->document->createElement('testcase'); 131 | foreach ($attributes as $key => $value) { 132 | $element->setAttribute($key, (string)$value); 133 | if (!is_numeric($value)) { 134 | continue; 135 | } 136 | $this->addAttributeValueToTestSuite($parent, $key, $value); 137 | } 138 | $parent->appendChild($element); 139 | $this->domElements[$name] = $element; 140 | } 141 | } 142 | 143 | private function addAttributeValueToTestSuite(\DOMElement $element, $key, $value) 144 | { 145 | $currentValue = $element->hasAttribute($key) ? $element->getAttribute($key) : 0; 146 | $element->setAttribute($key, (string)($currentValue + $value)); 147 | 148 | if ($element->hasAttribute('parent')) { 149 | $parent = $element->getAttribute('parent'); 150 | if (isset($this->domElements[$parent])) { 151 | $this->addAttributeValueToTestSuite($this->domElements[$parent], $key, $value); 152 | } 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /tests/PhpunitMerger/Command/AbstractCommandTestCase.php: -------------------------------------------------------------------------------- 1 | remove($this->logDirectory . $this->outputFile); 26 | 27 | $this->assertFileDoesNotExist($this->logDirectory . $this->outputFile); 28 | } 29 | 30 | public function assertOutputDirectoryNotExists() 31 | { 32 | $filesystem = new Filesystem(); 33 | $filesystem->remove($this->logDirectory . dirname($this->outputFile)); 34 | 35 | $this->assertDirectoryDoesNotExist($this->logDirectory . dirname($this->outputFile)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/PhpunitMerger/Command/Coverage/CoverageCommandTest.php: -------------------------------------------------------------------------------- 1 | assertOutputFileNotExists(); 22 | 23 | $input = new ArgvInput( 24 | [ 25 | 'coverage', 26 | $this->logDirectory . 'coverage/', 27 | $this->logDirectory . $this->outputFile, 28 | ] 29 | ); 30 | $output = $this->getMockBuilder(OutputInterface::class) 31 | ->getMock(); 32 | $output->method('write')->willThrowException(new \Exception()); 33 | 34 | $command = new CoverageCommand(); 35 | $command->run($input, $output); 36 | 37 | $this->assertFileExists($this->logDirectory . $this->outputFile); 38 | } 39 | 40 | public function testCoverageWritesStandardOutput() 41 | { 42 | $this->assertOutputFileNotExists(); 43 | 44 | $input = new ArgvInput( 45 | [ 46 | 'coverage', 47 | $this->logDirectory . 'coverage/', 48 | ] 49 | ); 50 | $output = $this->getMockBuilder(OutputInterface::class) 51 | ->getMock(); 52 | 53 | $command = new CoverageCommand(); 54 | $command->run($input, $output); 55 | } 56 | 57 | public function testCoverageWritesHtmlReport() 58 | { 59 | $this->outputFile = 'html/index.html'; 60 | $this->assertOutputDirectoryNotExists(); 61 | 62 | $input = new ArgvInput( 63 | [ 64 | 'coverage', 65 | $this->logDirectory . 'coverage/', 66 | '--html=' . $this->logDirectory . dirname($this->outputFile), 67 | ] 68 | ); 69 | $output = $this->getMockBuilder(OutputInterface::class) 70 | ->getMock(); 71 | 72 | $command = new CoverageCommand(); 73 | $command->run($input, $output); 74 | 75 | $this->assertFileExists($this->logDirectory . $this->outputFile); 76 | } 77 | 78 | public function testCoverageWritesHtmlReportWithCustomBounds() 79 | { 80 | $this->outputFile = 'html/index.html'; 81 | $this->assertOutputDirectoryNotExists(); 82 | 83 | $input = new ArgvInput( 84 | [ 85 | 'coverage', 86 | $this->logDirectory . 'coverage/', 87 | '--html=' . $this->logDirectory . dirname($this->outputFile), 88 | '--lowUpperBound=20', 89 | '--highLowerBound=70', 90 | ] 91 | ); 92 | $output = $this->getMockBuilder(OutputInterface::class) 93 | ->getMock(); 94 | 95 | $command = new CoverageCommand(); 96 | $command->run($input, $output); 97 | 98 | $this->assertFileExists($this->logDirectory . $this->outputFile); 99 | 100 | $content = file_get_contents($this->logDirectory . $this->outputFile); 101 | if (method_exists($this, 'assertStringContainsString')) { 102 | $this->assertStringContainsString('Low: 0% to 20%', $content); 103 | $this->assertStringContainsString('High: 70% to 100%', $content); 104 | } else { 105 | // Fallback for phpunit < 7.0 106 | $this->assertContains('Low: 0% to 20%', $content); 107 | $this->assertContains('High: 70% to 100%', $content); 108 | } 109 | } 110 | 111 | public function testCoverageWritesOutputFileAndHtmlReport() 112 | { 113 | $this->outputFile = 'html/coverage.xml'; 114 | $this->assertOutputFileNotExists(); 115 | $this->assertOutputDirectoryNotExists(); 116 | 117 | $input = new ArgvInput( 118 | [ 119 | 'coverage', 120 | $this->logDirectory . 'coverage/', 121 | '--html=' . $this->logDirectory . dirname($this->outputFile), 122 | $this->logDirectory . $this->outputFile, 123 | ] 124 | ); 125 | $output = $this->getMockBuilder(OutputInterface::class) 126 | ->getMock(); 127 | $output->method('write')->willThrowException(new \Exception()); 128 | 129 | $command = new CoverageCommand(); 130 | $command->run($input, $output); 131 | 132 | $this->assertFileExists($this->logDirectory . $this->outputFile); 133 | $this->assertFileExists($this->logDirectory . dirname($this->outputFile) . '/index.html'); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /tests/PhpunitMerger/Command/Log/LogCommandTest.php: -------------------------------------------------------------------------------- 1 | assertOutputFileNotExists(); 22 | 23 | $input = new ArgvInput( 24 | [ 25 | 'log', 26 | $this->logDirectory . 'log/', 27 | $this->logDirectory . $this->outputFile, 28 | ] 29 | ); 30 | $output = $this->getMockBuilder(OutputInterface::class)->getMock(); 31 | 32 | $command = new LogCommand(); 33 | $command->run($input, $output); 34 | 35 | $this->assertFileExists($this->logDirectory . $this->outputFile); 36 | } 37 | } 38 | --------------------------------------------------------------------------------