|
| 1 | +<?php |
| 2 | +/** |
| 3 | + * Copyright © Magento, Inc. All rights reserved. |
| 4 | + * See COPYING.txt for license details. |
| 5 | + */ |
| 6 | + |
| 7 | +// @codingStandardsIgnoreStart |
| 8 | +/** |
| 9 | + * Script to operate on a test suite defined in a phpunit configuration xml or xml.dist file; split the tests |
| 10 | + * in the suite into groups by required size; return total number of groups or generate phpunit_<index>.xml file |
| 11 | + * that defines a new test suite named group_<index> with tests in group <index> |
| 12 | + * |
| 13 | + * Common scenario: |
| 14 | + * |
| 15 | + * 1. Query how many groups in a test suite with a given size --group-size=<size> |
| 16 | + * php phpunitGroupConfig.php --get-total --configuration=<path-to-phpunit-xml-dist-file> --test-suite=<name> --group-size=<size> --isolate-tests=<path-to-isolate-tests-file> |
| 17 | + * |
| 18 | + * 2a. Generate the configuration file for group <index>. <index> must be in range of [1, total number of groups]) |
| 19 | + * php phpunitGroupConfig.php --get-group=<index> --configuration=<path-to-phpunit-xml-dist-file> --test-suite=<name> --group-size=<size> --isolate-tests=<path-to-isolate-tests-file> |
| 20 | + * |
| 21 | + * 2b. Or generate configuration files for all test groups at once |
| 22 | + * php phpunitGroupConfig.php --get-group=all --configuration=<path-to-phpunit-xml-dist-file> --test-suite=<name> --group-size=<size> --isolate-tests=<path-to-isolate-tests-file> |
| 23 | + * |
| 24 | + * 3. PHPUnit command to run tests for group at <index> |
| 25 | + * phpunit --configuration <path_to_phpunit_<index>.xml> --testsuite group_<index> |
| 26 | + */ |
| 27 | + |
| 28 | +$scriptName = basename(__FILE__); |
| 29 | + |
| 30 | +define( |
| 31 | + 'USAGE', |
| 32 | + <<<USAGE |
| 33 | +Usage: |
| 34 | +php -f $scriptName |
| 35 | + [--get-total] |
| 36 | + Option takes no value, when specified, script will return total number of groups for the test suite specified in --test-suite. |
| 37 | + It's the default if both --get-total and --get-group are specified or both --get-total and --get-group are not specified. |
| 38 | + [--get-group="<positive integer>|all"] |
| 39 | + When option takes a positive integer value <i>, script will generate phpunit_<i>.xml file in the same location as the config |
| 40 | + file specified in --configuration with a test suite named "group_<i>" which contains the i-th group of tests from the test |
| 41 | + suite specified in --test-suite. |
| 42 | + When option takes value "all", script will generate config files for all groups at once. |
| 43 | + --test-suite="<name>" |
| 44 | + Name of test suite to be splitted into groups. |
| 45 | + --group-size="<positive integer>" |
| 46 | + Number of tests per group. |
| 47 | + --configuration="<path>" |
| 48 | + Path to phpunit configuration xml or xml.dist file. |
| 49 | + [--isolate-tests="<path>"] |
| 50 | + Path to a text file containing tests that require group isolation. One test path per line. |
| 51 | +
|
| 52 | +Note: |
| 53 | +Script uses getopt() which does not accept " "(space) as a separator for optional values. Use "=" for [--get-group] and [--isolate-tests] instead. |
| 54 | +See https://www.php.net/manual/en/function.getopt.php |
| 55 | +
|
| 56 | +USAGE |
| 57 | +); |
| 58 | +// @codingStandardsIgnoreEnd |
| 59 | + |
| 60 | +$options = getopt( |
| 61 | + '', |
| 62 | + [ |
| 63 | + 'get-total', |
| 64 | + 'get-group::', |
| 65 | + 'test-suite:', |
| 66 | + 'group-size:', |
| 67 | + 'configuration:', |
| 68 | + 'isolate-tests::' |
| 69 | + ] |
| 70 | +); |
| 71 | +$requiredOpts = ['test-suite', 'group-size', 'configuration']; |
| 72 | + |
| 73 | +try { |
| 74 | + foreach ($requiredOpts as $opt) { |
| 75 | + assertUsage(empty($options[$opt]), "Option --$opt: cannot be empty\n"); |
| 76 | + } |
| 77 | + |
| 78 | + assertUsage(!ctype_digit($options['group-size']), "Option --group-size: must be positive integer\n"); |
| 79 | + assertUsage(!realpath($options['configuration']), "Option --configuration: file doesn't exist\n"); |
| 80 | + assertUsage( |
| 81 | + isset($options['isolate-tests']) && !realpath($options['isolate-tests']), |
| 82 | + "Option --isolate-tests: file doesn't exist\n" |
| 83 | + ); |
| 84 | + $isolateTests = isset($options['isolate-tests']) ? readIsolateTests(realpath($options['isolate-tests'])) : []; |
| 85 | + |
| 86 | + $generateConfig = null; |
| 87 | + $groupIndex = null; |
| 88 | + if (isset($options['get-total']) || !isset($options['get-group'])) { |
| 89 | + $generateConfig = false; |
| 90 | + } else { |
| 91 | + assertUsage( |
| 92 | + (empty($options['get-group']) || !ctype_digit($options['get-group'])) |
| 93 | + && strtolower($options['get-group']) != 'all', |
| 94 | + "Option --get-group: must be a positive integer or 'all'\n" |
| 95 | + ); |
| 96 | + $generateConfig = true; |
| 97 | + $groupIndex = strtolower($options['get-group']); |
| 98 | + } |
| 99 | + |
| 100 | + $testSuite = $options['test-suite']; |
| 101 | + $groupSize = $options['group-size']; |
| 102 | + $configFile = realpath($options['configuration']); |
| 103 | + $workingDir = dirname($configFile) . DIRECTORY_SEPARATOR; |
| 104 | + |
| 105 | + $savedCwd = getcwd(); |
| 106 | + chdir($workingDir); |
| 107 | + $allTests = getTestList($configFile, $testSuite); |
| 108 | + chdir($savedCwd); |
| 109 | + list($allRegularTests, $isolateTests) = fuzzyArrayDiff($allTests, $isolateTests); // diff to separate isolated tests |
| 110 | + |
| 111 | + $totalRegularTests = count($allRegularTests); |
| 112 | + if (($totalRegularTests % $groupSize) === 0) { |
| 113 | + $totalRegularGroups = $totalRegularTests / $groupSize; |
| 114 | + } else { |
| 115 | + $totalRegularGroups = (int)($totalRegularTests / $groupSize) + 1; |
| 116 | + } |
| 117 | + $totalGroups = $totalRegularGroups + count($isolateTests); |
| 118 | + assertUsage( |
| 119 | + $totalGroups == 0, |
| 120 | + "Option --test-suite: no test found for test suite '{$testSuite}'\n" |
| 121 | + ); |
| 122 | + |
| 123 | + if (!$generateConfig) { |
| 124 | + print $totalGroups; |
| 125 | + exit(0); |
| 126 | + } |
| 127 | + |
| 128 | + if ($groupIndex == 'all') { |
| 129 | + $sIndex = 1; |
| 130 | + $eIndex = $totalGroups; |
| 131 | + } else { |
| 132 | + assertUsage( |
| 133 | + (int)$groupIndex > $totalGroups, |
| 134 | + "Option --get-group: can not be greater than $totalGroups\n" |
| 135 | + ); |
| 136 | + $sIndex = (int)$groupIndex; |
| 137 | + $eIndex = $sIndex; |
| 138 | + } |
| 139 | + |
| 140 | + $successMsg = "PHPUnit configuration files created:\n"; |
| 141 | + for ($index = $sIndex; $index < $eIndex + 1; $index++) { |
| 142 | + $groupTests = []; |
| 143 | + if ($index <= $totalRegularGroups) { |
| 144 | + $groupTests = array_chunk($allRegularTests, $groupSize)[$index - 1]; |
| 145 | + } else { |
| 146 | + $groupTests[] = $isolateTests[$index - $totalRegularGroups - 1]; |
| 147 | + } |
| 148 | + |
| 149 | + $groupConfigFile = $workingDir . 'phpunit_' . $index . '.xml'; |
| 150 | + createGroupConfig($configFile, $groupConfigFile, $groupTests, $index); |
| 151 | + $successMsg .= "{$groupConfigFile}, group: {$index}, test suite: group_{$index}\n"; |
| 152 | + } |
| 153 | + print $successMsg; |
| 154 | + |
| 155 | +} catch (Exception $e) { |
| 156 | + print $e->getMessage(); |
| 157 | + exit(1); |
| 158 | +} |
| 159 | + |
| 160 | +/** |
| 161 | + * Generate a phpunit configuration file for a given group |
| 162 | + * |
| 163 | + * @param string $in |
| 164 | + * @param string $out |
| 165 | + * @param array $group |
| 166 | + * @param integer $index |
| 167 | + * @return void |
| 168 | + * @throws Exception |
| 169 | + */ |
| 170 | +function createGroupConfig($in, $out, $group, $index) |
| 171 | +{ |
| 172 | + $beforeTestSuites = true; |
| 173 | + $afterTestSuites = false; |
| 174 | + $outLines = ''; |
| 175 | + $inLines = explode("\n", file_get_contents($in)); |
| 176 | + foreach ($inLines as $inLine) { |
| 177 | + if ($beforeTestSuites) { |
| 178 | + // Replacing existing <testsuites> node with new <testsuites> node |
| 179 | + preg_match('/<testsuites/', $inLine, $bMatch); |
| 180 | + if (isset($bMatch[0])) { |
| 181 | + $beforeTestSuites = false; |
| 182 | + $outLines .= getFormattedGroup($group, $index); |
| 183 | + continue; |
| 184 | + } |
| 185 | + } |
| 186 | + if (!$afterTestSuites) { |
| 187 | + preg_match('/<\/\s*testsuites/', $inLine, $aMatch); |
| 188 | + if (isset($aMatch[0])) { |
| 189 | + $afterTestSuites = true; |
| 190 | + continue; |
| 191 | + } |
| 192 | + } |
| 193 | + if ($beforeTestSuites) { |
| 194 | + // Adding new <testsuites> node right before </phpunit> if there is no existing <testsuites> node |
| 195 | + preg_match('/<\/\s*phpunit/', $inLine, $lMatch); |
| 196 | + if (isset($lMatch[0])) { |
| 197 | + $outLines .= getFormattedGroup($group, $index); |
| 198 | + $outLines .= $inLine . "\n"; |
| 199 | + break; |
| 200 | + } |
| 201 | + } |
| 202 | + if ($beforeTestSuites || $afterTestSuites) { |
| 203 | + $outLines .= $inLine . "\n"; |
| 204 | + } |
| 205 | + } |
| 206 | + file_put_contents($out, $outLines); |
| 207 | +} |
| 208 | + |
| 209 | +/** |
| 210 | + * Format tests in an array into <testsuite> node defined by phpunit xml schema |
| 211 | + * |
| 212 | + * @param array $group |
| 213 | + * @param integer $index |
| 214 | + * @return string |
| 215 | + */ |
| 216 | +function getFormattedGroup($group, $index) |
| 217 | +{ |
| 218 | + $output = "\t<testsuites>\n"; |
| 219 | + $output .= "\t\t<testsuite name=\"group_{$index}\">\n"; |
| 220 | + foreach ($group as $ch) { |
| 221 | + $output .= "\t\t\t<file>{$ch}</file>\n"; |
| 222 | + } |
| 223 | + $output .= "\t\t</testsuite>\n"; |
| 224 | + $output .= "\t</testsuites>\n"; |
| 225 | + return $output; |
| 226 | +} |
| 227 | + |
| 228 | +/** |
| 229 | + * Return paths for all tests as an array for a given test suite in a phpunit.xml(.dist) file |
| 230 | + * |
| 231 | + * @param string $configFile |
| 232 | + * @param string $suiteName |
| 233 | + * @return array |
| 234 | + */ |
| 235 | +function getTestList($configFile, $suiteName) |
| 236 | +{ |
| 237 | + $testCases = []; |
| 238 | + $config = simplexml_load_file($configFile); |
| 239 | + foreach ($config->xpath('//testsuite') as $testsuite) { |
| 240 | + if (strtolower((string)$testsuite['name']) != strtolower($suiteName)) { |
| 241 | + continue; |
| 242 | + } |
| 243 | + foreach ($testsuite->file as $file) { |
| 244 | + $testCases[(string)$file] = true; |
| 245 | + } |
| 246 | + $excludeFiles = []; |
| 247 | + foreach ($testsuite->exclude as $excludeFile) { |
| 248 | + $excludeFiles[] = (string)$excludeFile; |
| 249 | + } |
| 250 | + foreach ($testsuite->directory as $directoryPattern) { |
| 251 | + foreach (glob($directoryPattern, GLOB_ONLYDIR) as $directory) { |
| 252 | + if (!file_exists((string)$directory)) { |
| 253 | + continue; |
| 254 | + } |
| 255 | + $suffix = isset($directory['suffix']) ? (string)$directory['suffix'] : 'Test.php'; |
| 256 | + $fileIterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator((string)$directory)); |
| 257 | + foreach ($fileIterator as $fileInfo) { |
| 258 | + $pathToTestCase = (string)$fileInfo; |
| 259 | + if (substr_compare($pathToTestCase, $suffix, -strlen($suffix)) === 0 |
| 260 | + && !isTestClassAbstract($pathToTestCase) |
| 261 | + ) { |
| 262 | + $inExclude = false; |
| 263 | + foreach ($excludeFiles as $excludeFile) { |
| 264 | + if (strpos($pathToTestCase, $excludeFile) !== false) { |
| 265 | + $inExclude = true; |
| 266 | + break; |
| 267 | + } |
| 268 | + } |
| 269 | + if (!$inExclude) { |
| 270 | + $testCases[$pathToTestCase] = true; |
| 271 | + } |
| 272 | + } |
| 273 | + } |
| 274 | + } |
| 275 | + } |
| 276 | + } |
| 277 | + $testCases = array_keys($testCases); // automatically avoid file duplications |
| 278 | + sort($testCases); |
| 279 | + return $testCases; |
| 280 | +} |
| 281 | + |
| 282 | +/** |
| 283 | + * Determine if a file contains an abstract class |
| 284 | + * |
| 285 | + * @param string $testClassPath |
| 286 | + * @return bool |
| 287 | + */ |
| 288 | +function isTestClassAbstract($testClassPath) |
| 289 | +{ |
| 290 | + return strpos(file_get_contents($testClassPath), "\nabstract class") !== false; |
| 291 | +} |
| 292 | + |
| 293 | +/** |
| 294 | + * Return isolation tests as an array by reading from a file |
| 295 | + * |
| 296 | + * @param string $file |
| 297 | + * @return array |
| 298 | + */ |
| 299 | +function readIsolateTests($file) |
| 300 | +{ |
| 301 | + $tests = []; |
| 302 | + $lines = explode("\n", file_get_contents($file)); |
| 303 | + foreach ($lines as $line) { |
| 304 | + if (!empty(trim($line)) && substr_compare(trim($line), '#', 0, 1) !== 0) { |
| 305 | + $tests[] = trim($line); |
| 306 | + } |
| 307 | + } |
| 308 | + return $tests; |
| 309 | +} |
| 310 | + |
| 311 | +/** |
| 312 | + * Array diff based on partial match |
| 313 | + * |
| 314 | + * @param array $oArray |
| 315 | + * @param array $dArray |
| 316 | + * @return array |
| 317 | + */ |
| 318 | +function fuzzyArrayDiff($oArray, $dArray) |
| 319 | +{ |
| 320 | + $ret1 = []; |
| 321 | + $ret2 = []; |
| 322 | + foreach ($oArray as $obj) { |
| 323 | + $ret1[] = $obj; |
| 324 | + foreach ($dArray as $diff) { |
| 325 | + if (stripos($obj, $diff) !== false) { |
| 326 | + $ret2[] = $obj; |
| 327 | + array_pop($ret1); |
| 328 | + break; |
| 329 | + } |
| 330 | + } |
| 331 | + } |
| 332 | + return [$ret1, $ret2]; |
| 333 | +} |
| 334 | + |
| 335 | +/** |
| 336 | + * Assert usage by throwing exception on condition evaluating to true |
| 337 | + * |
| 338 | + * @param bool $condition |
| 339 | + * @param string $error |
| 340 | + * @throws Exception |
| 341 | + */ |
| 342 | +function assertUsage($condition, $error) |
| 343 | +{ |
| 344 | + if ($condition) { |
| 345 | + $error .= "\n" . USAGE; |
| 346 | + throw new Exception($error); |
| 347 | + } |
| 348 | +} |
0 commit comments