From e565502400cf685a4e0ec5caaf75c838ec2727aa Mon Sep 17 00:00:00 2001 From: bfintal Date: Fri, 14 Aug 2026 17:14:15 +0800 Subject: [PATCH] test: add PHPUnit coverage for PHP runtime seams --- .github/workflows/php-unit.yml | 43 ++++++ .gitignore | 1 + AGENTS.md | 1 + CONTRIBUTING.MD | 21 +++ composer.json | 10 +- phpcs.xml.dist | 1 + phpunit.xml.dist | 15 ++ plugin.php | 5 + tests/phpunit/BlockDefaultsSanitizeTest.php | 70 +++++++++ tests/phpunit/CssOptimizeTest.php | 55 +++++++ tests/phpunit/DeactivationCleanupTest.php | 35 +++++ tests/phpunit/DesignLibraryValidateTest.php | 41 +++++ tests/phpunit/KsesTest.php | 45 ++++++ tests/phpunit/PostsExcerptTest.php | 52 +++++++ tests/phpunit/PostsQueryTest.php | 70 +++++++++ tests/phpunit/PostsRenderTest.php | 83 ++++++++++ tests/phpunit/RestPermissionsTest.php | 88 +++++++++++ tests/phpunit/SvgSanitizeTest.php | 38 +++++ tests/phpunit/TestCase.php | 159 ++++++++++++++++++++ tests/phpunit/UniqueIdTest.php | 70 +++++++++ tests/phpunit/bootstrap.php | 113 ++++++++++++++ tests/phpunit/run-premium.php | 23 +++ 22 files changed, 1037 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/php-unit.yml create mode 100644 phpunit.xml.dist create mode 100644 tests/phpunit/BlockDefaultsSanitizeTest.php create mode 100644 tests/phpunit/CssOptimizeTest.php create mode 100644 tests/phpunit/DeactivationCleanupTest.php create mode 100644 tests/phpunit/DesignLibraryValidateTest.php create mode 100644 tests/phpunit/KsesTest.php create mode 100644 tests/phpunit/PostsExcerptTest.php create mode 100644 tests/phpunit/PostsQueryTest.php create mode 100644 tests/phpunit/PostsRenderTest.php create mode 100644 tests/phpunit/RestPermissionsTest.php create mode 100644 tests/phpunit/SvgSanitizeTest.php create mode 100644 tests/phpunit/TestCase.php create mode 100644 tests/phpunit/UniqueIdTest.php create mode 100644 tests/phpunit/bootstrap.php create mode 100644 tests/phpunit/run-premium.php diff --git a/.github/workflows/php-unit.yml b/.github/workflows/php-unit.yml new file mode 100644 index 0000000000..c7e369fce6 --- /dev/null +++ b/.github/workflows/php-unit.yml @@ -0,0 +1,43 @@ +name: PHP Unit + +on: + push: + branches: [ master, develop ] + paths: + - '**.php' + - 'composer.json' + - 'phpunit.xml.dist' + - 'tests/phpunit/**' + - '.github/workflows/php-unit.yml' + pull_request: + branches: [ master, develop ] + paths: + - '**.php' + - 'composer.json' + - 'phpunit.xml.dist' + - 'tests/phpunit/**' + - '.github/workflows/php-unit.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + phpunit: + name: PHPUnit PHP ${{ matrix.php_version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php_version: [ '7.4', '8.5' ] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php_version }} + coverage: none + tools: composer:v2 + - name: Install Composer dependencies + run: composer update --no-interaction --prefer-dist --no-progress + - name: Run PHPUnit + run: composer test diff --git a/.gitignore b/.gitignore index bded210c4b..84f5bb5315 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ test-results/**/trace.zip composer.phar composer.lock +.phpunit.result.cache /vendor /wordpress /test-results/ diff --git a/AGENTS.md b/AGENTS.md index 41a47d6487..78f7ef7a8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,7 @@ Do not apply GitHub changes (close, comment, relabel) during issue triage withou - Unit / Jest: `npm run test:unit` (wp-scripts / `@wordpress/jest-preset-default`). Watch: `npm run test:unit:watch`. Premium: `cd pro__premium_only && npm run test:unit` when `pro__premium_only/` is present. +- PHP unit: `composer test` (PHPUnit 9 + Brain Monkey, no Docker / wp-env). Premium: `composer test:php:premium` when `pro__premium_only/` is present. - PHP compatibility: `composer phpcompat` (PHPCompatibilityWP; floor from `readme.txt` `Requires PHP`). Premium PHP: `composer phpcompat:premium` when `pro__premium_only/` is present. - Playwright e2e (WordPress Playground, no Docker): see [`e2e/readme.md`](./e2e/readme.md) diff --git a/CONTRIBUTING.MD b/CONTRIBUTING.MD index 581eab0db1..a6822d5629 100644 --- a/CONTRIBUTING.MD +++ b/CONTRIBUTING.MD @@ -198,6 +198,27 @@ This is to prevent errors when upgrading Stackable. 📝 Writing Tests ============= +### PHP unit tests + +PHP runtime tests live in `tests/phpunit/` (PHPUnit 9 + Brain Monkey). They do +not boot WordPress, MySQL, or Freemius. Each test `require_once`s the PHP file +under test after stubbing WordPress helpers. + +```bash +composer test +``` + +Premium PHP tests live in `pro__premium_only/tests/phpunit/` and reuse the free +Composer `vendor/` tree: + +```bash +composer test:php:premium +``` + +Keep PHPUnit files under `tests/` so they are not packaged in the plugin zip. + +CI runs PHP 7.4 and 8.5 (same corners as Playwright). + ### Block Testing Block testing is done in 5 steps, and covers the entirety of a block's codebase. diff --git a/composer.json b/composer.json index 80621442d5..07631e2b90 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,10 @@ "require-dev": { "wp-cli/i18n-command": "^2.2", "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "phpcompatibility/phpcompatibility-wp": "^2.1" + "phpcompatibility/phpcompatibility-wp": "^2.1", + "phpunit/phpunit": "^9.6", + "yoast/phpunit-polyfills": "^2.0", + "brain/monkey": "^2.6" }, "config": { "allow-plugins": { @@ -15,6 +18,9 @@ }, "scripts": { "phpcompat": "phpcs", - "phpcompat:premium": "phpcs --standard=pro__premium_only/phpcs.xml.dist pro__premium_only" + "phpcompat:premium": "phpcs --standard=pro__premium_only/phpcs.xml.dist pro__premium_only", + "test": "@test:php", + "test:php": "phpunit", + "test:php:premium": "php tests/phpunit/run-premium.php" } } diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 648c67712b..6b4d327447 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -10,6 +10,7 @@ */dist/* */build/* */pro__premium_only/* + */tests/* diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000000..bcf5dcc7bd --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,15 @@ + + + + + tests/phpunit + + + diff --git a/plugin.php b/plugin.php index 9b94e34807..da04814501 100644 --- a/plugin.php +++ b/plugin.php @@ -251,6 +251,11 @@ function is_frontend() { } } +// PHPUnit loads this file only for deactivation cleanup + is_frontend(). +if ( defined( 'STACKABLE_PHPUNIT' ) && STACKABLE_PHPUNIT ) { + return; +} + /** * Freemius. * This needs to be first. diff --git a/tests/phpunit/BlockDefaultsSanitizeTest.php b/tests/phpunit/BlockDefaultsSanitizeTest.php new file mode 100644 index 0000000000..b12800915a --- /dev/null +++ b/tests/phpunit/BlockDefaultsSanitizeTest.php @@ -0,0 +1,70 @@ +returnArg( 1 ); + Functions\when( 'parse_blocks' )->justReturn( array() ); + Functions\when( 'serialize_blocks' )->justReturn( '' ); + $this->require_plugin_file( 'src/deprecated/block-defaults/custom-block-styles.php' ); + } + + private function styles() { + return new Stackable_Custom_Block_Styles(); + } + + public function test_sanitize_block_name_rejects_core_blocks() { + $styles = $this->styles(); + $this->assertSame( '', $styles->sanitize_block_name( 'core/paragraph' ) ); + $this->assertSame( 'stackable/heading', $styles->sanitize_block_name( 'stackable/heading' ) ); + } + + public function test_sanitize_style_slug_uses_sanitize_title() { + $this->assertSame( 'my-style', $this->styles()->sanitize_style_slug( 'My Style' ) ); + } + + public function test_sanitize_array_setting_rejects_non_array() { + $this->assertSame( array(), $this->styles()->sanitize_array_setting( 'nope' ) ); + } + + public function test_sanitize_stored_block_styles_drops_invalid_blocks() { + $styles = $this->styles(); + $stored = array( + array( + 'block' => 'core/paragraph', + 'styles' => array( + array( + 'slug' => 'plain', + 'name' => 'Plain', + 'data' => '{"attributes":{"text":""},"innerBlocks":[]}', + 'save' => '', + ), + ), + ), + array( + 'block' => 'stackable/heading', + 'styles' => array( + array( + 'slug' => 'hero', + 'name' => 'Hero', + 'data' => '{"attributes":{"text":"Hello "},"innerBlocks":[]}', + 'save' => '', + ), + ), + ), + ); + $out = $styles->sanitize_stored_block_styles( $stored ); + $this->assertCount( 1, $out ); + $this->assertSame( 'stackable/heading', $out[0]->block ); + $this->assertSame( 'hero', $out[0]->styles[0]->slug ); + $this->assertStringNotContainsString( '', + ), + $this->attributes(), + '

!#title!#

' + ); + $this->assertStringNotContainsString( ''; + $html = generate_render_item_from_stackable_posts_block( + array( + 'ID' => 1, + 'post_title' => 'A', + ), + $attrs, + '!#readmoreText!#' + ); + $this->assertStringContainsString( 'more', $html ); + $this->assertStringNotContainsString( ''; + $out = $this->sanitizer()->sanitize_svg_setting( $input ); + $this->assertStringNotContainsString( '#is', '', (string) $text ); + } + + public function stub_sanitize_text_field( $value ) { + if ( ! is_string( $value ) ) { + return ''; + } + return trim( strip_tags( $value ) ); + } + + public function stub_sanitize_title( $value ) { + $value = strtolower( (string) $value ); + $value = preg_replace( '/[^a-z0-9]+/', '-', $value ); + return trim( $value, '-' ); + } + + /** + * WordPress-like trim: strip tags, then split on whitespace. + * + * @param string $text + * @param int $num_words + * @param string|null $more + * @return string + */ + public function stub_wp_trim_words( $text, $num_words = 55, $more = null ) { + if ( null === $more ) { + $more = '…'; + } + $text = preg_replace( '/<[^>]*>/', '', (string) $text ); + $words = preg_split( '/[\r\n\t ]+/', trim( $text ), $num_words + 1 ); + if ( false === $words ) { + return ''; + } + if ( count( $words ) > $num_words ) { + array_pop( $words ); + return implode( ' ', $words ) . $more; + } + return implode( ' ', $words ); + } +} diff --git a/tests/phpunit/UniqueIdTest.php b/tests/phpunit/UniqueIdTest.php new file mode 100644 index 0000000000..906648afa4 --- /dev/null +++ b/tests/phpunit/UniqueIdTest.php @@ -0,0 +1,70 @@ +require_plugin_file( 'src/unique-id.php' ); + $GLOBALS['stackable_unique_ids'] = array(); + } + + public function test_non_stackable_block_is_unchanged() { + $html = '

Hello

'; + $out = stackable_prevent_duplicate_unique_ids( $html, array( + 'blockName' => 'core/paragraph', + 'attrs' => array( 'uniqueId' => 'abc1234' ), + ) ); + $this->assertSame( $html, $out ); + } + + public function test_missing_attrs_is_unchanged() { + $html = '
'; + $out = stackable_prevent_duplicate_unique_ids( $html, array( + 'blockName' => 'stackable/heading', + ) ); + $this->assertSame( $html, $out ); + } + + public function test_empty_unique_id_is_unchanged() { + $html = '
'; + $out = stackable_prevent_duplicate_unique_ids( $html, array( + 'blockName' => 'stackable/heading', + 'attrs' => array( 'uniqueId' => '' ), + ) ); + $this->assertSame( $html, $out ); + } + + public function test_first_unique_id_is_kept() { + $html = '

Title

'; + $block = array( + 'blockName' => 'stackable/heading', + 'attrs' => array( 'uniqueId' => 'abc1234' ), + ); + $out = stackable_prevent_duplicate_unique_ids( $html, $block ); + $this->assertSame( $html, $out ); + } + + public function test_duplicate_unique_id_is_rewritten() { + $html = '

Title

'; + $block = array( + 'blockName' => 'stackable/heading', + 'attrs' => array( 'uniqueId' => 'abc1234' ), + ); + + stackable_prevent_duplicate_unique_ids( $html, $block ); + $second = stackable_prevent_duplicate_unique_ids( $html, $block ); + + $this->assertStringNotContainsString( 'abc1234', $second ); + $this->assertMatchesRegularExpression( '/stk-[0-9a-z]{7}/', $second ); + $this->assertMatchesRegularExpression( '/data-block-id="[0-9a-z]{7}"/', $second ); + + $third = stackable_prevent_duplicate_unique_ids( $html, $block ); + $this->assertStringNotContainsString( 'abc1234', $third ); + $this->assertNotSame( $second, $third ); + } +} diff --git a/tests/phpunit/bootstrap.php b/tests/phpunit/bootstrap.php new file mode 100644 index 0000000000..a635bad69b --- /dev/null +++ b/tests/phpunit/bootstrap.php @@ -0,0 +1,113 @@ +code = $code; + $this->message = $message; + if ( $code ) { + $this->errors[ $code ][] = $message; + } + if ( $data ) { + $this->error_data[ $code ] = $data; + } + } + + public function get_error_code() { + return $this->code; + } + + public function get_error_message() { + return $this->message; + } + } +} + +if ( ! function_exists( 'is_wp_error' ) ) { + function is_wp_error( $thing ) { + return $thing instanceof WP_Error; + } +} + +if ( ! function_exists( 'is_frontend' ) ) { + function is_frontend() { + return false; + } +} + +if ( ! function_exists( '__return_false' ) ) { + function __return_false() { + return false; + } +} + +if ( ! function_exists( '__return_true' ) ) { + function __return_true() { + return true; + } +} + +if ( ! function_exists( 'register_rest_route' ) ) { + function register_rest_route( $namespace, $route, $args = array() ) { + $args['_route'] = $route; + $args['_namespace'] = $namespace; + $GLOBALS['stackable_phpunit_rest_routes'][] = $args; + return true; + } +} + +require_once __DIR__ . '/TestCase.php'; diff --git a/tests/phpunit/run-premium.php b/tests/phpunit/run-premium.php new file mode 100644 index 0000000000..7e9368dbba --- /dev/null +++ b/tests/phpunit/run-premium.php @@ -0,0 +1,23 @@ +