From b4d76f62a551e2a1e052055b59c5d91bc8276a4f Mon Sep 17 00:00:00 2001 From: Paul Bearne Date: Wed, 9 Sep 2026 17:23:49 -0400 Subject: [PATCH 1/7] Add GitHub Actions workflow to validate PHPUnit test file conventions. --- .../workflows/validate-phpunit-cleanup-pr.yml | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 .github/workflows/validate-phpunit-cleanup-pr.yml diff --git a/.github/workflows/validate-phpunit-cleanup-pr.yml b/.github/workflows/validate-phpunit-cleanup-pr.yml new file mode 100644 index 0000000000000..6938b55de676b --- /dev/null +++ b/.github/workflows/validate-phpunit-cleanup-pr.yml @@ -0,0 +1,190 @@ +name: Validate PHPUnit Test Cleanup Files + +on: + pull_request_target: + types: [ opened, synchronize, reopened ] + pull_request: + types: [ opened, synchronize, reopened ] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + pull-requests: read + contents: read + +jobs: + validate-files: + name: Validate PHPUnit Test Files + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + + - name: Validate Test File Structure, Namespaces, and Class Names + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require( 'fs' ); + const path = require( 'path' ); + + const pr = context.payload.pull_request; + if ( ! pr ) { + core.info( 'No pull request found in event payload. Skipping PR file validation.' ); + return; + } + + core.info( `Validating test files in PR #${pr.number}...` ); + + // Fetch list of files changed in the pull request + const changedFiles = await github.paginate( github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + per_page: 100, + } ); + + // Helper to get allowed namespace segment variations from a path segment + function normalizeSegment( segment ) { + const lower = segment.toLowerCase(); + if ( lower === 'wp-includes' ) return [ 'WP_Includes' ]; + if ( lower === 'wp-admin' ) return [ 'WP_Admin' ]; + if ( lower === 'admin' ) return [ 'Admin', 'WP_Admin' ]; + if ( lower === 'includes' ) return [ 'Includes', 'WP_Includes' ]; + + const pascal = segment.replace( /(?:^|-|_)+([a-zA-Z0-9])/g, ( _, chr ) => chr.toUpperCase() ); + const upperUnderscore = segment.replace( /-/g, '_' ); + return Array.from( new Set( [ segment, pascal, upperUnderscore ] ) ); + } + + const errors = []; + const passes = []; + let validatedCount = 0; + + for ( const file of changedFiles ) { + const filename = file.filename.replace( /\\/g, '/' ); + + // Check only files within tests/phpunit/tests/ + if ( ! filename.startsWith( 'tests/phpunit/tests/' ) || ! filename.endsWith( '.php' ) ) { + continue; + } + + // Skip deleted files + if ( file.status === 'removed' ) { + core.info( `Skipping deleted file: ${filename}` ); + continue; + } + + const relPath = filename.slice( 'tests/phpunit/tests/'.length ); + const pathParts = relPath.split( '/' ); + const baseName = pathParts.pop(); + + // Skip non-test support files, fixtures, or data directories + if ( baseName === 'base.php' || pathParts.includes( 'data' ) || pathParts.includes( 'fixtures' ) ) { + core.info( `Skipping helper/fixture file: ${filename}` ); + continue; + } + + validatedCount++; + core.info( `\nValidating test file: ${filename}` ); + + // 1. Validate Filename suffix (*Test.php) + if ( ! baseName.endsWith( 'Test.php' ) ) { + errors.push( `[${filename}] Test filename must end with "Test.php" (Pattern: FunctionUnderTest[OptionalSubsetIndicator]Test.php).` ); + } else { + passes.push( `[${filename}] Filename "${baseName}" ends with "Test.php".` ); + } + + const expectedClassName = baseName.replace( /\.php$/, '' ); + + // 2. Validate directory depth (must have a parent directory for Class_Under_Test or Functions_FileName) + if ( pathParts.length < 1 ) { + errors.push( `[${filename}] Test file must reside in a directory representing Class_Under_Test or Functions_FileName.` ); + } + + // Read file content from checked out workspace + let fileContent = ''; + try { + fileContent = fs.readFileSync( path.join( process.env.GITHUB_WORKSPACE || '.', filename ), 'utf8' ); + } catch ( e ) { + errors.push( `[${filename}] Could not read file content: ${e.message}` ); + continue; + } + + // 3. Validate Namespace declaration + const nsMatch = fileContent.match( /^\s*namespace\s+([^;]+);/m ); + if ( ! nsMatch ) { + errors.push( `[${filename}] Missing namespace declaration.` ); + } else { + const declaredNs = nsMatch[1].trim(); + if ( ! declaredNs.startsWith( 'WordPress\\Tests' ) ) { + errors.push( `[${filename}] Namespace "${declaredNs}" must start with "WordPress\\Tests".` ); + } else { + const nsParts = declaredNs.split( '\\' ).slice( 2 ); // Remove 'WordPress' and 'Tests' + if ( nsParts.length !== pathParts.length ) { + errors.push( `[${filename}] Namespace depth (${nsParts.length}: "${declaredNs}") does not match directory depth (${pathParts.length}: "${pathParts.join('/')}").` ); + } else { + let nsMatchFailed = false; + for ( let i = 0; i < pathParts.length; i++ ) { + const expectedVariants = normalizeSegment( pathParts[i] ); + const actualSegment = nsParts[i]; + const matched = expectedVariants.some( ( v ) => v.toLowerCase() === actualSegment.toLowerCase() ); + if ( ! matched ) { + errors.push( `[${filename}] Namespace segment "${actualSegment}" in "${declaredNs}" does not match directory segment "${pathParts[i]}" (expected one of: ${expectedVariants.join( ', ' )}).` ); + nsMatchFailed = true; + } + } + if ( ! nsMatchFailed ) { + passes.push( `[${filename}] Namespace "${declaredNs}" accurately matches file path.` ); + } + } + } + } + + // 4. Validate Class Name declaration + const classMatches = []; + const classRegex = /^\s*(?:final\s+|abstract\s+)?class\s+([a-zA-Z0-9_]+)/gm; + let match; + while ( ( match = classRegex.exec( fileContent ) ) !== null ) { + classMatches.push( match[1] ); + } + + if ( classMatches.length === 0 ) { + errors.push( `[${filename}] No class declaration found in file.` ); + } else if ( classMatches.length > 1 ) { + errors.push( `[${filename}] Declares multiple classes (${classMatches.join(', ')}). Exactly one test class is allowed per file.` ); + } else { + const declaredClass = classMatches[0]; + if ( declaredClass !== expectedClassName ) { + errors.push( `[${filename}] Class name "${declaredClass}" does not match filename "${baseName}" (expected "${expectedClassName}").` ); + } else { + passes.push( `[${filename}] Class name "${declaredClass}" matches filename.` ); + } + + if ( ! declaredClass.endsWith( 'Test' ) ) { + errors.push( `[${filename}] Class name "${declaredClass}" must end with "Test".` ); + } + } + } + + // Summary Output + core.info( `\n--- Validated ${validatedCount} Test File(s) ---` ); + for ( const pass of passes ) { + core.info( `āœ“ ${pass}` ); + } + + if ( errors.length > 0 ) { + core.info( '\n--- File Validation Failures ---' ); + for ( const err of errors ) { + core.error( `āœ— ${err}` ); + } + core.setFailed( `Pull request #${pr.number} failed test file specification validation with ${errors.length} error(s).` ); + } else if ( validatedCount > 0 ) { + core.info( `\nāœ“ All ${validatedCount} test file(s) meet the path, namespace, and class name specifications!` ); + } else { + core.info( '\nNo PHPUnit test files under tests/phpunit/tests/ were modified in this PR.' ); + } From 509b00382d43e1bdadf300b70bb1677fd8413578 Mon Sep 17 00:00:00 2001 From: Paul Bearne Date: Wed, 9 Sep 2026 17:30:49 -0400 Subject: [PATCH 2/7] Refactor PHPUnit validation workflow: adjust triggers, permissions, and checkout configuration. --- .github/workflows/validate-phpunit-cleanup-pr.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/validate-phpunit-cleanup-pr.yml b/.github/workflows/validate-phpunit-cleanup-pr.yml index 6938b55de676b..c6707b439572f 100644 --- a/.github/workflows/validate-phpunit-cleanup-pr.yml +++ b/.github/workflows/validate-phpunit-cleanup-pr.yml @@ -1,28 +1,28 @@ name: Validate PHPUnit Test Cleanup Files on: - pull_request_target: - types: [ opened, synchronize, reopened ] pull_request: types: [ opened, synchronize, reopened ] workflow_dispatch: concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }} cancel-in-progress: true -permissions: - pull-requests: read - contents: read +permissions: {} jobs: validate-files: name: Validate PHPUnit Test Files runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: read steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: + persist-credentials: false ref: ${{ github.event.pull_request.head.sha || github.ref }} - name: Validate Test File Structure, Namespaces, and Class Names From 87f4d439f603e6d4e575c875249bba73d3ada29a Mon Sep 17 00:00:00 2001 From: Paul Bearne Date: Wed, 9 Sep 2026 17:38:46 -0400 Subject: [PATCH 3/7] Rename PHPUnit test class to follow naming conventions. --- .../{wpAbilitiesRegistry.php => wpAbilitiesRegistryTest.php} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/phpunit/tests/abilities-api/{wpAbilitiesRegistry.php => wpAbilitiesRegistryTest.php} (99%) diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistryTest.php similarity index 99% rename from tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php rename to tests/phpunit/tests/abilities-api/wpAbilitiesRegistryTest.php index 613fb7afb0474..fd4dcfc61be55 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistryTest.php @@ -7,7 +7,7 @@ * * @group abilities-api */ -class Tests_Abilities_API_WpAbilitiesRegistry extends WP_UnitTestCase { +class WpAbilitiesRegistryTest extends WP_UnitTestCase { public static $test_ability_name = 'test/add-numbers'; public static $test_ability_args = array(); From d51f135edeec122c36a09f1e76c2574b0f25e2bb Mon Sep 17 00:00:00 2001 From: Paul Bearne Date: Wed, 9 Sep 2026 17:45:53 -0400 Subject: [PATCH 4/7] Enhance PHPUnit validation workflow: enforce stricter naming conventions for test files and classes. --- .../workflows/validate-phpunit-cleanup-pr.yml | 32 ++++++++++++++++--- ...ryTest.php => WpAbilitiesRegistryTest.php} | 0 2 files changed, 27 insertions(+), 5 deletions(-) rename tests/phpunit/tests/abilities-api/{wpAbilitiesRegistryTest.php => WpAbilitiesRegistryTest.php} (100%) diff --git a/.github/workflows/validate-phpunit-cleanup-pr.yml b/.github/workflows/validate-phpunit-cleanup-pr.yml index c6707b439572f..7140bce85b95e 100644 --- a/.github/workflows/validate-phpunit-cleanup-pr.yml +++ b/.github/workflows/validate-phpunit-cleanup-pr.yml @@ -92,14 +92,28 @@ jobs: validatedCount++; core.info( `\nValidating test file: ${filename}` ); - // 1. Validate Filename suffix (*Test.php) + // 1. Validate Filename conventions (starts with capital letter, CamelCase/PascalCase, ends with Test.php) + const expectedClassName = baseName.replace( /\.php$/, '' ); + let filenameValid = true; + + if ( ! /^[A-Z]/.test( baseName ) ) { + errors.push( `[${filename}] Test filename "${baseName}" must start with a capital letter.` ); + filenameValid = false; + } + if ( ! baseName.endsWith( 'Test.php' ) ) { - errors.push( `[${filename}] Test filename must end with "Test.php" (Pattern: FunctionUnderTest[OptionalSubsetIndicator]Test.php).` ); - } else { - passes.push( `[${filename}] Filename "${baseName}" ends with "Test.php".` ); + errors.push( `[${filename}] Test filename "${baseName}" must end with "Test.php" (Pattern: FunctionUnderTest[OptionalSubsetIndicator]Test.php).` ); + filenameValid = false; } - const expectedClassName = baseName.replace( /\.php$/, '' ); + if ( ! /^[A-Z][a-zA-Z0-9_]*Test\.php$/.test( baseName ) ) { + errors.push( `[${filename}] Test filename "${baseName}" must follow CamelCase / PascalCase convention and end with "Test.php".` ); + filenameValid = false; + } + + if ( filenameValid ) { + passes.push( `[${filename}] Filename "${baseName}" starts with a capital letter, follows CamelCase convention, and ends with "Test.php".` ); + } // 2. Validate directory depth (must have a parent directory for Class_Under_Test or Functions_FileName) if ( pathParts.length < 1 ) { @@ -168,6 +182,14 @@ jobs: if ( ! declaredClass.endsWith( 'Test' ) ) { errors.push( `[${filename}] Class name "${declaredClass}" must end with "Test".` ); } + + if ( ! /^[A-Z]/.test( declaredClass ) ) { + errors.push( `[${filename}] Class name "${declaredClass}" must start with a capital letter.` ); + } + + if ( ! /^[A-Z][a-zA-Z0-9_]*Test$/.test( declaredClass ) ) { + errors.push( `[${filename}] Class name "${declaredClass}" must follow CamelCase / PascalCase convention and end with "Test".` ); + } } } diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistryTest.php b/tests/phpunit/tests/abilities-api/WpAbilitiesRegistryTest.php similarity index 100% rename from tests/phpunit/tests/abilities-api/wpAbilitiesRegistryTest.php rename to tests/phpunit/tests/abilities-api/WpAbilitiesRegistryTest.php From 0b511ed50627082b724c42527bb1dc57aa359589 Mon Sep 17 00:00:00 2001 From: Paul Bearne Date: Wed, 9 Sep 2026 17:56:45 -0400 Subject: [PATCH 5/7] Enhance PHPUnit validation workflow: enforce @covers annotation verification in test classes. --- .../workflows/validate-phpunit-cleanup-pr.yml | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate-phpunit-cleanup-pr.yml b/.github/workflows/validate-phpunit-cleanup-pr.yml index 7140bce85b95e..160ad153d96c6 100644 --- a/.github/workflows/validate-phpunit-cleanup-pr.yml +++ b/.github/workflows/validate-phpunit-cleanup-pr.yml @@ -25,7 +25,7 @@ jobs: persist-credentials: false ref: ${{ github.event.pull_request.head.sha || github.ref }} - - name: Validate Test File Structure, Namespaces, and Class Names + - name: Validate Test File Structure, Namespaces, Class Names, and @covers Annotations uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | @@ -191,6 +191,41 @@ jobs: errors.push( `[${filename}] Class name "${declaredClass}" must follow CamelCase / PascalCase convention and end with "Test".` ); } } + + // 5. Validate @covers docblock annotations (must be in opening class comment and nowhere else) + const classDocMatch = fileContent.match( /\/\*\*([^*]*(?:\*(?!\/)[^*]*)*)\*\/\s*(?:final\s+|abstract\s+)?class\s+([a-zA-Z0-9_]+)/ ); + if ( ! classDocMatch ) { + errors.push( `[${filename}] Missing opening class docblock comment preceding class declaration.` ); + } else { + const classDocContent = classDocMatch[1]; + const hasCoversInClassDoc = /@covers\b/.test( classDocContent ); + + if ( ! hasCoversInClassDoc ) { + errors.push( `[${filename}] Opening class docblock must contain a "@covers" annotation.` ); + } else { + passes.push( `[${filename}] Opening class docblock contains "@covers" annotation.` ); + } + + // Check for @covers annotations outside the opening class docblock + const docblockStart = classDocMatch.index; + const docblockEnd = docblockStart + classDocMatch[0].length; + const coversRegex = /@covers\b/g; + let m; + const outsideLines = []; + + while ( ( m = coversRegex.exec( fileContent ) ) !== null ) { + if ( m.index < docblockStart || m.index >= docblockEnd ) { + const lineNum = fileContent.substring( 0, m.index ).split( '\n' ).length; + outsideLines.push( lineNum ); + } + } + + if ( outsideLines.length > 0 ) { + errors.push( `[${filename}] Found ${outsideLines.length} "@covers" annotation(s) outside the opening class docblock (lines: ${outsideLines.join( ', ' )}). "@covers" annotations are only permitted in the opening class docblock.` ); + } else if ( hasCoversInClassDoc ) { + passes.push( `[${filename}] No "@covers" annotations found outside the opening class docblock.` ); + } + } } // Summary Output From 58f8f1ffae2cf7d8026f3148a78a890c973a345c Mon Sep 17 00:00:00 2001 From: Paul Bearne Date: Wed, 9 Sep 2026 18:04:48 -0400 Subject: [PATCH 6/7] Remove redundant @covers annotations in WP_Abilities_Registry PHPUnit tests. --- .../abilities-api/WpAbilitiesRegistryTest.php | 51 +------------------ 1 file changed, 1 insertion(+), 50 deletions(-) diff --git a/tests/phpunit/tests/abilities-api/WpAbilitiesRegistryTest.php b/tests/phpunit/tests/abilities-api/WpAbilitiesRegistryTest.php index fd4dcfc61be55..4b9f6272c2324 100644 --- a/tests/phpunit/tests/abilities-api/WpAbilitiesRegistryTest.php +++ b/tests/phpunit/tests/abilities-api/WpAbilitiesRegistryTest.php @@ -1,5 +1,5 @@ registry->register( self::$test_ability_name, self::$test_ability_args ); @@ -526,7 +489,6 @@ public function test_register_new_ability() { * * @ticket 64098 * - * @covers WP_Abilities_Registry::is_registered */ public function test_is_registered_for_unknown_ability() { $result = $this->registry->is_registered( 'test/unknown' ); @@ -538,8 +500,6 @@ public function test_is_registered_for_unknown_ability() { * * @ticket 64098 * - * @covers WP_Abilities_Registry::register - * @covers WP_Abilities_Registry::is_registered */ public function test_is_registered_for_known_ability() { $this->registry->register( 'test/one', self::$test_ability_args ); @@ -555,7 +515,6 @@ public function test_is_registered_for_known_ability() { * * @ticket 64098 * - * @covers WP_Abilities_Registry::get_registered * * @expectedIncorrectUsage WP_Abilities_Registry::get_registered */ @@ -569,8 +528,6 @@ public function test_get_registered_rejects_unknown_ability_name() { * * @ticket 64098 * - * @covers WP_Abilities_Registry::register - * @covers WP_Abilities_Registry::get_registered */ public function test_get_registered_for_known_ability() { $this->registry->register( 'test/one', self::$test_ability_args ); @@ -586,8 +543,6 @@ public function test_get_registered_for_known_ability() { * * @ticket 64098 * - * @covers WP_Abilities_Registry::unregister - * * @expectedIncorrectUsage WP_Abilities_Registry::unregister */ public function test_unregister_not_registered_ability() { @@ -600,8 +555,6 @@ public function test_unregister_not_registered_ability() { * * @ticket 64098 * - * @covers WP_Abilities_Registry::register - * @covers WP_Abilities_Registry::unregister */ public function test_unregister_for_known_ability() { $this->registry->register( 'test/one', self::$test_ability_args ); @@ -619,8 +572,6 @@ public function test_unregister_for_known_ability() { * * @ticket 64098 * - * @covers WP_Abilities_Registry::register - * @covers WP_Abilities_Registry::get_all_registered */ public function test_get_all_registered() { $ability_one_name = 'test/one'; From 9f4543a67a754bccbea7c797f90ca940c7a1aa1f Mon Sep 17 00:00:00 2001 From: Paul Bearne Date: Wed, 9 Sep 2026 18:11:03 -0400 Subject: [PATCH 7/7] Delete tests/phpunit/tests/abilities-api/WpAbilitiesRegistryTest.php remove test file --- .../abilities-api/WpAbilitiesRegistryTest.php | 714 ------------------ 1 file changed, 714 deletions(-) delete mode 100644 tests/phpunit/tests/abilities-api/WpAbilitiesRegistryTest.php diff --git a/tests/phpunit/tests/abilities-api/WpAbilitiesRegistryTest.php b/tests/phpunit/tests/abilities-api/WpAbilitiesRegistryTest.php deleted file mode 100644 index 4b9f6272c2324..0000000000000 --- a/tests/phpunit/tests/abilities-api/WpAbilitiesRegistryTest.php +++ /dev/null @@ -1,714 +0,0 @@ -registry = new WP_Abilities_Registry(); - - remove_all_filters( 'wp_register_ability_args' ); - - // Simulates the Abilities API init hook to allow test ability category registration. - global $wp_current_filter; - $wp_current_filter[] = 'wp_abilities_api_categories_init'; - wp_register_ability_category( - 'math', - array( - 'label' => 'Math', - 'description' => 'Mathematical operations and calculations.', - ) - ); - array_pop( $wp_current_filter ); - - self::$test_ability_args = array( - 'label' => 'Add numbers', - 'description' => 'Calculates the result of adding two numbers.', - 'category' => 'math', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'a' => array( - 'type' => 'number', - 'description' => 'First number.', - 'required' => true, - ), - 'b' => array( - 'type' => 'number', - 'description' => 'Second number.', - 'required' => true, - ), - ), - 'additionalProperties' => false, - ), - 'output_schema' => array( - 'type' => 'number', - 'description' => 'The result of adding the two numbers.', - 'required' => true, - ), - 'execute_callback' => static function ( array $input ): int { - return $input['a'] + $input['b']; - }, - 'permission_callback' => static function (): bool { - return true; - }, - 'meta' => array( - 'foo' => 'bar', - ), - ); - } - - /** - * Tear down each test method. - */ - public function tear_down(): void { - $this->registry = null; - - remove_all_filters( 'wp_register_ability_args' ); - - // Clean up registered test ability category. - wp_unregister_ability_category( 'math' ); - - parent::tear_down(); - } - - /** - * Should reject ability name without a namespace. - * - * @ticket 64098 - * - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_invalid_name_without_namespace() { - $result = $this->registry->register( 'without-namespace', self::$test_ability_args ); - $this->assertNull( $result ); - } - - /** - * Should reject ability name with invalid characters. - * - * @ticket 64098 - * - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_invalid_characters_in_name() { - $result = $this->registry->register( 'still/_doing_it_wrong', array() ); - $this->assertNull( $result ); - } - - /** - * Should reject ability name with uppercase characters. - * - * @ticket 64098 - * - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_invalid_uppercase_characters_in_name() { - $result = $this->registry->register( 'Test/AddNumbers', self::$test_ability_args ); - $this->assertNull( $result ); - } - - /** - * Should reject ability registration without a label. - * - * @ticket 64098 - * - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_invalid_missing_label() { - // Remove the label from the args. - unset( self::$test_ability_args['label'] ); - - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - $this->assertNull( $result ); - } - - /** - * Should reject ability registration with invalid label type. - * - * @ticket 64098 - * - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_invalid_label_type() { - self::$test_ability_args['label'] = false; - - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - $this->assertNull( $result ); - } - - /** - * Should reject ability registration without a description. - * - * @ticket 64098 - * - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_invalid_missing_description() { - // Remove the description from the args. - unset( self::$test_ability_args['description'] ); - - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - $this->assertNull( $result ); - } - - /** - * Should reject ability registration with invalid description type. - * - * @ticket 64098 - * - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_invalid_description_type() { - self::$test_ability_args['description'] = false; - - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - $this->assertNull( $result ); - } - - /** - * Tests registering an ability with non-existent category. - * - * @ticket 64098 - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_ability_nonexistent_category(): void { - $args = array_merge( - self::$test_ability_args, - array( 'category' => 'nonexistent' ) - ); - - $result = $this->registry->register( self::$test_ability_name, $args ); - - $this->assertNull( $result, 'Should return null when category does not exist.' ); - } - - /** - * Tests that an invalid category type is rejected before the category lookup. - * - * @ticket 65569 - * - * @dataProvider data_invalid_category_types - * - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - * - * @param mixed $category Invalid category value. - */ - public function test_register_ability_rejects_invalid_category_type( $category ): void { - $args = self::$test_ability_args; - $args['category'] = $category; - - $result = $this->registry->register( self::$test_ability_name, $args ); - - $this->assertNull( $result ); - $this->assertStringContainsString( - 'Ability category must be a string.', - $this->caught_doing_it_wrong['WP_Abilities_Registry::register'] - ); - } - - /** - * Data provider for invalid category types. - * - * @return array> Test cases. - */ - public static function data_invalid_category_types(): array { - return array( - 'null' => array( null ), - 'boolean' => array( false ), - 'integer' => array( 1 ), - 'array' => array( array() ), - ); - } - - /** - * Tests that an empty category is rejected rather than replaced by the default. - * - * @ticket 65569 - * - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_ability_rejects_empty_category(): void { - $args = self::$test_ability_args; - $args['category'] = ''; - - $result = $this->registry->register( self::$test_ability_name, $args ); - - $this->assertNull( $result ); - $this->assertStringContainsString( - 'Ability category "" is not registered.', - $this->caught_doing_it_wrong['WP_Abilities_Registry::register'] - ); - } - - /** - * Should reject ability registration without an execute callback. - * - * @ticket 64098 - * - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_invalid_missing_execute_callback() { - // Remove the execute_callback from the args. - unset( self::$test_ability_args['execute_callback'] ); - - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - $this->assertNull( $result ); - } - - /** - * Should reject ability registration if the execute callback is not a callable. - * - * @ticket 64098 - * - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_incorrect_execute_callback_type() { - self::$test_ability_args['execute_callback'] = 'not-a-callback'; - - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - $this->assertNull( $result ); - } - - /** - * Should allow ability registration with custom ability_class that overrides do_execute. - * - * @ticket 64407 - * - */ - public function test_register_with_custom_ability_class_without_execute_callback() { - // Remove execute_callback and permission_callback since the custom class provides its own implementation. - unset( self::$test_ability_args['execute_callback'] ); - unset( self::$test_ability_args['permission_callback'] ); - - self::$test_ability_args['ability_class'] = 'Tests_Custom_Ability_Class'; - - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - - $this->assertInstanceOf( WP_Ability::class, $result, 'Should return a WP_Ability instance.' ); - $this->assertInstanceOf( Tests_Custom_Ability_Class::class, $result, 'Should return an instance of the custom class.' ); - - // Verify the custom execute method works. - $execute_result = $result->execute( - array( - 'a' => 5, - 'b' => 3, - ) - ); - $this->assertSame( 15, $execute_result, 'Custom do_execute should multiply instead of add.' ); - } - - /** - * Should reject ability registration without an execute callback. - * - * @ticket 64098 - * - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_invalid_missing_permission_callback() { - // Remove the permission_callback from the args. - unset( self::$test_ability_args['permission_callback'] ); - - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - $this->assertNull( $result ); - } - - /** - * Should reject ability registration if the permission callback is not a callable. - * - * @ticket 64098 - * - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_incorrect_permission_callback_type() { - self::$test_ability_args['permission_callback'] = 'not-a-callback'; - - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - $this->assertNull( $result ); - } - - /** - * Should reject ability registration if the input schema is not an array. - * - * @ticket 64098 - * - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_incorrect_input_schema_type() { - self::$test_ability_args['input_schema'] = 'not-an-array'; - - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - $this->assertNull( $result ); - } - - /** - * Should reject ability registration if the output schema is not an array. - * - * @ticket 64098 - * - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_incorrect_output_schema_type() { - self::$test_ability_args['output_schema'] = 'not-an-array'; - - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - $this->assertNull( $result ); - } - - /** - * Should reject ability registration with invalid `annotations` type. - * - * @ticket 64098 - * - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_invalid_annotations_type() { - self::$test_ability_args['meta']['annotations'] = false; - - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - $this->assertNull( $result ); - } - - /** - * Should reject ability registration with invalid meta type. - * - * @ticket 64098 - * - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_invalid_meta_type() { - self::$test_ability_args['meta'] = false; - - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - $this->assertNull( $result ); - } - - /** - * Should reject ability registration with invalid show in REST type. - * - * @ticket 64098 - * - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_invalid_show_in_rest_type() { - self::$test_ability_args['meta']['show_in_rest'] = 5; - - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - $this->assertNull( $result ); - } - - /** - * Should reject ability registration with invalid public type. - * - * @ticket 65568 - * - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_invalid_public_type() { - self::$test_ability_args['meta']['public'] = 5; - - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - $this->assertNull( $result ); - } - - /** - * Should reject registration for already registered ability. - * - * @ticket 64098 - * - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_incorrect_already_registered_ability() { - $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - - $this->assertNull( $result ); - } - - /** - * Should successfully register a new ability. - * - * @ticket 64098 - * - */ - public function test_register_new_ability() { - $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - - $this->assertEquals( - new WP_Ability( self::$test_ability_name, self::$test_ability_args ), - $result - ); - } - - /** - * Should return false for ability that's not registered. - * - * @ticket 64098 - * - */ - public function test_is_registered_for_unknown_ability() { - $result = $this->registry->is_registered( 'test/unknown' ); - $this->assertFalse( $result ); - } - - /** - * Should return true if ability is registered. - * - * @ticket 64098 - * - */ - public function test_is_registered_for_known_ability() { - $this->registry->register( 'test/one', self::$test_ability_args ); - $this->registry->register( 'test/two', self::$test_ability_args ); - $this->registry->register( 'test/three', self::$test_ability_args ); - - $result = $this->registry->is_registered( 'test/one' ); - $this->assertTrue( $result ); - } - - /** - * Should not find ability that's not registered. - * - * @ticket 64098 - * - * - * @expectedIncorrectUsage WP_Abilities_Registry::get_registered - */ - public function test_get_registered_rejects_unknown_ability_name() { - $ability = $this->registry->get_registered( 'test/unknown' ); - $this->assertNull( $ability ); - } - - /** - * Should find registered ability by name. - * - * @ticket 64098 - * - */ - public function test_get_registered_for_known_ability() { - $this->registry->register( 'test/one', self::$test_ability_args ); - $this->registry->register( 'test/two', self::$test_ability_args ); - $this->registry->register( 'test/three', self::$test_ability_args ); - - $result = $this->registry->get_registered( 'test/two' ); - $this->assertSame( 'test/two', $result->get_name() ); - } - - /** - * Unregistering should fail if an ability is not registered. - * - * @ticket 64098 - * - * @expectedIncorrectUsage WP_Abilities_Registry::unregister - */ - public function test_unregister_not_registered_ability() { - $result = $this->registry->unregister( 'test/unregistered' ); - $this->assertNull( $result ); - } - - /** - * Should unregister ability by name. - * - * @ticket 64098 - * - */ - public function test_unregister_for_known_ability() { - $this->registry->register( 'test/one', self::$test_ability_args ); - $this->registry->register( 'test/two', self::$test_ability_args ); - $this->registry->register( 'test/three', self::$test_ability_args ); - - $result = $this->registry->unregister( 'test/three' ); - $this->assertSame( 'test/three', $result->get_name() ); - - $this->assertFalse( $this->registry->is_registered( 'test/three' ) ); - } - - /** - * Should retrieve all registered abilities. - * - * @ticket 64098 - * - */ - public function test_get_all_registered() { - $ability_one_name = 'test/one'; - $this->registry->register( $ability_one_name, self::$test_ability_args ); - - $ability_two_name = 'test/two'; - $this->registry->register( $ability_two_name, self::$test_ability_args ); - - $ability_three_name = 'test/three'; - $this->registry->register( $ability_three_name, self::$test_ability_args ); - - $result = $this->registry->get_all_registered(); - $this->assertCount( 3, $result ); - $this->assertSame( $ability_one_name, $result[ $ability_one_name ]->get_name() ); - $this->assertSame( $ability_two_name, $result[ $ability_two_name ]->get_name() ); - $this->assertSame( $ability_three_name, $result[ $ability_three_name ]->get_name() ); - } - - /** - * Test register_ability_args filter modifies the args before ability instantiation. - * - * @ticket 64098 - */ - public function test_register_ability_args_filter_modifies_args() { - $was_filter_callback_fired = false; - - // Define the filter. - add_filter( - 'wp_register_ability_args', - static function ( $args ) use ( &$was_filter_callback_fired ) { - $args['label'] = 'Modified label'; - $original_execute_callback = $args['execute_callback']; - $args['execute_callback'] = static function ( array $input ) use ( &$was_filter_callback_fired, $original_execute_callback ) { - $was_filter_callback_fired = true; - return $original_execute_callback( $input ); - }; - - return $args; - }, - 10 - ); - - // Register the ability. - $ability = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - - // Check the label was modified by the filter. - $this->assertSame( 'Modified label', $ability->get_label() ); - - // Call the execute callback. - $result = $ability->execute( - array( - 'a' => 1, - 'b' => 2, - ) - ); - - $this->assertTrue( $was_filter_callback_fired, 'The execute callback defined in the filter was not fired.' ); - $this->assertSame( 3, $result, 'The original execute callback did not return the expected result.' ); - } - - /** - * Test register_ability_args filter can block ability registration by returning invalid args. - * - * @ticket 64098 - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_ability_args_filter_blocks_registration() { - // Define the filter. - add_filter( - 'wp_register_ability_args', - static function ( $args ) { - // Remove the label to make the args invalid. - unset( $args['label'] ); - return $args; - }, - 10 - ); - - // Register the ability. - $ability = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - - // Check the ability was not registered. - $this->assertNull( $ability, 'The ability was registered even though the args were made invalid by the filter.' ); - } - - /** - * Test register_ability_args filter can block an invalid ability class from being used. - * - * @ticket 64098 - * - * @expectedIncorrectUsage WP_Abilities_Registry::register - */ - public function test_register_ability_args_filter_blocks_invalid_ability_class() { - // Define the filter. - add_filter( - 'wp_register_ability_args', - static function ( $args ) { - // Set an invalid ability class. - $args['ability_class'] = 'NonExistentClass'; - return $args; - }, - 10 - ); - // Register the ability. - $ability = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - - // Check the ability was not registered. - $this->assertNull( $ability, 'The ability was registered even though the ability class was made invalid by the filter.' ); - } - - /** - * Tests register_ability_args filter is only applied to the specific ability being registered. - * - * @ticket 64098 - */ - public function test_register_ability_args_filter_only_applies_to_specific_ability() { - add_filter( - 'wp_register_ability_args', - static function ( $args, $name ) { - if ( self::$test_ability_name !== $name ) { - // Do not modify args for other abilities. - return $args; - } - - $args['label'] = 'Modified label for specific ability'; - return $args; - }, - 10, - 2 - ); - - // Register the first ability, which the filter should modify. - $filtered_ability = $this->registry->register( self::$test_ability_name, self::$test_ability_args ); - $this->assertSame( 'Modified label for specific ability', $filtered_ability->get_label() ); - - $unfiltered_ability = $this->registry->register( 'test/another-ability', self::$test_ability_args ); - $this->assertNotSame( $filtered_ability->get_label(), $unfiltered_ability->get_label(), 'The filter incorrectly modified the args for an ability it should not have.' ); - } -}