Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
52.63% covered (warning)
52.63%
30 / 57
61.11% covered (warning)
61.11%
11 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
PhpUnitXmlManager
52.63% covered (warning)
52.63%
30 / 57
61.11% covered (warning)
61.11%
11 / 18
118.38
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getPhpUnitXmlTarget
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPhpUnitXmlDist
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTestsList
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadPhpUnitXmlDist
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadPhpUnitXml
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadTestClasses
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 scanForTestFiles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 extractNamespaceFromFile
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 resolveFileForTest
28.57% covered (danger)
28.57%
4 / 14
0.00% covered (danger)
0.00%
0 / 1
19.12
 buildSuites
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isPhpUnitXmlPrepared
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createPhpUnitXml
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 listTestsNotice
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 splitTestsList
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 splitTestsListExtensions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 splitTestsListDefault
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 splitTestsCustom
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3declare( strict_types = 1 );
4
5namespace MediaWiki\Composer\PhpUnitSplitter;
6
7/**
8 * @license GPL-2.0-or-later
9 */
10class PhpUnitXmlManager {
11
12    private string $rootDir;
13
14    private string $testsListFile;
15
16    /**
17     * The `SkippedTestCase` is generated dynamically by PHPUnit for tests
18     * that are marked as skipped. We don't need to find a matching filesystem
19     * file for these.
20     *
21     * The `ParserIntegrationTest` is a special case - it's a single test class
22     * that generates very many tests. To balance out the test suites, we exclude
23     * the class from the scan, and add it back in PhpUnitXml::addSpecialCaseTests
24     */
25    private const EXPECTED_MISSING_CLASSES = [
26        "PHPUnit\\Framework\\SkippedTestCase",
27        "MediaWiki\\Extension\\Scribunto\\Tests\\Engines\\LuaCommon\\LuaEngineTestSkip",
28        "\\ParserIntegrationTest",
29    ];
30
31    public function __construct( string $rootDir, string $testsListFile ) {
32        $this->rootDir = $rootDir;
33        $this->testsListFile = $testsListFile;
34    }
35
36    private function getPhpUnitXmlTarget(): string {
37        return $this->rootDir . DIRECTORY_SEPARATOR . PhpUnitXml::PHP_UNIT_XML_FILE;
38    }
39
40    private function getPhpUnitXmlDist(): string {
41        return $this->rootDir . DIRECTORY_SEPARATOR . "phpunit.xml.dist";
42    }
43
44    private function getTestsList(): string {
45        return $this->rootDir . DIRECTORY_SEPARATOR . $this->testsListFile;
46    }
47
48    private function loadPhpUnitXmlDist(): PhpUnitXml {
49        return $this->loadPhpUnitXml( $this->getPhpUnitXmlDist() );
50    }
51
52    private function loadPhpUnitXml( string $targetFile ): PhpUnitXml {
53        return new PhpUnitXml( $targetFile );
54    }
55
56    private function loadTestClasses(): array {
57        if ( !file_exists( $this->getTestsList() ) ) {
58            throw new TestListMissingException( $this->getTestsList() );
59        }
60        return ( new PhpUnitTestListProcessor( $this->getTestsList() ) )->getTestClasses();
61    }
62
63    private function scanForTestFiles(): array {
64        return ( new PhpUnitTestFileScanner( $this->rootDir ) )->scanForFiles();
65    }
66
67    private static function extractNamespaceFromFile( $filename ): array {
68        $contents = file_get_contents( $filename );
69        $matches = [];
70        if ( preg_match( '/^namespace\s+([^\s;]+)/m', $contents, $matches ) ) {
71            return explode( '\\', $matches[1] );
72        }
73        return [];
74    }
75
76    /**
77     * @param TestDescriptor $testDescriptor
78     * @param array $phpFiles
79     * @return ?string
80     * @throws MissingNamespaceMatchForTestException
81     * @throws UnlocatedTestException
82     */
83    private function resolveFileForTest( TestDescriptor $testDescriptor, array $phpFiles ): ?string {
84        $filename = $testDescriptor->getClassName() . ".php";
85        if ( !array_key_exists( $filename, $phpFiles ) ) {
86            if ( !in_array( $testDescriptor->getFullClassname(), self::EXPECTED_MISSING_CLASSES ) ) {
87                throw new UnlocatedTestException( $testDescriptor );
88            } else {
89                return null;
90            }
91        }
92        if ( count( $phpFiles[$filename] ) === 1 ) {
93            return $phpFiles[$filename][0];
94        }
95        $possibleNamespaces = [];
96        foreach ( $phpFiles[$filename] as $file ) {
97            $namespace = self::extractNamespaceFromFile( $file );
98            if ( $namespace === $testDescriptor->getNamespace() ) {
99                return $file;
100            }
101            $possibleNamespaces[] = $namespace;
102        }
103        throw new MissingNamespaceMatchForTestException( $testDescriptor );
104    }
105
106    private function buildSuites( array $testClasses, int $groups ): array {
107        return ( new TestSuiteBuilder() )->buildSuites( $testClasses, $groups );
108    }
109
110    public function isPhpUnitXmlPrepared(): bool {
111        return PhpUnitXml::isPhpUnitXmlPrepared( $this->rootDir . DIRECTORY_SEPARATOR . "phpunit.xml" );
112    }
113
114    /**
115     * @return void
116     * @throws MissingNamespaceMatchForTestException
117     * @throws TestListMissingException
118     * @throws UnlocatedTestException
119     * @throws SuiteGenerationException
120     */
121    public function createPhpUnitXml( int $groups ) {
122        $unitFile = $this->loadPhpUnitXmlDist();
123        $testFiles = $this->scanForTestFiles();
124        $testClasses = $this->loadTestClasses();
125        $seenFiles = [];
126        foreach ( $testClasses as $testDescriptor ) {
127            $file = $this->resolveFileForTest( $testDescriptor, $testFiles );
128            if ( is_string( $file ) && !array_key_exists( $file, $seenFiles ) ) {
129                $testDescriptor->setFilename( $file );
130                $seenFiles[$file] = 1;
131            }
132        }
133        $suites = $this->buildSuites( $testClasses, $groups - 1 );
134        $unitFile->addSplitGroups( $suites );
135        $unitFile->addSpecialCaseTests( $groups );
136        $unitFile->saveToDisk( $this->getPhpUnitXmlTarget() );
137    }
138
139    public static function listTestsNotice() {
140        print( PHP_EOL );
141        print( 'Running `phpunit --list-tests-xml` to get a list of expected tests ... ' . PHP_EOL );
142        print( PHP_EOL );
143    }
144
145    /**
146     * @throws TestListMissingException
147     * @throws UnlocatedTestException
148     * @throws MissingNamespaceMatchForTestException
149     * @throws SuiteGenerationException
150     */
151    public static function splitTestsList( string $testListFile ) {
152        /**
153         * We split into 8 groups here, because experimentally that generates 100% CPU load
154         * on developer machines and results in groups that are similar in size to the
155         * Parser tests (which we have to run in a group on their own - see T345481)
156         */
157        ( new PhpUnitXmlManager( getcwd(), $testListFile ) )->createPhpUnitXml( 8 );
158        print( PHP_EOL . 'Created modified `phpunit.xml` with test suite groups' . PHP_EOL );
159    }
160
161    /**
162     * @throws TestListMissingException
163     * @throws UnlocatedTestException
164     * @throws MissingNamespaceMatchForTestException
165     * @throws SuiteGenerationException
166     */
167    public static function splitTestsListExtensions() {
168        self::splitTestsList( 'tests-list-extensions.xml' );
169    }
170
171    /**
172     * @throws TestListMissingException
173     * @throws UnlocatedTestException
174     * @throws MissingNamespaceMatchForTestException
175     * @throws SuiteGenerationException
176     */
177    public static function splitTestsListDefault() {
178        self::splitTestsList( 'tests-list-default.xml' );
179    }
180
181    /**
182     * @throws TestListMissingException
183     * @throws UnlocatedTestException
184     * @throws MissingNamespaceMatchForTestException
185     * @throws SuiteGenerationException
186     */
187    public static function splitTestsCustom() {
188        if ( $_SERVER["argc"] < 3 ) {
189            print( 'Specify a filename to split' . PHP_EOL );
190            exit( 1 );
191        }
192        $filename = $_SERVER["argv"][2];
193        self::splitTestsList( $filename );
194    }
195}