From ab40ea0bcc7693a59812c82b892bc6e5c59e8d78 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 19 Jun 2026 11:22:24 +1000 Subject: [PATCH 01/11] Initial header tests copied from https://github.com/peterwilsoncc/wp-plugin-template --- tests/unit/test-plugin-headers.php | 491 +++++++++++++++++++++++++++++ 1 file changed, 491 insertions(+) create mode 100644 tests/unit/test-plugin-headers.php diff --git a/tests/unit/test-plugin-headers.php b/tests/unit/test-plugin-headers.php new file mode 100644 index 00000000..69e7057d --- /dev/null +++ b/tests/unit/test-plugin-headers.php @@ -0,0 +1,491 @@ + Headers defined in the readme spec. Key: Header; Value: OPTIONAL, REQUIRED, FORBIDDEN. + */ + public static $readme_headers = array( + 'Contributors' => self::REQUIRED, + 'Tags' => self::OPTIONAL, + 'Donate link' => self::OPTIONAL, + 'Tested up to' => self::REQUIRED, + 'Stable tag' => self::REQUIRED, + 'License' => self::REQUIRED, + 'License URI' => self::OPTIONAL, + + // Plugin file headers that do not belong in the readme. + 'Plugin Name' => self::FORBIDDEN, + 'Plugin URI' => self::FORBIDDEN, + 'Description' => self::FORBIDDEN, + 'Version' => self::FORBIDDEN, + 'Author' => self::FORBIDDEN, + 'Author URI' => self::FORBIDDEN, + 'Text Domain' => self::FORBIDDEN, + 'Domain Path' => self::FORBIDDEN, + 'Network' => self::FORBIDDEN, + 'Update URI' => self::FORBIDDEN, + 'Requires at least' => self::FORBIDDEN, // Both WP and the plugin directory prefer the version in the plugin file. + 'Requires PHP' => self::FORBIDDEN, // Both WP and the plugin directory prefer the version in the plugin file. + 'Requires Plugins' => self::FORBIDDEN, + ); + + /** + * Plugin headers specification + * + * @var array Headers defined in the plugin spec. Key: Header; Value: OPTIONAL, REQUIRED, FORBIDDEN. + */ + public static $plugin_headers = array( + 'Plugin Name' => self::REQUIRED, + 'Plugin URI' => self::OPTIONAL, + 'Description' => self::REQUIRED, + 'Version' => self::REQUIRED, + 'Requires at least' => self::REQUIRED, // Not required by the spec but I'm enforcing it. + 'Requires PHP' => self::REQUIRED, // Not required by the spec but I'm enforcing it. + 'Author' => self::REQUIRED, + 'Author URI' => self::OPTIONAL, + 'License' => self::REQUIRED, + 'License URI' => self::OPTIONAL, + 'Text Domain' => self::OPTIONAL, + 'Domain Path' => self::OPTIONAL, + 'Network' => self::OPTIONAL, + 'Update URI' => self::OPTIONAL, + 'Requires Plugins' => self::OPTIONAL, + + // Readme file headers that do not belong in the plugin file. + 'Contributors' => self::FORBIDDEN, + 'Tags' => self::FORBIDDEN, + 'Donate link' => self::FORBIDDEN, + 'Stable tag' => self::FORBIDDEN, + + /* + * Opinionated: Allowed by the spec. + * + * The WordPress plugin directory will use the plugin file headers if + * it exists, and fall back to the readme file if it does not. + * + * However, the 10up Github Action for deploying updates to the + * directory will require a version bump if the plugin file is + * modified, so it's best to keep tested up to in the readme file. + * + * WordPress Core doesn't use the header, it pulls the data in + * from the plugin API. + */ + 'Tested up to' => self::FORBIDDEN, + ); + + /** + * Deprecated headers mapping. + * + * Opinionated: These headers are parsed correctly by the WordPress.org + * plugin repository but go against the recommended headers in the + * documentation. + * + * @var array Mapping of deprecated header to current header. + */ + public static $deprecated_headers = array( + 'Tested' => 'Tested up to', + 'Requires' => 'Requires at least', + ); + + /** + * Headers defined in the plugins readme.text file. + * + * @var string[] Headers defined in the readme spec Header => value. + */ + public static $defined_readme_headers = array(); + + /** + * Headers defined in the plugin file. + * + * @var string[] Headers defined in the plugin spec Header => value. + */ + public static $defined_plugin_headers = array(); + + /** + * Plugin file names. + * + * @var string[] The readme and plugin file names. + */ + public static $file_names = array(); + + /** + * Helper function to read the file headers. + * + * Based on the get_file_data function in wp-includes/functions.php. + * + * @see https://developer.wordpress.org/reference/functions/get_file_data/ + * + * @param string $file The file to read the headers from. + * @param array $default_headers List of headers, in the format array( 'HeaderKey' => 'Header Name' ). + * @param string $context Unused. Included for consistency with the WP function signature. + * @return array Array of file header values keyed by header name. + */ + public static function get_file_data( string $file, array $default_headers, $context = '' ): array { + // Pull only the first 8 KB of the file in. + $file_data = file_get_contents( $file, false, null, 0, 8 * 1024 ); + + if ( false === $file_data ) { + $file_data = ''; + } + + // Make sure we catch CR-only line endings. + $file_data = str_replace( "\r", "\n", $file_data ); + + $all_headers = $default_headers; + + foreach ( $all_headers as $field => $regex ) { + if ( preg_match( '/^(?:[ \t]*<\?php)?[ \t\/*#@]*' . preg_quote( $regex, '/' ) . ':(.*)$/mi', $file_data, $match ) && $match[1] ) { + $all_headers[ $field ] = trim( preg_replace( '/\s*(?:\*\/|\?>).*/', '', $match[1] ) ); + } else { + $all_headers[ $field ] = ''; + } + } + + return $all_headers; + } + + /** + * Set up shared fixtures. + */ + public static function setUpBeforeClass(): void { + parent::setUpBeforeClass(); + // Get the file names. + self::$file_names['readme'] = self::PLUGIN_ROOT_DIR . '/readme.txt'; + + $plugin_file_name = basename( realpath( self::PLUGIN_ROOT_DIR ) ) . '.php'; + if ( ! file_exists( self::PLUGIN_ROOT_DIR . "/{$plugin_file_name}" ) ) { + // Fallback to the generic plugin file name. + $plugin_file_name = 'plugin.php'; + } + + self::$file_names['plugin'] = self::PLUGIN_ROOT_DIR . "/{$plugin_file_name}"; + + // Get the readme headers. + $readme_file_data = array(); + foreach ( self::$readme_headers as $header => $required ) { + $readme_file_data[ $header ] = $header; + } + self::$defined_readme_headers = self::get_file_data( + self::$file_names['readme'], + $readme_file_data + ); + self::$defined_readme_headers = array_filter( self::$defined_readme_headers ); + + // Get the plugin headers. + $plugin_file_data = array(); + foreach ( self::$plugin_headers as $header => $required ) { + $plugin_file_data[ $header ] = $header; + } + + self::$defined_plugin_headers = self::get_file_data( + self::$file_names['plugin'], + $plugin_file_data + ); + self::$defined_plugin_headers = array_filter( self::$defined_plugin_headers ); + } + + /** + * Test that the readme file has all required headers. + * + * @dataProvider data_required_readme_headers + * + * @param string $header Header to test. + */ + public function test_required_readme_headers( $header ) { + $this->assertArrayHasKey( $header, self::$defined_readme_headers, "The readme file header '{$header}' is missing." ); + $this->assertNotEmpty( self::$defined_readme_headers[ $header ], "The readme file header '{$header}' is empty." ); + } + + /** + * Data provider for test_required_readme_headers. + * + * @return array[] Data provider. + */ + public static function data_required_readme_headers() { + $required_headers = array_filter( + self::$readme_headers, + function ( $status ) { + return self::REQUIRED === $status; + } + ); + $headers = array(); + foreach ( $required_headers as $header => $required ) { + $headers[ $header ] = array( $header ); + } + return $headers; + } + + /** + * Test that the readme file does not have any forbidden headers. + * + * @dataProvider data_forbidden_readme_headers + * + * @param string $header Header to test. + */ + public function test_forbidden_readme_headers( $header ) { + $this->assertArrayNotHasKey( $header, self::$defined_readme_headers, "The readme file header '{$header}' is forbidden." ); + } + + /** + * Data provider for test_forbidden_readme_headers. + * + * @return array[] Data provider. + */ + public static function data_forbidden_readme_headers() { + $forbidden_headers = array_filter( + self::$readme_headers, + function ( $status ) { + return self::FORBIDDEN === $status; + } + ); + $headers = array(); + foreach ( $forbidden_headers as $header => $required ) { + $headers[ $header ] = array( $header ); + } + return $headers; + } + + /** + * Test that the plugin file has all required headers. + * + * @dataProvider data_required_plugin_headers + * + * @param string $header Header to test. + */ + public function test_required_plugin_headers( $header ) { + $this->assertArrayHasKey( $header, self::$defined_plugin_headers, "The plugin file header '{$header}' is missing." ); + $this->assertNotEmpty( self::$defined_plugin_headers[ $header ], "The readme file header '{$header}' is empty." ); + } + + /** + * Data provider for test_required_plugin_headers. + * + * @return array[] Data provider. + */ + public static function data_required_plugin_headers() { + $required_headers = array_filter( + self::$plugin_headers, + function ( $status ) { + return self::REQUIRED === $status; + } + ); + $headers = array(); + foreach ( $required_headers as $header => $required ) { + $headers[ $header ] = array( $header ); + } + return $headers; + } + + /** + * Test that the plugin file does not have any forbidden headers. + * + * @dataProvider data_forbidden_plugin_headers + * + * @param string $header Header to test. + */ + public function test_forbidden_plugin_headers( $header ) { + $this->assertArrayNotHasKey( $header, self::$defined_plugin_headers, "The plugin file header '{$header}' is forbidden." ); + } + + /** + * Data provider for test_forbidden_plugin_headers. + * + * @return array[] Data provider. + */ + public static function data_forbidden_plugin_headers() { + $forbidden_headers = array_filter( + self::$plugin_headers, + function ( $status ) { + return self::FORBIDDEN === $status; + } + ); + $headers = array(); + foreach ( $forbidden_headers as $header => $required ) { + $headers[ $header ] = array( $header ); + } + return $headers; + } + + /** + * Test that headers defined in both the readme and plugin file match. + * + * @dataProvider data_common_headers_match + * + * @param string $plugin_header_name Plugin file header name to test. + * @param string|null $readme_header_name Readme file header name to test. If null, the plugin header name will be used. + */ + public function test_common_headers_match( $plugin_header_name, $readme_header_name = null ) { + $readme_header_name = $readme_header_name ?? $plugin_header_name; + if ( empty( self::$defined_plugin_headers[ $plugin_header_name ] ) || empty( self::$defined_readme_headers[ $readme_header_name ] ) ) { + // The header is not common to both files so the test passes. + $this->assertTrue( true ); + return; + } + + $plugin_header = self::$defined_plugin_headers[ $plugin_header_name ]; + $readme_header = self::$defined_readme_headers[ $readme_header_name ]; + + $message = "The header '{$plugin_header_name}' does not match between the readme and plugin file."; + if ( $plugin_header_name !== $readme_header_name ) { + $message = "The plugin header '{$plugin_header_name}' does not match the readme header '{$readme_header_name}'."; + } + + $this->assertSame( $plugin_header, $readme_header, $message ); + } + + /** + * Data provider for test_common_headers_match. + * + * @return array[] Data provider. + */ + public static function data_common_headers_match() { + // Can't use the defined headers as they are not defined until after this is called. + $common_headers = array_intersect_key( + self::$readme_headers, + self::$plugin_headers + ); + + $headers = array(); + // Always test the version matches the stable tag. + $headers['Stable tag matches version'] = array( 'Version', 'Stable tag' ); + + foreach ( $common_headers as $header => $value ) { + $headers[ $header ] = array( $header ); + } + return $headers; + } + + /** + * Test that no deprecated headers are used. + * + * @dataProvider data_no_deprecated_headers + * + * @param string $file File to test, either 'readme' or 'plugin'. + * @param string $deprecated_header Deprecated header to test. + * @param string $correct_header Correct header to use. + */ + public function test_no_deprecated_headers( $file, $deprecated_header, $correct_header ) { + $file_name = 'readme' === $file ? self::$file_names['readme'] : self::$file_names['plugin']; + $file_data = array( + $deprecated_header => $deprecated_header, + ); + $defined_data = self::get_file_data( + $file_name, + $file_data + ); + $defined_data = array_filter( $defined_data ); + $this->assertArrayNotHasKey( $deprecated_header, $defined_data, "The {$file} file header '{$deprecated_header}' is deprecated. Use '{$correct_header}' instead." ); + } + + /** + * Data provider for test_no_deprecated_headers. + * + * @return array[] Data provider. + */ + public static function data_no_deprecated_headers() { + $files = array( 'readme', 'plugin' ); + foreach ( $files as $file ) { + foreach ( self::$deprecated_headers as $deprecated_header => $correct_header ) { + $test_name = "{$file} - {$deprecated_header}"; + $tests[ $test_name ] = array( $file, $deprecated_header, $correct_header ); + } + } + + return $tests; + } + + /** + * Ensure that the plugin banner includes a low resolution version. + * + * Per the plugin asset guidelines, the high resolution (retina) banner can + * not be used alone, it must be accompanied by a low resolution version. + * + * @dataProvider data_banner_includes_low_res_version + * + * @param string $banner Hi-res banner file name to test. + */ + public function test_banner_includes_low_res_version( $banner ) { + // Remove the extension from the banner file name. + $high_res_banner_prefix = pathinfo( $banner, PATHINFO_FILENAME ) . '.'; + + $low_res_banner_prefix = str_replace( + '1544x500', + '772x250', + $high_res_banner_prefix + ); + + $file_list = scandir( self::WP_ORG_ASSETS_DIR ); + // Search for the low resolution banner file. + $low_res_files = array_filter( + $file_list, + function ( $file ) use ( $low_res_banner_prefix ) { + return str_starts_with( $file, $low_res_banner_prefix ); + } + ); + + $this->assertNotEmpty( + $low_res_files, + "Low resolution banner file for '{$banner}' does not exist." + ); + } + + /** + * Data provider for test_banner_includes_low_res_version. + * + * @return array[] Data provider. + */ + public static function data_banner_includes_low_res_version() { + if ( ! is_dir( self::WP_ORG_ASSETS_DIR ) ) { + // No assets directory, so no banners. + return array(); + } + + $file_list = scandir( self::WP_ORG_ASSETS_DIR ); + + if ( false === $file_list ) { + // No files found, so no banners. + return array(); + } + + // Filter out the files that do not begin with `banner-1544x500`. + $banner_files = array_filter( + $file_list, + function ( $file ) { + return str_starts_with( $file, 'banner-1544x500' ); + } + ); + + if ( empty( $banner_files ) ) { + // No banners found. + return array(); + } + + // Convert each file name to a data provider entry. + $banner_data = array(); + foreach ( $banner_files as $banner_file ) { + $banner_data[ $banner_file ] = array( $banner_file ); + } + + return $banner_data; + } +} From b5ef1d51caddfc60ba55c1a0be22a6ebd5ceb3ab Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 19 Jun 2026 11:28:12 +1000 Subject: [PATCH 02/11] Update tests for 10up repo opinionated decisions. --- tests/unit/test-plugin-headers.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/test-plugin-headers.php b/tests/unit/test-plugin-headers.php index 69e7057d..4e67bcb0 100644 --- a/tests/unit/test-plugin-headers.php +++ b/tests/unit/test-plugin-headers.php @@ -32,6 +32,8 @@ class HeadersTests extends TestCase { 'Stable tag' => self::REQUIRED, 'License' => self::REQUIRED, 'License URI' => self::OPTIONAL, + 'Requires at least' => self::REQUIRED, // Opinionated: Allows out of release cycle bumps. + 'Requires PHP' => self::REQUIRED, // Opinionated: Allows out of release cycle bumps. // Plugin file headers that do not belong in the readme. 'Plugin Name' => self::FORBIDDEN, @@ -44,8 +46,6 @@ class HeadersTests extends TestCase { 'Domain Path' => self::FORBIDDEN, 'Network' => self::FORBIDDEN, 'Update URI' => self::FORBIDDEN, - 'Requires at least' => self::FORBIDDEN, // Both WP and the plugin directory prefer the version in the plugin file. - 'Requires PHP' => self::FORBIDDEN, // Both WP and the plugin directory prefer the version in the plugin file. 'Requires Plugins' => self::FORBIDDEN, ); @@ -59,8 +59,6 @@ class HeadersTests extends TestCase { 'Plugin URI' => self::OPTIONAL, 'Description' => self::REQUIRED, 'Version' => self::REQUIRED, - 'Requires at least' => self::REQUIRED, // Not required by the spec but I'm enforcing it. - 'Requires PHP' => self::REQUIRED, // Not required by the spec but I'm enforcing it. 'Author' => self::REQUIRED, 'Author URI' => self::OPTIONAL, 'License' => self::REQUIRED, @@ -76,6 +74,8 @@ class HeadersTests extends TestCase { 'Tags' => self::FORBIDDEN, 'Donate link' => self::FORBIDDEN, 'Stable tag' => self::FORBIDDEN, + 'Requires PHP' => self::FORBIDDEN, // Opinionated: Allows out of release cycle bumps. + 'Requires at least' => self::FORBIDDEN, // Opinionated: Allows out of release cycle bumps. /* * Opinionated: Allowed by the spec. From 48bc95df889e814c6252c84b3372c07ad4614d5e Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 19 Jun 2026 11:28:28 +1000 Subject: [PATCH 03/11] Move requires PHP header to readme. --- readme.txt | 1 + simple-podcasting.php | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.txt b/readme.txt index 0ded449c..2f340a6a 100644 --- a/readme.txt +++ b/readme.txt @@ -1,6 +1,7 @@ === Simple Podcasting === Contributors: 10up, helen, adamsilverstein, jakemgold, jeffpaul, cadic Tags: podcasting, podcast, apple podcasts, episode, season +Requires PHP: 7.4 Requires at least: 6.8 Tested up to: 7.0 Stable tag: 1.9.1 diff --git a/simple-podcasting.php b/simple-podcasting.php index 452e8569..d87ffb3a 100644 --- a/simple-podcasting.php +++ b/simple-podcasting.php @@ -4,7 +4,6 @@ * Plugin URI: https://github.com/10up/simple-podcasting * Description: Easily set up multiple podcast feeds using built-in WordPress posts. Includes a podcast block for the new WordPress editor. * Version: 1.9.1 - * Requires PHP: 7.4 * Author: 10up * Author URI: http://10up.com/ * License: GPL v2 or later From dde96a6591ff4d19ded09d216a7c9003c17b7f11 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 19 Jun 2026 11:32:10 +1000 Subject: [PATCH 04/11] Rename class to match file name. --- tests/unit/test-plugin-headers.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test-plugin-headers.php b/tests/unit/test-plugin-headers.php index 4e67bcb0..8b65d887 100644 --- a/tests/unit/test-plugin-headers.php +++ b/tests/unit/test-plugin-headers.php @@ -8,9 +8,9 @@ use PHPUnit\Framework\TestCase; /** - * The HeadersTests class tests the plugin headers are in sync and located in the correct files. + * The PluginHeadersTests class tests the plugin headers are in sync and located in the correct files. */ -class HeadersTests extends TestCase { +class PluginHeadersTests extends TestCase { const OPTIONAL = 0; const REQUIRED = 1; From 064e3c0e4cf7e44f3fdfcd39eb55c96b84078d30 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 19 Jun 2026 11:45:42 +1000 Subject: [PATCH 05/11] Test plugin version is consistent. --- tests/unit/test-plugin-version.php | 181 +++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 tests/unit/test-plugin-version.php diff --git a/tests/unit/test-plugin-version.php b/tests/unit/test-plugin-version.php new file mode 100644 index 00000000..7c815c88 --- /dev/null +++ b/tests/unit/test-plugin-version.php @@ -0,0 +1,181 @@ + $default_headers List of headers, in the format array( 'HeaderKey' => 'Header Name' ). + * @param string $context Unused. Included for consistency with the WP function signature. + * @return array Array of file header values keyed by header name. + */ + public static function get_file_data( string $file, array $default_headers, $context = '' ): array { + // Pull only the first 8 KB of the file in. + $file_data = file_get_contents( $file, false, null, 0, 8 * 1024 ); + + if ( false === $file_data ) { + $file_data = ''; + } + + // Make sure we catch CR-only line endings. + $file_data = str_replace( "\r", "\n", $file_data ); + + $all_headers = $default_headers; + + foreach ( $all_headers as $field => $regex ) { + if ( preg_match( '/^(?:[ \t]*<\?php)?[ \t\/*#@]*' . preg_quote( $regex, '/' ) . ':(.*)$/mi', $file_data, $match ) && $match[1] ) { + $all_headers[ $field ] = trim( preg_replace( '/\s*(?:\*\/|\?>).*/', '', $match[1] ) ); + } else { + $all_headers[ $field ] = ''; + } + } + + return $all_headers; + } + + /** + * Test Stable Tag in readme.txt matches plugin version. + */ + public function test_stable_tag_matches_plugin_version() { + $readme_file = self::PLUGIN_ROOT_DIR . '/readme.txt'; + $readme_data = self::get_file_data( + $readme_file, + array( + 'Stable tag' => 'Stable tag', + ) + ); + + $this->assertSame( self::get_plugin_version_constant(), $readme_data['Stable tag'], 'The Stable tag in readme.txt does not match the plugin version.' ); + } + + /** + * Test version header in the plugin file matches plugin version constant. + */ + public function test_plugin_version_header() { + // Get the plugin headers. + // Plugin name. + $plugin_file_name = basename( realpath( self::PLUGIN_ROOT_DIR ) ) . '.php'; + if ( ! file_exists( self::PLUGIN_ROOT_DIR . "/{$plugin_file_name}" ) ) { + // Fallback to the generic plugin file name. + $plugin_file_name = 'plugin.php'; + } + + $plugin_file_data = self::get_file_data( + self::PLUGIN_ROOT_DIR . "/{$plugin_file_name}", + array( + 'Version' => 'Version', + ) + ); + + $this->assertSame( self::get_plugin_version_constant(), $plugin_file_data['Version'], 'The Version header in the plugin file does not match the plugin version constant.' ); + } + + /** + * Test the plugin version in package.json matches the plugin version constant. + */ + public function test_package_json_version() { + $package_file = self::PLUGIN_ROOT_DIR . '/package.json'; + if ( ! file_exists( $package_file ) ) { + // Package file does not exist, consider this test passed. + $this->assertTrue( true ); + return; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- fine for the tests. + $package_data = json_decode( file_get_contents( $package_file ), true ); + $this->assertSame( self::get_plugin_version_constant(), $package_data['version'], 'The version in package.json does not match the plugin version constant.' ); + } + + /** + * Test the plugin version in package-lock.json matches the plugin version constant. + */ + public function test_package_lock_json_version() { + $package_lock_file = self::PLUGIN_ROOT_DIR . '/package-lock.json'; + if ( ! file_exists( $package_lock_file ) ) { + // Package lock file does not exist, consider this test passed. + $this->assertTrue( true ); + return; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- fine for the tests. + $package_lock_data = json_decode( file_get_contents( $package_lock_file ), true ); + $this->assertSame( self::get_plugin_version_constant(), $package_lock_data['version'], 'The version in package-lock.json does not match the plugin version constant.' ); + $this->assertSame( self::get_plugin_version_constant(), $package_lock_data['packages']['']['version'], "The packages['']['version'] in package-lock.json packages does not match the plugin version constant." ); + } + + /** + * Ensure that composer.json does not have a version key. + * + * Per the docs: + * + * > In most cases this is not required and should be omitted (see below). + * > + * > Packagist uses VCS repositories, so the statement above is very much true for Packagist + * > as well. Specifying the version yourself will most likely end up creating problems at + * > some point due to human error. + */ + public function test_composer_version_is_not_present() { + $composer_file = self::PLUGIN_ROOT_DIR . '/composer.json'; + if ( ! file_exists( $composer_file ) ) { + // Composer file does not exist, consider this test passed. + $this->assertTrue( true ); + return; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- fine for the tests. + $composer_data = json_decode( file_get_contents( $composer_file ), true ); + $this->assertArrayNotHasKey( 'version', $composer_data, 'The version key should not be present in composer.json.' ); + } +} From f08ceda9ed060447419d5d5ffd6d9c3ead6c558f Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 19 Jun 2026 11:50:09 +1000 Subject: [PATCH 06/11] Set license URI to be consistent. --- readme.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.txt b/readme.txt index 2f340a6a..1d32a121 100644 --- a/readme.txt +++ b/readme.txt @@ -6,7 +6,7 @@ Requires at least: 6.8 Tested up to: 7.0 Stable tag: 1.9.1 License: GPLv2 or later -License URI: http://www.gnu.org/licenses/gpl-2.0.html +License URI: https://www.gnu.org/licenses/gpl-2.0.html Set up multiple podcast feeds using built-in WordPress posts. Includes a podcast block and podcast transcript block for the WordPress block editor. From a20e95b56f2f7e124d4a3e1f08db7244f680f698 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 19 Jun 2026 11:52:34 +1000 Subject: [PATCH 07/11] Use consistent license string. --- simple-podcasting.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simple-podcasting.php b/simple-podcasting.php index d87ffb3a..5ece0bc6 100644 --- a/simple-podcasting.php +++ b/simple-podcasting.php @@ -6,7 +6,7 @@ * Version: 1.9.1 * Author: 10up * Author URI: http://10up.com/ - * License: GPL v2 or later + * License: GPLv2 or later * License URI: https://www.gnu.org/licenses/gpl-2.0.html * Text Domain: simple-podcasting * From b77e0443b15b62dc676ec76a769a63d067e9e25d Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 19 Jun 2026 11:59:41 +1000 Subject: [PATCH 08/11] Use strpos for PHP 7.4 compat. --- tests/unit/test-plugin-headers.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test-plugin-headers.php b/tests/unit/test-plugin-headers.php index 8b65d887..cfc97757 100644 --- a/tests/unit/test-plugin-headers.php +++ b/tests/unit/test-plugin-headers.php @@ -439,7 +439,7 @@ public function test_banner_includes_low_res_version( $banner ) { $low_res_files = array_filter( $file_list, function ( $file ) use ( $low_res_banner_prefix ) { - return str_starts_with( $file, $low_res_banner_prefix ); + return 0 === strpos( $file, $low_res_banner_prefix ); } ); @@ -471,7 +471,7 @@ public static function data_banner_includes_low_res_version() { $banner_files = array_filter( $file_list, function ( $file ) { - return str_starts_with( $file, 'banner-1544x500' ); + return 0 === strpos( $file, 'banner-1544x500' ); } ); From eca178bf2f8cca0aaf56968fb75a216d6ccbaf60 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 19 Jun 2026 12:22:30 +1000 Subject: [PATCH 09/11] Test minimum required PHP version is consistent. --- tests/unit/test-plugin-headers.php | 35 ++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/unit/test-plugin-headers.php b/tests/unit/test-plugin-headers.php index cfc97757..987751f2 100644 --- a/tests/unit/test-plugin-headers.php +++ b/tests/unit/test-plugin-headers.php @@ -414,6 +414,41 @@ public static function data_no_deprecated_headers() { return $tests; } + /** + * Test minimum PHP requirement matches across composer.json, readme.txt, + * and minimum_php_requirement() in the plugin file. + */ + public function test_minimum_php_requirement_matches_across_files() { + $composer_file = self::PLUGIN_ROOT_DIR . '/composer.json'; + $plugin_file = self::$file_names['plugin']; + + $composer_contents = file_get_contents( $composer_file ); + $this->assertNotFalse( $composer_contents, 'Unable to read composer.json.' ); + + $composer_data = json_decode( $composer_contents, true ); + $this->assertIsArray( $composer_data, 'composer.json is not valid JSON.' ); + $this->assertArrayHasKey( 'require', $composer_data, 'composer.json is missing the require section.' ); + $this->assertArrayHasKey( 'php', $composer_data['require'], 'composer.json is missing require.php.' ); + + preg_match( '/\d+(?:\.\d+)+/', (string) $composer_data['require']['php'], $composer_match ); + $this->assertNotEmpty( $composer_match, 'Unable to parse PHP minimum version from composer.json require.php.' ); + $composer_min_php = $composer_match[0]; + + $this->assertArrayHasKey( 'Requires PHP', self::$defined_readme_headers, "The readme.txt header 'Requires PHP' is missing." ); + $readme_min_php = self::$defined_readme_headers['Requires PHP']; + + $plugin_contents = file_get_contents( $plugin_file ); + $this->assertNotFalse( $plugin_contents, 'Unable to read plugin file.' ); + + $function_pattern = '/function\s+minimum_php_requirement\s*\(\s*\)\s*\{[\s\S]*?return\s+[\"\']([^\"\']+)[\"\']\s*;/'; + preg_match( $function_pattern, $plugin_contents, $plugin_match ); + $this->assertNotEmpty( $plugin_match, 'Unable to parse minimum_php_requirement() return value from plugin file.' ); + $function_min_php = $plugin_match[1]; + + $this->assertSame( $function_min_php, $readme_min_php, 'Minimum PHP version mismatch between minimum_php_requirement() and readme.txt Requires PHP.' ); + $this->assertSame( $function_min_php, $composer_min_php, 'Minimum PHP version mismatch between minimum_php_requirement() and composer.json require.php.' ); + } + /** * Ensure that the plugin banner includes a low resolution version. * From 29fa634d9f82dd3081138c06b3018ed6cda555e0 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 19 Jun 2026 12:22:59 +1000 Subject: [PATCH 10/11] Update minimum PHP version. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 7182b75b..8e204fb5 100755 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ "issues": "https://github.com/10up/simple-podcasting/issues" }, "require": { - "php": ">=7.3" + "php": ">=7.4" }, "require-dev": { "10up/phpcs-composer": "^3.0", From 54d2626df45c6eccee729697e184d1d4b8faa6da Mon Sep 17 00:00:00 2001 From: Peter Wilson <519727+peterwilsoncc@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:25:06 +1000 Subject: [PATCH 11/11] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/unit/test-plugin-headers.php | 12 +++--------- tests/unit/test-plugin-version.php | 12 ++---------- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/tests/unit/test-plugin-headers.php b/tests/unit/test-plugin-headers.php index 987751f2..98954583 100644 --- a/tests/unit/test-plugin-headers.php +++ b/tests/unit/test-plugin-headers.php @@ -108,7 +108,7 @@ class PluginHeadersTests extends TestCase { ); /** - * Headers defined in the plugins readme.text file. + * Headers defined in the plugin's readme.txt file. * * @var string[] Headers defined in the readme spec Header => value. */ @@ -172,13 +172,7 @@ public static function setUpBeforeClass(): void { // Get the file names. self::$file_names['readme'] = self::PLUGIN_ROOT_DIR . '/readme.txt'; - $plugin_file_name = basename( realpath( self::PLUGIN_ROOT_DIR ) ) . '.php'; - if ( ! file_exists( self::PLUGIN_ROOT_DIR . "/{$plugin_file_name}" ) ) { - // Fallback to the generic plugin file name. - $plugin_file_name = 'plugin.php'; - } - - self::$file_names['plugin'] = self::PLUGIN_ROOT_DIR . "/{$plugin_file_name}"; + self::$file_names['plugin'] = self::PLUGIN_ROOT_DIR . '/simple-podcasting.php'; // Get the readme headers. $readme_file_data = array(); @@ -274,7 +268,7 @@ function ( $status ) { */ public function test_required_plugin_headers( $header ) { $this->assertArrayHasKey( $header, self::$defined_plugin_headers, "The plugin file header '{$header}' is missing." ); - $this->assertNotEmpty( self::$defined_plugin_headers[ $header ], "The readme file header '{$header}' is empty." ); + $this->assertNotEmpty( self::$defined_plugin_headers[ $header ], "The plugin file header '{$header}' is empty." ); } /** diff --git a/tests/unit/test-plugin-version.php b/tests/unit/test-plugin-version.php index 7c815c88..d883583b 100644 --- a/tests/unit/test-plugin-version.php +++ b/tests/unit/test-plugin-version.php @@ -23,11 +23,7 @@ class PluginVersionTests extends TestCase { * @throws RuntimeException If the plugin version cannot be determined. */ public static function get_plugin_version_constant() { - $plugin_file_name = basename( realpath( self::PLUGIN_ROOT_DIR ) ) . '.php'; - if ( ! file_exists( self::PLUGIN_ROOT_DIR . "/{$plugin_file_name}" ) ) { - // Fallback to the generic plugin file name. - $plugin_file_name = 'plugin.php'; - } + $plugin_file_name = 'simple-podcasting.php'; /* * Determine the value of `PODCASTING_VERSION` constant. @@ -106,11 +102,7 @@ public function test_stable_tag_matches_plugin_version() { public function test_plugin_version_header() { // Get the plugin headers. // Plugin name. - $plugin_file_name = basename( realpath( self::PLUGIN_ROOT_DIR ) ) . '.php'; - if ( ! file_exists( self::PLUGIN_ROOT_DIR . "/{$plugin_file_name}" ) ) { - // Fallback to the generic plugin file name. - $plugin_file_name = 'plugin.php'; - } + $plugin_file_name = 'simple-podcasting.php'; $plugin_file_data = self::get_file_data( self::PLUGIN_ROOT_DIR . "/{$plugin_file_name}",