Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
43.94% covered (danger)
43.94%
58 / 132
29.41% covered (danger)
29.41%
5 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
ComposerLaunchParallel
44.96% covered (danger)
44.96%
58 / 129
29.41% covered (danger)
29.41%
5 / 17
292.59
0.00% covered (danger)
0.00%
0 / 1
 __construct
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
4.05
 isDatabaseRun
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isDatabaseRunForGroups
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 start
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getLogFilePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 updateTestTimings
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
3.02
 getGroupName
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 prepareEnvironment
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 runTestSuite
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 extractArgs
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 launchTests
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
30
 launchTestsCustomGroups
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getDatabaseExcludeGroups
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 launchTestsDatabase
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getDatabaselessExcludeGroups
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 launchTestsDatabaseless
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getSplitGroupCount
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
5.40
1<?php
2
3declare( strict_types = 1 );
4
5namespace MediaWiki\Composer;
6
7use Composer\Script\Event;
8use MediaWiki\Composer\PhpUnitSplitter\InvalidSplitGroupCountException;
9use MediaWiki\Composer\PhpUnitSplitter\LockingException;
10use MediaWiki\Composer\PhpUnitSplitter\PhpUnitConsoleOutputProcessingException;
11use MediaWiki\Composer\PhpUnitSplitter\PhpUnitConsoleOutputProcessor;
12use MediaWiki\Composer\PhpUnitSplitter\PhpUnitXml;
13use MediaWiki\Composer\PhpUnitSplitter\SplitGroupExecutor;
14use MediaWiki\Maintenance\ForkController;
15use Shellbox\Shellbox;
16
17$basePath = getenv( 'MW_INSTALL_PATH' ) !== false ? getenv( 'MW_INSTALL_PATH' ) : __DIR__ . '/../..';
18
19require_once $basePath . '/includes/BootstrapHelperFunctions.php';
20require_once $basePath . '/maintenance/includes/ForkController.php';
21
22/**
23 * Launch PHPUnit test suites in parallel.
24 *
25 * This class is run directly from composer.json,
26 * outside of any MediaWiki context;
27 * consequently, most MediaWiki code cannot be used here.
28 * We extend ForkController because it's convenient to do so and ForkController still works here,
29 * but we cannot use e.g. Shell::command() to run the composer sub-commands,
30 * nor anything else that requires MediaWiki services or config.
31 * (But we can use the underlying Shellbox library directly.)
32 *
33 * @license GPL-2.0-or-later
34 */
35class ComposerLaunchParallel extends ForkController {
36
37    private SplitGroupExecutor $splitGroupExecutor;
38    private ComposerSystemInterface $composerSystemInterface;
39    private string $logDir;
40
41    private const DEFAULT_SPLIT_GROUP_COUNT = 8;
42
43    private const ALWAYS_EXCLUDE = [ 'Broken' ];
44    public const DATABASELESS_GROUPS = [];
45    public const DATABASE_GROUPS = [ 'Database' ];
46    private array $groups = [];
47    private array $excludeGroups = [];
48
49    public const EXIT_STATUS_SUCCESS = 0;
50    public const EXIT_STATUS_FAILURE = 1;
51    public const EXIT_STATUS_PHPUNIT_LIST_TESTS_ERROR = 2;
52
53    public function __construct(
54        string $phpUnitConfigFile,
55        array $groups,
56        array $excludeGroups,
57        ?Event $event,
58        ?SplitGroupExecutor $splitGroupExecutor = null,
59        ?ComposerSystemInterface $composerSystemInterface = null,
60        ?string $logDir = null
61    ) {
62        $this->groups = $groups;
63        $this->excludeGroups = $excludeGroups;
64        if ( $logDir ) {
65            $this->logDir = $logDir;
66        } else {
67            $envLogDir = getenv( 'MW_LOG_DIR' );
68            $this->logDir = is_string( $envLogDir ) ? $envLogDir : '.';
69        }
70        $this->composerSystemInterface = $composerSystemInterface ?? new ComposerSystemInterface();
71        $this->splitGroupExecutor = $splitGroupExecutor ?? new SplitGroupExecutor(
72            $phpUnitConfigFile, Shellbox::createUnboxedExecutor(), $event->getIO(), $this->composerSystemInterface
73        );
74
75        /**
76         * By default, the splitting process splits the tests into 8 groups. 7 of the groups are composed
77         * of evenly distributed test classes extracted from the `--list-tests-xml` phpunit function. The
78         * last group contains just the ExtensionsParserTestSuite.  We first check if
79         * PHPUNIT_PARALLEL_GROUP_COUNT is set in the environment, and override the group count
80         * if so.
81         */
82        $splitGroupCount = self::getSplitGroupCount();
83        if ( !$this->isDatabaseRun() ) {
84            /**
85             * In the splitting, we put ExtensionsParserTestSuite in `split_group_7` on its own. We only
86             * need to run `split_group_7` when we run Database tests, since all Parser tests use the
87             * database. Running `split_group_7` when no matches tests get executed results in a phpunit
88             * error code.
89             */
90            $splitGroupCount = $splitGroupCount - 1;
91        }
92        parent::__construct( $splitGroupCount );
93    }
94
95    private function isDatabaseRun(): bool {
96        return self::isDatabaseRunForGroups( $this->groups, $this->excludeGroups );
97    }
98
99    private static function isDatabaseRunForGroups( array $groups, array $excludeGroups ): bool {
100        return in_array( 'Database', $groups ) &&
101            !in_array( 'Database', $excludeGroups );
102    }
103
104    /**
105     * @inheritDoc
106     * @throws LockingException
107     */
108    public function start(): string {
109        $status = parent::start();
110        if ( $status === 'child' ) {
111            $this->runTestSuite( $this->getChildNumber() );
112        }
113        return $status;
114    }
115
116    /**
117     * Generate a path in the log directory for a given filename
118     *
119     * @param string $filename
120     * @return string
121     */
122    private function getLogFilePath( string $filename ) {
123        return implode( DIRECTORY_SEPARATOR, [ $this->logDir, $filename ] );
124    }
125
126    /**
127     * @throws LockingException
128     */
129    private function updateTestTimings( int $childNumber, float $elapsedTime ): void {
130        $groupName = $this->getGroupName();
131        $splitGroupTimingFilePath = $this->getLogFilePath( "phpunit_{$groupName}_split_group_timings.json" );
132        touch( $splitGroupTimingFilePath );
133        $splitGroupTimingFileHandle = fopen( $splitGroupTimingFilePath, "r+" );
134        if ( flock( $splitGroupTimingFileHandle, LOCK_EX ) ) {
135            $existingContent = file_get_contents( $splitGroupTimingFilePath );
136            if ( strlen( $existingContent ) === 0 ) {
137                $timingData = [];
138            } else {
139                $timingData = json_decode( $existingContent, true );
140            }
141            $timingData[ "PHPUnit {$groupName} split_group {$childNumber}" ] = $elapsedTime;
142            ftruncate( $splitGroupTimingFileHandle, 0 );
143            fwrite( $splitGroupTimingFileHandle, json_encode( $timingData, JSON_FORCE_OBJECT ) );
144            fclose( $splitGroupTimingFileHandle );
145        } else {
146            fclose( $splitGroupTimingFileHandle );
147            throw new LockingException( $splitGroupTimingFilePath );
148        }
149    }
150
151    private function getGroupName(): string {
152        $excludeGroups = array_diff( $this->excludeGroups, $this->groups );
153        if ( !self::isDatabaseRunForGroups( $this->groups, $excludeGroups ) ) {
154            return "databaseless";
155        }
156        return "database";
157    }
158
159    protected function prepareEnvironment() {
160        // Skip parent class method to avoid errors:
161        // this script does not run inside MediaWiki, so there is no environment to prepare
162    }
163
164    /**
165     * @throws LockingException
166     */
167    private function runTestSuite( int $groupId ): void {
168        $startTime = microtime( true );
169        $excludeGroups = array_diff( $this->excludeGroups, $this->groups );
170        $groupName = $this->getGroupName();
171        $resultCacheFile = $this->getLogFilePath( "phpunit_group_{$groupId}_{$groupName}.result.cache" );
172        $result = $this->splitGroupExecutor->executeSplitGroup(
173            "split_group_$groupId",
174            $this->groups,
175            $excludeGroups,
176            $resultCacheFile,
177            $groupId
178        );
179        $consoleOutput = $result->getStdout();
180        if ( $consoleOutput ) {
181            $this->composerSystemInterface->putFileContents(
182                "phpunit_output_{$groupId}_{$groupName}.log",
183                $consoleOutput
184            );
185        }
186        $this->composerSystemInterface->print( $consoleOutput );
187        $this->updateTestTimings( $this->getChildNumber(), microtime( true ) - $startTime );
188        $this->composerSystemInterface->exit( $result->getExitCode() );
189    }
190
191    private static function extractArgs(): array {
192        $options = [];
193        foreach ( [ "group", "exclude-group" ] as $argument ) {
194            $groupIndex = array_search( "--" . $argument, $_SERVER['argv'] );
195            if ( $groupIndex > 0 ) {
196                if ( count( $_SERVER['argv'] ) > $groupIndex + 1 ) {
197                    $nextArg = $_SERVER['argv'][$groupIndex + 1];
198                    if ( str_starts_with( $nextArg, "--" ) ) {
199                        throw new \InvalidArgumentException(
200                            "parameter " . $argument . " takes a variable - none supplied"
201                        );
202                    }
203                    $options[$argument] = $nextArg;
204                } else {
205                    throw new \InvalidArgumentException(
206                        "parameter " . $argument . " takes a variable - not enough arguments supplied"
207                    );
208                }
209            }
210        }
211        return $options;
212    }
213
214    /**
215     * @throws PhpUnitConsoleOutputProcessingException
216     */
217    public static function launchTests( Event $event, array $groups, array $excludeGroups ): void {
218        $groupName = self::isDatabaseRunForGroups( $groups, $excludeGroups ) ? "database" : "databaseless";
219        $phpUnitConfig = getcwd() . DIRECTORY_SEPARATOR . 'phpunit-' . $groupName . '.xml';
220        if ( !PhpUnitXml::isPhpUnitXmlPrepared( $phpUnitConfig ) ) {
221            $event->getIO()->error( "%s is not present or does not contain split test suites", [ $phpUnitConfig ] );
222            $event->getIO()->error( "run `composer phpunit:prepare-parallel:...` to generate the split suites" );
223            exit( self::EXIT_STATUS_FAILURE );
224        }
225        $event->getIO()->info( "Running 'split_group_X' suites in parallel..." );
226        $launcher = new ComposerLaunchParallel( $phpUnitConfig, $groups, $excludeGroups, $event );
227        $launcher->start();
228        if ( $launcher->allSuccessful() ) {
229            $event->getIO()->info( "All split_groups succeeded!" );
230            exit( self::EXIT_STATUS_SUCCESS );
231        } else {
232            $event->getIO()->write( PHP_EOL . PHP_EOL );
233            $event->getIO()->warning( "Some split_groups failed - returning failure status" );
234            $groupName = self::isDatabaseRunForGroups( $groups, $excludeGroups ) ? "database" : "databaseless";
235            $event->getIO()->warning( "Summarizing parallel error logs for " . $groupName . " group..." );
236            $event->getIO()->write( PHP_EOL );
237            PhpUnitConsoleOutputProcessor::collectAndDumpFailureSummary(
238                "phpunit_output_%d_{$groupName}.log",
239                self::getSplitGroupCount(),
240                $event->getIO()
241            );
242            exit( self::EXIT_STATUS_FAILURE );
243        }
244    }
245
246    public static function launchTestsCustomGroups( Event $event ) {
247        $options = self::extractArgs();
248        if ( array_key_exists( 'exclude-group', $options ) ) {
249            $excludeGroups = explode( ',', $options['exclude-group'] );
250        } else {
251            $excludeGroups = [ 'Broken', 'Standalone', 'Database' ];
252        }
253        if ( array_key_exists( 'group', $options ) ) {
254            $groups = explode( ',', $options['group'] );
255        } else {
256            $groups = [];
257        }
258        self::launchTests( $event, $groups, $excludeGroups );
259    }
260
261    public static function getDatabaseExcludeGroups(): array {
262        return array_merge( self::ALWAYS_EXCLUDE, [ 'Standalone' ] );
263    }
264
265    public static function launchTestsDatabase( Event $event ) {
266        self::launchTests(
267            $event,
268            self::DATABASE_GROUPS,
269            self::getDatabaseExcludeGroups()
270        );
271    }
272
273    public static function getDatabaselessExcludeGroups(): array {
274        return array_merge( self::ALWAYS_EXCLUDE, [ 'Standalone', 'Database' ] );
275    }
276
277    public static function launchTestsDatabaseless( Event $event ) {
278        self::launchTests(
279            $event,
280            self::DATABASELESS_GROUPS,
281            self::getDatabaselessExcludeGroups()
282        );
283    }
284
285    /**
286     * Get a split group count, either from the default defined on this class, or from
287     * PHPUNIT_PARALLEL_GROUP_COUNT in the environment.
288     *
289     * Throws InvalidSplitGroupCountException for an invalid count.
290     */
291    public static function getSplitGroupCount(): int {
292        $splitGroupCount = self::DEFAULT_SPLIT_GROUP_COUNT;
293
294        $envSplitGroupCount = getenv( 'PHPUNIT_PARALLEL_GROUP_COUNT' );
295        if ( $envSplitGroupCount !== false ) {
296            if ( !preg_match( '/^\d+$/', $envSplitGroupCount ) ) {
297                throw new InvalidSplitGroupCountException( $envSplitGroupCount );
298            }
299            $splitGroupCount = (int)$envSplitGroupCount;
300        }
301
302        if ( $splitGroupCount < 2 ) {
303            throw new InvalidSplitGroupCountException( (string)$splitGroupCount );
304        }
305
306        return $splitGroupCount;
307    }
308
309}