diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index f9c427a9..a9d72e8a 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -109,11 +109,9 @@ jobs:
- name: Run PHPUnit Tests
if: matrix.php-version != '8.3'
- run: |
- # The full suite exceeds the container memory limit when run in one PHP process.
- # Restart PHPUnit between small batches so every test file still runs.
- find tests -name '*_Test.php' -type f -print0 \
- | xargs -0 -r -n 50 vendor/bin/phpunit
+ # Run bounded XML suites rather than passing many positional paths to PHPUnit.
+ # Ten files keep the largest suites below the runner memory limit.
+ run: PHPUNIT_BATCH_SIZE=10 php scripts/run-phpunit-batches.php
- name: Run PHPUnit Tests with Coverage
if: matrix.php-version == '8.3'
diff --git a/scripts/run-phpunit-batches.php b/scripts/run-phpunit-batches.php
new file mode 100644
index 00000000..76dbc8fc
--- /dev/null
+++ b/scripts/run-phpunit-batches.php
@@ -0,0 +1,313 @@
+isFile() && str_ends_with($file->getPathname(), 'Test.php')) {
+ $test_files[] = $file->getPathname();
+ }
+ }
+}
+
+$test_files = array_values(array_unique($test_files));
+sort($test_files, SORT_STRING);
+
+if (empty($test_files)) {
+ fwrite(STDERR, "No PHPUnit test files were found.\n");
+ exit(2);
+}
+
+$clean_plugin_tables = static function () use ($table_prefix) {
+ $db_host = DB_HOST;
+ $db_port = 0;
+
+ if (preg_match('/^([^:]+):(\d+)$/', DB_HOST, $host_parts)) {
+ $db_host = $host_parts[1];
+ $db_port = (int) $host_parts[2];
+ }
+
+ mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
+ $database = mysqli_init();
+ $database->real_connect($db_host, DB_USER, DB_PASSWORD, DB_NAME, $db_port);
+ $tables = $database->query('SHOW TABLES');
+ $prefixes = [
+ $table_prefix . 'wu_',
+ $table_prefix . 'actionscheduler_',
+ ];
+
+ $database->query('SET FOREIGN_KEY_CHECKS = 0');
+
+ while (true) {
+ $table = $tables->fetch_row();
+
+ if (null === $table) {
+ break;
+ }
+
+ foreach ($prefixes as $prefix) {
+ if (str_starts_with($table[0], $prefix)) {
+ $table_name = str_replace('`', '``', $table[0]);
+ $database->query("DROP TABLE IF EXISTS `{$table_name}`");
+ break;
+ }
+ }
+ }
+
+ $database->query('SET FOREIGN_KEY_CHECKS = 1');
+ $database->close();
+};
+
+$count_executed_files = static function ($junit_path, $batch) {
+ if ( ! is_file($junit_path)) {
+ throw new RuntimeException('PHPUnit did not write its JUnit report.');
+ }
+
+ $report = simplexml_load_file($junit_path);
+
+ if (false === $report) {
+ throw new RuntimeException('PHPUnit wrote an unreadable JUnit report.');
+ }
+
+ $test_cases = $report->xpath('//testcase[@file]');
+
+ if (false === $test_cases) {
+ throw new RuntimeException('PHPUnit JUnit report test cases could not be read.');
+ }
+
+ $expected_files = array_fill_keys($batch, true);
+ $executed_files = [];
+
+ foreach ($test_cases as $test_case) {
+ $test_file = realpath((string) $test_case['file']);
+
+ if (false !== $test_file && isset($expected_files[ $test_file ])) {
+ $executed_files[ $test_file ] = true;
+ }
+ }
+
+ return count($executed_files);
+};
+
+$batches = array_chunk($test_files, $batch_size);
+$total_batches = count($batches);
+$expected_files = count($test_files);
+$executed_files = 0;
+$batch_offset = 0;
+$exit_status = 0;
+
+if ($batch_number) {
+ if ( ! isset($batches[ $batch_number - 1 ])) {
+ fwrite(STDERR, sprintf("PHPUnit batch %d does not exist; expected 1-%d.\n", $batch_number, $total_batches));
+ exit(2);
+ }
+
+ $batch_offset = $batch_number - 1;
+ $batches = [$batches[ $batch_offset ]];
+ $expected_files = count($batches[0]);
+}
+
+foreach ($batches as $index => $batch) {
+ $current_batch = $batch_offset + $index + 1;
+
+ try {
+ $clean_plugin_tables();
+ } catch (Throwable $exception) {
+ fwrite(STDERR, sprintf("Unable to reset plugin test tables: %s\n", $exception->getMessage()));
+ exit(2);
+ }
+
+ $config_path = tempnam(sys_get_temp_dir(), 'wu-phpunit-batch-');
+ $junit_path = tempnam(sys_get_temp_dir(), 'wu-phpunit-junit-');
+
+ if (false === $config_path || false === $junit_path) {
+ if (false !== $config_path) {
+ unlink($config_path);
+ }
+
+ if (false !== $junit_path) {
+ unlink($junit_path);
+ }
+
+ fwrite(STDERR, "Unable to create a temporary PHPUnit configuration.\n");
+ exit(2);
+ }
+
+ $file_nodes = array_map(
+ static fn($file) => ' ' . htmlspecialchars($file, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '',
+ $batch
+ );
+ $config = sprintf(
+ "\n\n \n \n \n \n \n%s\n \n \n\n",
+ htmlspecialchars($bootstrap, ENT_XML1 | ENT_QUOTES, 'UTF-8'),
+ $current_batch,
+ implode("\n", $file_nodes)
+ );
+
+ if (strlen($config) !== file_put_contents($config_path, $config)) {
+ unlink($config_path);
+ unlink($junit_path);
+ fwrite(STDERR, "Unable to write a temporary PHPUnit configuration.\n");
+ exit(2);
+ }
+
+ fwrite(STDOUT, sprintf("Running PHPUnit batch %d/%d (%d files)\n", $current_batch, $total_batches, count($batch)));
+
+ $process = proc_open(
+ [PHP_BINARY, $phpunit, '--configuration', $config_path, '--no-coverage', '--log-junit', $junit_path],
+ [STDIN, STDOUT, STDERR],
+ $pipes,
+ $project_root
+ );
+
+ if ( ! is_resource($process)) {
+ unlink($config_path);
+ unlink($junit_path);
+ fwrite(STDERR, "Unable to start PHPUnit.\n");
+ exit(2);
+ }
+
+ $batch_status = proc_close($process);
+
+ if (0 !== $batch_status) {
+ unlink($config_path);
+ unlink($junit_path);
+
+ if (9 === $batch_status && 1 < count($batch)) {
+ fwrite(STDERR, sprintf("PHPUnit batch %d/%d was killed; retrying each file separately.\n", $current_batch, $total_batches));
+
+ foreach ($batch as $test_file) {
+ $retry_environment = getenv();
+ $retry_environment['PHPUNIT_BATCH_NUMBER'] = '0';
+ $retry_process = proc_open(
+ [PHP_BINARY, __FILE__, $test_file],
+ [STDIN, STDOUT, STDERR],
+ $retry_pipes,
+ $project_root,
+ $retry_environment
+ );
+
+ if ( ! is_resource($retry_process) || 0 !== proc_close($retry_process)) {
+ fwrite(STDERR, sprintf("PHPUnit retry failed for %s.\n", $test_file));
+ $exit_status = 1;
+ continue;
+ }
+
+ ++$executed_files;
+ }
+
+ continue;
+ }
+
+ fwrite(STDERR, sprintf("PHPUnit batch %d/%d failed with exit code %d.\n", $current_batch, $total_batches, $batch_status));
+ $exit_status = 1;
+ continue;
+ }
+
+ try {
+ $executed_files += $count_executed_files($junit_path, $batch);
+ } catch (RuntimeException $exception) {
+ unlink($config_path);
+ unlink($junit_path);
+ fwrite(STDERR, sprintf("Unable to verify PHPUnit batch %d: %s\n", $current_batch, $exception->getMessage()));
+ exit(2);
+ }
+
+ unlink($config_path);
+ unlink($junit_path);
+}
+
+try {
+ $clean_plugin_tables();
+} catch (Throwable $exception) {
+ fwrite(STDERR, sprintf("Unable to reset plugin test tables: %s\n", $exception->getMessage()));
+ exit(2);
+}
+
+if (0 !== $exit_status) {
+ exit(1);
+}
+
+fwrite(STDOUT, sprintf("PHPUnit test-file accounting: %d/%d executed.\n", $executed_files, $expected_files));
+
+if ($executed_files !== $expected_files) {
+ fwrite(STDERR, "PHPUnit did not execute every discovered test file.\n");
+ exit(2);
+}
+
+exit(0);