-
-
Notifications
You must be signed in to change notification settings - Fork 86
fix(ci): run every PHPUnit test file #1843
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,285 @@ | ||
| <?php | ||
| /** | ||
| * Run PHPUnit test files in deterministic, memory-bounded suites. | ||
| * | ||
| * PHPUnit accepts one file or directory as its positional test target. Passing | ||
| * many files through xargs silently runs only the first file in each batch. | ||
| * This runner creates a temporary configuration for every batch and removes | ||
| * plugin-owned test tables between processes so reset WordPress IDs cannot | ||
| * collide with stale Ultimate Multisite records. | ||
| * | ||
| * @package WP_Ultimo | ||
| */ | ||
|
|
||
| // WordPress is not loaded in this CLI runner, so native file, database, and process APIs are required. | ||
| // phpcs:disable WordPress.WP.AlternativeFunctions, WordPress.PHP.DiscouragedPHPFunctions.system_calls_proc_open, WordPress.DB.RestrictedFunctions | ||
|
|
||
| $project_root = dirname(__DIR__); | ||
| $phpunit = $project_root . '/vendor/phpunit/phpunit/phpunit'; | ||
| $bootstrap = $project_root . '/tests/bootstrap.php'; | ||
| $batch_size = (int) (getenv('PHPUNIT_BATCH_SIZE') ?: 1); | ||
| $batch_number = (int) (getenv('PHPUNIT_BATCH_NUMBER') ?: 0); | ||
| $input_paths = array_slice($argv, 1) ?: [$project_root . '/tests']; | ||
|
|
||
| if ($batch_size < 1) { | ||
| fwrite(STDERR, "PHPUNIT_BATCH_SIZE must be at least 1.\n"); | ||
| exit(2); | ||
| } | ||
|
|
||
| if ($batch_number < 0) { | ||
| fwrite(STDERR, "PHPUNIT_BATCH_NUMBER cannot be negative.\n"); | ||
| exit(2); | ||
| } | ||
|
|
||
| if ( ! is_file($phpunit) || ! is_file($bootstrap)) { | ||
| fwrite(STDERR, "Install Composer dependencies before running PHPUnit batches.\n"); | ||
| exit(2); | ||
| } | ||
|
|
||
| $tests_directory = getenv('WP_TESTS_DIR') ?: rtrim(sys_get_temp_dir(), '/\\') . '/wordpress-tests-lib'; | ||
| $config_path = getenv('WP_TESTS_CONFIG_FILE_PATH') ?: $tests_directory . '/wp-tests-config.php'; | ||
|
|
||
| if (is_dir($config_path)) { | ||
| $config_path = rtrim($config_path, '/\\') . '/wp-tests-config.php'; | ||
| } | ||
|
|
||
| if ( ! is_file($config_path)) { | ||
| fwrite(STDERR, "The WordPress test configuration could not be found.\n"); | ||
| exit(2); | ||
| } | ||
|
|
||
| require $config_path; | ||
|
|
||
| if ( ! class_exists('mysqli') || ! defined('DB_NAME') || ! defined('DB_USER') || ! defined('DB_PASSWORD') || ! defined('DB_HOST') || ! isset($table_prefix)) { | ||
| fwrite(STDERR, "The WordPress test database configuration is incomplete.\n"); | ||
| exit(2); | ||
| } | ||
|
|
||
| $test_files = []; | ||
|
|
||
| foreach ($input_paths as $input_path) { | ||
| $path = realpath($input_path); | ||
|
|
||
| if (false === $path) { | ||
| fwrite(STDERR, sprintf("Test path does not exist: %s\n", $input_path)); | ||
| exit(2); | ||
| } | ||
|
|
||
| if (is_file($path)) { | ||
| if (str_ends_with($path, 'Test.php')) { | ||
| $test_files[] = $path; | ||
| } | ||
|
|
||
| continue; | ||
| } | ||
|
|
||
| $iterator = new RecursiveIteratorIterator( | ||
| new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS) | ||
| ); | ||
|
|
||
| foreach ($iterator as $file) { | ||
| if ($file->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) => ' <file>' . htmlspecialchars($file, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</file>', | ||
| $batch | ||
| ); | ||
| $config = sprintf( | ||
| "<?xml version=\"1.0\"?>\n<phpunit bootstrap=\"%s\" backupGlobals=\"false\" colors=\"true\">\n <php>\n <const name=\"WP_TESTS_MULTISITE\" value=\"1\"/>\n </php>\n <testsuites>\n <testsuite name=\"batch-%d\">\n%s\n </testsuite>\n </testsuites>\n</phpunit>\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); | ||
| 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); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🌐 Web query:
What DB_HOST formats does WordPress wpdb::parse_db_host() support, and how must each parsed value be passed to mysqli_real_connect()?💡 Result:
The WordPress method wpdb::parse_db_host is designed to decompose the DB_HOST configuration string into separate components required for mysqli_real_connect, which does not natively support combined host, port, or socket strings [1][2]. Supported DB_HOST Formats The method expects the input string to follow one of two general patterns, optionally suffixed with a Unix socket path separated by:/: 1. IPv6 Address: Identified by the presence of at least two colons in the host component [1][3]. - Format: [host]:port or host:port - Example: [::1]:3306 or::1:3306 2. IPv4 Address or Hostname: - Format: host:port - Example: 127.0.0.1:3306 or db.example.com:3306 Socket Handling If the string contains:/, the method treats the substring following it as a Unix socket path and extracts it separately [1][4]. For example, localhost:/tmp/mysql.sock parses the host as localhost and the socket as /tmp/mysql.sock [1]. How Parsed Values are Passed to mysqli_real_connect The array returned by parse_db_host contains four elements in this order: host, port, socket, and a boolean indicating if it is an IPv6 address [1][3]. Within wpdb::db_connect, these are passed to mysqli_real_connect as follows [5]: - Host: The parsed host string is used. If the address is IPv6 and the PHP mysqlnd extension is loaded, the host is wrapped in square brackets (e.g., [::1]) to ensure compatibility [5]. - Port: Passed as an integer or null [1]. - Socket: Passed as a string or null [1]. The call signature effectively uses these variables as: mysqli_real_connect($dbh, $host, $dbuser, $dbpassword, null, $port, $socket, $client_flags) [5].
Citations:
🏁 Script executed:
Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 5180
🤖 get_repo_knowledge executed:
get_repo_knowledge Ultimate-Multisite/ultimate-multisite /tmp/coderabbit-repo-knowledge/ultimate-multisite-ultimate-multisite-479ad920/conventionsLength of output: 3504
Support WordPress-compatible
DB_HOSTformats.For
DB_HOST='localhost:/tmp/mysql.sock'or an IPv6 value, the parser leaves the complete value in$db_host, keeps$db_portat0, and provides no socket toreal_connect(). This can make the cleanup connection fail or use the wrong endpoint. Parse the host, port, and socket with WordPress-compatible semantics before callingreal_connect().🤖 Prompt for AI Agents