Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/reusable-phpunit-tests-v3.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: >-

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

always() also runs the step on cancelled jobs, and phpunit-tests.yml sets cancel-in-progress: true, so PR runs are cancelled often. !cancelled() matches the intent.

always() && inputs.report && inputs.php == '8.5' &&
( github.event_name == 'pull_request' ||
( github.event_name == 'push' && github.ref == 'refs/heads/trunk' ) )
Comment on lines +276 to +279
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 }}
Expand Down
268 changes: 268 additions & 0 deletions tests/phpunit/prepare-slow-test-annotations.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
#!/usr/bin/env php
<?php

/**
* Flags slow PHPUnit tests with GitHub Actions annotations and a run summary.
*
* Usage:
*
* php tests/phpunit/prepare-slow-test-annotations.php <junit-file> \
* [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 <junit-file> "

Check failure on line 112 in tests/phpunit/prepare-slow-test-annotations.php

View workflow job for this annotation

GitHub Actions / Coding standards / PHP checks

String "Usage: php tests/phpunit/prepare-slow-test-annotations.php <junit-file> " does not require double quotes; use single quotes instead
. "[threshold-seconds] [max-annotations]\n"
Comment on lines +112 to +113

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fails the build. The string holds no variable and no escape, so Squiz.Strings.DoubleQuoteUsage rejects the double quotes. Coding standards / PHP checks is the only red check on the PR.

Suggested change
"Usage: php tests/phpunit/prepare-slow-test-annotations.php <junit-file> "
. "[threshold-seconds] [max-annotations]\n"
'Usage: php tests/phpunit/prepare-slow-test-annotations.php <junit-file> '
. "[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 );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The max-annotations argument caps the summary table, not the annotations, which line 214 caps separately at 10. The name says the opposite of what the value does. The table then stops at 20 rows with no marker, so it reads as complete when it is not.


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'] );
}
Comment on lines +217 to +223

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A testcase with line and no file emits ::warning line=60,..., which GitHub ignores. PHPUnit sets both together from reflection, so this branch never fires on a real report — tidying only.

Suggested change
if ( '' !== $test['file'] ) {
$properties[] = 'file=' . wp_phpunit_escape_command_property( $test['file'] );
}
if ( '' !== $test['line'] ) {
$properties[] = 'line=' . wp_phpunit_escape_command_property( $test['line'] );
}
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 = '&mdash;';
}

$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 );
}
Loading