[BUGFIX] Apply enableFields in JOIN's ON condition
[Packages/TYPO3.CMS.git] / Build / Scripts / splitFunctionalTests.php
1 #!/usr/bin/env php
2 <?php
3 declare(strict_types=1);
4
5 /*
6 * This file is part of the TYPO3 CMS project.
7 *
8 * It is free software; you can redistribute it and/or modify it under
9 * the terms of the GNU General Public License, either version 2
10 * of the License, or any later version.
11 *
12 * For the full copyright and license information, please read the
13 * LICENSE.txt file that was distributed with this source code.
14 *
15 * The TYPO3 project - inspiring people to share!
16 */
17
18 use PhpParser\Comment\Doc;
19 use PhpParser\Node;
20 use PhpParser\NodeTraverser;
21 use PhpParser\NodeVisitor\NameResolver;
22 use PhpParser\NodeVisitorAbstract;
23 use PhpParser\ParserFactory;
24 use Symfony\Component\Console\Input\ArgvInput;
25 use Symfony\Component\Console\Input\InputArgument;
26 use Symfony\Component\Console\Input\InputDefinition;
27 use Symfony\Component\Console\Input\InputOption;
28 use Symfony\Component\Console\Output\ConsoleOutput;
29 use Symfony\Component\Console\Output\OutputInterface;
30 use Symfony\Component\Finder\Finder;
31 use Symfony\Component\Finder\SplFileInfo;
32
33 if (PHP_SAPI !== 'cli') {
34 die('Script must be called from command line.' . chr(10));
35 }
36
37 require __DIR__ . '/../../vendor/autoload.php';
38
39 /**
40 * This script is typically executed by runTests.sh.
41 *
42 * The script expects to be run from the core root:
43 * ./Build/Scripts/splitFunctionalTests.php <numberOfChunks>
44 *
45 * Verbose output with 8 chunks:
46 * ./Build/Scripts/splitFunctionalTests.php 8 -v
47 *
48 * It's purpose is to find all core functional tests and split them into
49 * pieces. In CI, there are for example 8 jobs for the functional tests and each
50 * picks one chunk of tests. This way, functional tests are run in parallel and
51 * thus reduce the overall runtime of the test suite.
52 *
53 * phpunit .xml config files including their specific set of tests are written to:
54 * Build/Scripts/FunctionalTests-Job-<counter>.xml
55 */
56 class SplitFunctionalTests
57 {
58 /**
59 * Main entry method
60 */
61 public function execute()
62 {
63 $input = new ArgvInput($_SERVER['argv'], $this->getInputDefinition());
64 $output = new ConsoleOutput();
65
66 // Number of chunks and verbose output
67 $numberOfChunks = (int)$input->getArgument('numberOfChunks');
68
69 if ($numberOfChunks < 1 || $numberOfChunks > 99) {
70 throw new \InvalidArgumentException(
71 'Main argument "numberOfChunks" must be at least 1 and maximum 99',
72 1528319388
73 );
74 }
75
76 if ($input->hasParameterOption('-v', true) || $input->hasParameterOption('--verbose', true)) {
77 $output->setVerbosity(OutputInterface::VERBOSITY_VERBOSE);
78 }
79
80 // Find functional test files
81 $testFiles = (new Finder())
82 ->files()
83 ->in(__DIR__ . '/../../typo3/sysext/*/Tests/Functional')
84 ->name('/Test\.php$/')
85 ->sortByName()
86 ;
87
88 $parser = (new ParserFactory())->create(ParserFactory::ONLY_PHP7);
89 $testStats = [];
90 foreach ($testFiles as $file) {
91 /** @var $file SplFileInfo */
92 $relativeFilename = $file->getRealPath();
93 preg_match('/.*typo3\/sysext\/(.*)$/', $relativeFilename, $matches);
94 $relativeFilename = '../typo3/sysext/' . $matches[1];
95
96 $ast = $parser->parse($file->getContents());
97 $traverser = new NodeTraverser();
98 $visitor = new NameResolver();
99 $traverser->addVisitor($visitor);
100 $visitor = new FunctionalTestCaseVisitor();
101 $traverser->addVisitor($visitor);
102 $traverser->traverse($ast);
103
104 $fqcn = $visitor->getFqcn();
105 $tests = $visitor->getTests();
106 if (!empty($tests)) {
107 $testStats[$relativeFilename] = 0;
108 }
109
110 foreach ($tests as $test) {
111 if (isset($test['dataProvider'])) {
112 // Test uses a data provider - get number of data sets
113 $dataProviderMethodName = $test['dataProvider'];
114 $methods = (new $fqcn())->$dataProviderMethodName();
115 if ($methods instanceof Generator) {
116 $numberOfDataSets = iterator_count($methods);
117 } else {
118 $numberOfDataSets = count($methods);
119 }
120 $testStats[$relativeFilename] += $numberOfDataSets;
121 } else {
122 // Just a single test
123 $testStats[$relativeFilename] += 1;
124 }
125 }
126 }
127
128 // Sort test files by number of tests, descending
129 arsort($testStats);
130
131 $this->createPhpunitXmlHeader($numberOfChunks);
132
133 $numberOfTestsPerChunk = [];
134 for ($i = 1; $i <= $numberOfChunks; $i++) {
135 $numberOfTestsPerChunk[$i] = 0;
136 }
137
138 foreach ($testStats as $testFile => $numberOfTestsInFile) {
139 // Sort list of tests per chunk by number of tests, pick lowest as
140 // the target of this test file
141 asort($numberOfTestsPerChunk);
142 reset($numberOfTestsPerChunk);
143 $jobFileNumber = key($numberOfTestsPerChunk);
144
145 $content = <<<EOF
146 <directory>
147 $testFile
148 </directory>
149
150 EOF;
151 file_put_contents(__DIR__ . '/../' . 'FunctionalTests-Job-' . $jobFileNumber . '.xml', $content, FILE_APPEND);
152
153 $numberOfTestsPerChunk[$jobFileNumber] = $numberOfTestsPerChunk[$jobFileNumber] + $numberOfTestsInFile;
154 }
155
156 $this->createPhpunitXmlFooter($numberOfChunks);
157
158 if ($output->isVerbose()) {
159 $output->writeln('Number of test files found: ' . count($testStats));
160 $output->writeln('Number of tests found: ' . array_sum($testStats));
161 $output->writeln('Number of chunks prepared: ' . $numberOfChunks);
162 ksort($numberOfTestsPerChunk);
163 foreach ($numberOfTestsPerChunk as $chunkNumber => $testNumber) {
164 $output->writeln('Number of tests in chunk ' . $chunkNumber . ': ' . $testNumber);
165 }
166 }
167 }
168
169 /**
170 * Allowed script arguments
171 *
172 * @return InputDefinition argv input definition of symfony console
173 */
174 private function getInputDefinition(): InputDefinition
175 {
176 return new InputDefinition([
177 new InputArgument('numberOfChunks', InputArgument::REQUIRED, 'Number of chunks / jobs to create'),
178 new InputOption('--verbose', '-v', InputOption::VALUE_NONE, 'Enable verbose output'),
179 ]);
180 }
181
182 /**
183 * "Header" part of a phpunit.xml functional config file
184 *
185 * @param int $numberOfChunks
186 */
187 private function createPhpunitXmlHeader(int $numberOfChunks): void
188 {
189 $content = <<<EOF
190 <phpunit
191 backupGlobals="true"
192 bootstrap="../vendor/typo3/testing-framework/Resources/Core/Build/FunctionalTestsBootstrap.php"
193 colors="true"
194 convertErrorsToExceptions="true"
195 convertWarningsToExceptions="true"
196 forceCoversAnnotation="false"
197 stopOnError="false"
198 stopOnFailure="false"
199 stopOnIncomplete="false"
200 stopOnSkipped="false"
201 verbose="false"
202 beStrictAboutTestsThatDoNotTestAnything="false"
203 >
204 <testsuites>
205 <testsuite name="Core tests">
206
207 EOF;
208 for ($i = 1; $i <= $numberOfChunks; $i++) {
209 file_put_contents(__DIR__ . '/../' . 'FunctionalTests-Job-' . $i . '.xml', $content);
210 }
211 }
212
213 /**
214 * "Footer" part of a phpunit.xml functional config file
215 *
216 * @param int $numberOfChunks
217 */
218 private function createPhpunitXmlFooter(int $numberOfChunks): void
219 {
220 $content = <<<EOF
221 </testsuite>
222 </testsuites>
223 </phpunit>
224
225 EOF;
226 for ($i = 1; $i <= $numberOfChunks; $i++) {
227 file_put_contents(__DIR__ . '/../' . 'FunctionalTests-Job-' . $i . '.xml', $content, FILE_APPEND);
228 }
229 }
230 }
231
232 /**
233 * nikic/php-parser node visitor to find test class namespace,
234 * count @test annotated methods and their possible @dataProvider's
235 */
236 class FunctionalTestCaseVisitor extends NodeVisitorAbstract
237 {
238 /**
239 * @var array[] An array of arrays with test method names and optionally a data provider name
240 */
241 private $tests = [];
242
243 /**
244 * @var string Fully qualified test class name
245 */
246 private $fqcn;
247
248 /**
249 * Create a list of '@test' annotated methods in a test case
250 * file and see if single tests use data providers.
251 *
252 * @param Node $node
253 */
254 public function enterNode(Node $node): void
255 {
256 if ($node instanceof Node\Stmt\Class_
257 && !$node->isAnonymous()
258 ) {
259 // The test class full namespace
260 $this->fqcn = (string)$node->namespacedName;
261 }
262
263 if ($node instanceof Node\Stmt\ClassMethod
264 && ($docComment = $node->getDocComment()) instanceof Doc
265 ) {
266 preg_match_all(
267 '/\s*\s@(?<annotations>[^\s.].*)\n/',
268 $docComment->getText(),
269 $matches
270 );
271 foreach ($matches['annotations'] as $possibleTest) {
272 if ($possibleTest === 'test') {
273 // Found a test
274 $test = [
275 'methodName' => $node->name->name,
276 ];
277 foreach ($matches['annotations'] as $possibleDataProvider) {
278 // See if this test has a data provider attached
279 if (strpos($possibleDataProvider, 'dataProvider') === 0) {
280 $test['dataProvider'] = trim(ltrim($possibleDataProvider, 'dataProvider'));
281 }
282 }
283 $this->tests[] = $test;
284 }
285 }
286 }
287 }
288
289 /**
290 * Return array of found tests and their data providers
291 *
292 * @return array
293 */
294 public function getTests(): array
295 {
296 return $this->tests;
297 }
298
299 /**
300 * Return Fully qualified class test name
301 *
302 * @return string
303 */
304 public function getFqcn(): string
305 {
306 return $this->fqcn;
307 }
308 }
309
310 $splitFunctionalTests = new SplitFunctionalTests();
311 exit($splitFunctionalTests->execute());