From c1a50b895d6cfe476e44b9682025f6f5d213c0f6 Mon Sep 17 00:00:00 2001 From: Lance Willett Date: Thu, 20 Aug 2026 16:52:41 -0700 Subject: [PATCH] Build/Test Tools: Flag slow PHPUnit tests with annotations. Parse the JUnit report from the canonical PHP 8.5 report job and emit GitHub Actions warning annotations plus a run-summary table for tests over a threshold, on pull requests and pushes to trunk. Advisory only: the step runs even on failed test runs and never fails the build itself. This names the slow tests, complementing the aggregate CodeVitals trend that stores them without naming any one test. See #65887. --- .../workflows/reusable-phpunit-tests-v3.yml | 16 ++ .../phpunit/prepare-slow-test-annotations.php | 268 ++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 tests/phpunit/prepare-slow-test-annotations.php diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml index 4f7addcd4e60e..a0d02bb06b00f 100644 --- a/.github/workflows/reusable-phpunit-tests-v3.yml +++ b/.github/workflows/reusable-phpunit-tests-v3.yml @@ -269,6 +269,22 @@ jobs: TEST_GROUPS: ${{ inputs.phpunit-test-groups }} MULTISITE_FLAG: ${{ inputs.multisite && 'multisite' || 'single' }} + - name: Flag slow PHPUnit tests + # Runs even when the test step failed (always()), so the signal still + # surfaces on red runs, and never fails the job itself (continue-on-error), + # because this is advisory only. + if: >- + always() && inputs.report && inputs.php == '8.5' && + ( github.event_name == 'pull_request' || + ( github.event_name == 'push' && github.ref == 'refs/heads/trunk' ) ) + continue-on-error: true + run: | + if [ -f tests/phpunit/build/logs/junit.xml ]; then + php tests/phpunit/prepare-slow-test-annotations.php tests/phpunit/build/logs/junit.xml + else + echo 'PHPUnit JUnit report not found; skipping slow-test annotations.' + fi + - name: Run AJAX tests if: ${{ ! inputs.phpunit-test-groups && ! inputs.coverage-report }} continue-on-error: ${{ inputs.allow-errors }} diff --git a/tests/phpunit/prepare-slow-test-annotations.php b/tests/phpunit/prepare-slow-test-annotations.php new file mode 100644 index 0000000000000..8ed28786b5f24 --- /dev/null +++ b/tests/phpunit/prepare-slow-test-annotations.php @@ -0,0 +1,268 @@ +#!/usr/bin/env php + \ + * [threshold-seconds] [max-annotations] + * + * @package WordPress + * @subpackage UnitTests + */ + +/** + * Escapes a GitHub Actions workflow command message. + * + * @param string $value Message to escape. + * @return string Escaped message. + */ +function wp_phpunit_escape_command_message( $value ) { + return str_replace( + array( '%', "\r", "\n" ), + array( '%25', '%0D', '%0A' ), + $value + ); +} + +/** + * Escapes a GitHub Actions workflow command property. + * + * @param string $value Property value to escape. + * @return string Escaped property value. + */ +function wp_phpunit_escape_command_property( $value ) { + return str_replace( + array( ',', ':' ), + array( '%2C', '%3A' ), + wp_phpunit_escape_command_message( $value ) + ); +} + +/** + * Escapes text for a Markdown table cell. + * + * @param string $value Cell value to escape. + * @return string Escaped cell value. + */ +function wp_phpunit_escape_markdown_cell( $value ) { + return str_replace( + array( '|', "\r", "\n" ), + array( '\\|', ' ', ' ' ), + $value + ); +} + +/** + * Converts a container-absolute test path to a repository-relative one. + * + * PHPUnit records absolute paths (the repository is mounted at /var/www in the + * Docker environment). GitHub annotations need repository-relative paths to + * resolve to a line, so a known workspace prefix is stripped when present. + * + * @param string $file Path recorded in the JUnit report. + * @return string Repository-relative path, or the input unchanged. + */ +function wp_phpunit_relative_path( $file ) { + if ( '' === $file ) { + return ''; + } + + $prefixes = array( '/var/www/' ); + $workspace = getenv( 'GITHUB_WORKSPACE' ); + + if ( is_string( $workspace ) && '' !== $workspace ) { + $prefixes[] = rtrim( $workspace, '/' ) . '/'; + } + + foreach ( $prefixes as $prefix ) { + if ( 0 === strncmp( $file, $prefix, strlen( $prefix ) ) ) { + return substr( $file, strlen( $prefix ) ); + } + } + + return $file; +} + +/** + * Appends a summary to GitHub Actions or writes it to standard output. + * + * @param string $summary Markdown summary. + * @return void + * @throws RuntimeException If the GitHub Actions summary cannot be written. + */ +function wp_phpunit_write_summary( $summary ) { + $summary_file = getenv( 'GITHUB_STEP_SUMMARY' ); + + if ( false === $summary_file || '' === $summary_file ) { + echo $summary; + return; + } + + if ( false === file_put_contents( $summary_file, $summary, FILE_APPEND ) ) { + throw new RuntimeException( 'The GitHub Actions step summary could not be written.' ); + } +} + +if ( $argc < 2 || $argc > 4 ) { + fwrite( + STDERR, + "Usage: php tests/phpunit/prepare-slow-test-annotations.php " + . "[threshold-seconds] [max-annotations]\n" + ); + exit( 1 ); +} + +try { + $file = $argv[1]; + $threshold_value = $argv[2] ?? '1.0'; + $max_annotations = $argv[3] ?? '20'; + + if ( ! is_numeric( $threshold_value ) || (float) $threshold_value < 0 ) { + throw new RuntimeException( 'The slow-test threshold must be a non-negative number.' ); + } + + if ( ! ctype_digit( $max_annotations ) || (int) $max_annotations < 1 ) { + throw new RuntimeException( 'The maximum annotation count must be a positive integer.' ); + } + + if ( ! is_readable( $file ) ) { + throw new RuntimeException( 'The JUnit report could not be read.' ); + } + + $threshold = (float) $threshold_value; + $max_annotations = (int) $max_annotations; + $reader = new XMLReader(); + $previous_libxml_state = libxml_use_internal_errors( true ); + $reader_is_open = false; + + libxml_clear_errors(); + + try { + if ( ! $reader->open( $file, null, LIBXML_NONET | LIBXML_COMPACT ) ) { + throw new RuntimeException( 'The JUnit report could not be opened.' ); + } + + $reader_is_open = true; + $slow_tests = array(); + + while ( $reader->read() ) { + if ( XMLReader::ELEMENT !== $reader->nodeType || 'testcase' !== $reader->name ) { + continue; + } + + $time = $reader->getAttribute( 'time' ); + + // A testcase without numeric timing (for example a skipped test) carries + // no slow-test signal, so it is ignored rather than treated as an error. + if ( ! is_numeric( $time ) ) { + continue; + } + + if ( (float) $time <= $threshold ) { + continue; + } + + $slow_tests[] = array( + 'name' => (string) $reader->getAttribute( 'name' ), + 'class' => (string) $reader->getAttribute( 'class' ), + 'file' => wp_phpunit_relative_path( (string) $reader->getAttribute( 'file' ) ), + 'line' => (string) $reader->getAttribute( 'line' ), + 'time' => (float) $time, + 'time_display' => $time, + ); + } + + $xml_errors = libxml_get_errors(); + } finally { + if ( $reader_is_open ) { + $reader->close(); + } + + libxml_clear_errors(); + libxml_use_internal_errors( $previous_libxml_state ); + } + + foreach ( $xml_errors as $xml_error ) { + if ( LIBXML_ERR_WARNING < $xml_error->level ) { + throw new RuntimeException( 'The JUnit report contains invalid XML.' ); + } + } + + usort( + $slow_tests, + static function ( $left, $right ) { + if ( $left['time'] === $right['time'] ) { + return strcmp( $left['class'] . '::' . $left['name'], $right['class'] . '::' . $right['name'] ); + } + + return $right['time'] <=> $left['time']; + } + ); + + $slow_tests = array_slice( $slow_tests, 0, $max_annotations ); + + if ( ! $slow_tests ) { + wp_phpunit_write_summary( "No PHPUnit tests exceeded {$threshold_value}s.\n" ); + exit( 0 ); + } + + // GitHub Actions renders at most 10 warning annotations per step, so the inline + // annotations are capped there while the summary table below can list more. + foreach ( array_slice( $slow_tests, 0, 10 ) as $test ) { + $properties = array(); + + if ( '' !== $test['file'] ) { + $properties[] = 'file=' . wp_phpunit_escape_command_property( $test['file'] ); + } + + if ( '' !== $test['line'] ) { + $properties[] = 'line=' . wp_phpunit_escape_command_property( $test['line'] ); + } + + $properties[] = 'title=' . wp_phpunit_escape_command_property( 'Slow PHPUnit test' ); + $message = sprintf( + '%s::%s took %ss', + $test['class'], + $test['name'], + $test['time_display'] + ); + + printf( + "::warning %s::%s\n", + implode( ',', $properties ), + wp_phpunit_escape_command_message( $message ) + ); + } + + $summary = "### Slowest PHPUnit tests (main suite, over {$threshold_value}s)\n\n"; + $summary .= "| Test | Time (s) | File:line |\n"; + $summary .= "| --- | ---: | --- |\n"; + + foreach ( $slow_tests as $test ) { + $location = $test['file']; + + if ( '' !== $test['line'] ) { + $location .= ( '' !== $location ? ':' : 'Line ' ) . $test['line']; + } + + if ( '' === $location ) { + $location = '—'; + } + + $summary .= sprintf( + "| %s::%s | %s | %s |\n", + wp_phpunit_escape_markdown_cell( $test['class'] ), + wp_phpunit_escape_markdown_cell( $test['name'] ), + $test['time_display'], + wp_phpunit_escape_markdown_cell( $location ) + ); + } + + wp_phpunit_write_summary( $summary ); +} catch ( Throwable $error ) { + fwrite( STDERR, $error->getMessage() . "\n" ); + exit( 1 ); +}