diff --git a/.github/workflows/php-unit.yml b/.github/workflows/php-unit.yml
new file mode 100644
index 000000000..c7e369fce
--- /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 bded210c4..84f5bb531 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 41a47d648..78f7ef7a8 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 581eab0db..a6822d562 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 80621442d..07631e2b9 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 648c67712..6b4d32744 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 000000000..bcf5dcc7b
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,15 @@
+
+
+
+
+ tests/phpunit
+
+
+
diff --git a/plugin.php b/plugin.php
index 9b94e3480..da0481450 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 000000000..b12800915
--- /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 000000000..906648afa
--- /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 000000000..a635bad69
--- /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 000000000..7e9368dbb
--- /dev/null
+++ b/tests/phpunit/run-premium.php
@@ -0,0 +1,23 @@
+