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