diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..f3cf37c --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,83 @@ +workflows: + version: 2 + main: + jobs: + - php80-build + - php81-build + - php82-build + - php83-build + - php84-build + - php85-build + +version: 2 + +job-references: + mysql_image: &mysql_image + cimg/mysql:8.0 + + setup_environment: &setup_environment + name: "Setup Environment Variables" + command: | + echo "export PATH=$HOME/.composer/vendor/bin:$PATH" >> $BASH_ENV + source /home/circleci/.bashrc + + install_dependencies: &install_dependencies + name: "Install Dependencies" + command: | + sudo apt-get update && sudo apt-get install mysql-client subversion + + php_job: &php_job + environment: + - WP_TESTS_DIR: "/tmp/wordpress-tests-lib" + - WP_CORE_DIR: "/tmp/wordpress/" + steps: + - checkout + - run: php --version + - run: composer --version + - run: *setup_environment + - run: *install_dependencies + - run: + name: "Run Tests" + command: | + rm -rf $WP_TESTS_DIR $WP_CORE_DIR + bash bin/install-wp-tests.sh wordpress_test root '' 127.0.0.1 latest + make test-unit + WP_MULTISITE=1 make test-unit + make test-style + +jobs: + php80-build: + <<: *php_job + docker: + - image: cimg/php:8.0 + - image: *mysql_image + + php81-build: + <<: *php_job + docker: + - image: cimg/php:8.1 + - image: *mysql_image + + php82-build: + <<: *php_job + docker: + - image: cimg/php:8.2 + - image: *mysql_image + + php83-build: + <<: *php_job + docker: + - image: cimg/php:8.3 + - image: *mysql_image + + php84-build: + <<: *php_job + docker: + - image: cimg/php:8.4 + - image: *mysql_image + + php85-build: + <<: *php_job + docker: + - image: cimg/php:8.5 + - image: *mysql_image diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..37ee520 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +* @deliciousbrains/deli-eng + +#jira:DELI is where issues related to this repository should be ticketed] \ No newline at end of file diff --git a/.gitignore b/.gitignore index 57872d0..23505a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /vendor/ +/.idea +*.cache diff --git a/.phpcs.xml b/.phpcs.xml new file mode 100644 index 0000000..56d4730 --- /dev/null +++ b/.phpcs.xml @@ -0,0 +1,69 @@ + + + Generally-applicable sniffs for WordPress plugins. + + + . + /vendor/ + /node_modules/ + /tests/* + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..22fa3f5 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +.PHONY: test +test: test-unit test-style + +.PHONY: test-unit +test-unit: vendor + vendor/bin/phpunit + +.PHONY: test-style +test-style: vendor + vendor/bin/phpcs + +vendor: composer.json + composer install --ignore-platform-reqs + +.PHONY: update-deps +update-deps: + composer update --with-all-dependencies + +.PHONY: clean +clean: + rm -f tests/.phpunit.result.cache .phpunit.result.cache + +.PHONY: clean-all +clean-all: clean + rm -rf vendor + rm -f composer.lock coverage.clover diff --git a/README.md b/README.md index 04d74cc..a6e534b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,19 @@ WP Background Processing can be used to fire off non-blocking asynchronous reque Inspired by [TechCrunch WP Asynchronous Tasks](https://github.com/techcrunch/wp-async-task). -__Requires PHP 5.2+__ +__Requires PHP 5.6+__ + +## Install + +The recommended way to install this library in your project is by loading it through Composer: + +```shell +composer require deliciousbrains/wp-background-processing +``` + +It is highly recommended to prefix wrap the library class files using [the Mozart package](https://packagist.org/packages/coenjacobs/mozart), to prevent collisions with other projects using this same library. + +## Usage ### Async Request @@ -15,88 +27,110 @@ Extend the `WP_Async_Request` class: ```php class WP_Example_Request extends WP_Async_Request { + /** + * @var string + */ + protected $prefix = 'my_plugin'; + /** * @var string */ protected $action = 'example_request'; /** - * Handle + * Handle a dispatched request. * * Override this method to perform any actions required * during the async request. */ protected function handle() { - // Actions to perform + // Actions to perform. } } ``` -##### `protected $action` +#### `protected $prefix` + +Should be set to a unique prefix associated with your plugin, theme, or site's custom function prefix. + +#### `protected $action` Should be set to a unique name. -##### `protected function handle()` +#### `protected function handle()` Should contain any logic to perform during the non-blocking request. The data passed to the request will be accessible via `$_POST`. -##### Dispatching Requests +#### Dispatching Requests Instantiate your request: -`$this->example_request = new WP_Example_Request();` +```php +$this->example_request = new WP_Example_Request(); +``` Add data to the request if required: -`$this->example_request->data( array( 'value1' => $value1, 'value2' => $value2 ) );` +```php +$this->example_request->data( array( 'value1' => $value1, 'value2' => $value2 ) ); +``` Fire off the request: -`$this->example_request->dispatch();` +```php +$this->example_request->dispatch(); +``` Chaining is also supported: -`$this->example_request->data( array( 'data' => $data ) )->dispatch();` +```php +$this->example_request->data( array( 'data' => $data ) )->dispatch(); +``` ### Background Process -Background processes work in a similar fashion to async requests but they allow you to queue tasks. Items pushed onto the queue will be processed in the background once the queue has been dispatched. Queues will also scale based on available server resources, so higher end servers will process more items per batch. Once a batch has completed the next batch will start instantly. +Background processes work in a similar fashion to async requests, but they allow you to queue tasks. Items pushed onto the queue will be processed in the background once the queue has been saved and dispatched. Queues will also scale based on available server resources, so higher end servers will process more items per batch. Once a batch has completed, the next batch will start instantly. Health checks run by default every 5 minutes to ensure the queue is running when queued items exist. If the queue has failed it will be restarted. -Queues work on a first in first out basis, which allows additional items to be pushed to the queue even if it’s already processing. +Queues work on a first in first out basis, which allows additional items to be pushed to the queue even if it’s already processing. Saving a new batch of queued items and dispatching while another background processing instance is already running will result in the dispatch shortcutting out and the existing instance eventually picking up the new items and processing them when it is their turn. Extend the `WP_Background_Process` class: ```php class WP_Example_Process extends WP_Background_Process { + /** + * @var string + */ + protected $prefix = 'my_plugin'; + /** * @var string */ protected $action = 'example_process'; /** - * Task + * Perform task with queued item. * * Override this method to perform any actions required on each * queue item. Return the modified item for further processing * in the next pass through. Or, return false to remove the * item from the queue. * - * @param mixed $item Queue item to iterate over + * @param mixed $item Queue item to iterate over. * * @return mixed */ protected function task( $item ) { - // Actions to perform + // Actions to perform. return false; } /** - * Complete + * Complete processing. * * Override if applicable, but ensure that the below actions are * performed, or, call parent::complete(). @@ -110,23 +144,31 @@ class WP_Example_Process extends WP_Background_Process { } ``` -##### `protected $action` +#### `protected $prefix` + +Should be set to a unique prefix associated with your plugin, theme, or site's custom function prefix. + +#### `protected $action` Should be set to a unique name. -##### `protected function task( $item )` +#### `protected function task( $item )` Should contain any logic to perform on the queued item. Return `false` to remove the item from the queue or return `$item` to push it back onto the queue for further processing. If the item has been modified and is pushed back onto the queue the current state will be saved before the batch is exited. -##### `protected function complete()` +#### `protected function complete()` Optionally contain any logic to perform once the queue has completed. -##### Dispatching Processes +#### Dispatching Processes Instantiate your process: -`$this->example_process = new WP_Example_Process();` +```php +$this->example_process = new WP_Example_Process(); +``` + +**Note:** You must instantiate your process unconditionally. All requests should do this, even if nothing is pushed to the queue. Push items to the queue: @@ -136,13 +178,171 @@ foreach ( $items as $item ) { } ``` +An item can be any valid PHP value, string, integer, array or object. If needed, the $item is serialized when written to the database. + Save and dispatch the queue: -`$this->example_process->save()->dispatch();` +```php +$this->example_process->save()->dispatch(); +``` + +#### Handling serialized objects in queue items + +Queue items that contain non-scalar values are serialized when stored in the database. To avoid potential security issues during unserialize, this library provides the option to set the `allowed_classes` option when calling `unserialize()` which limits which classes can be instantiated. It's kept internally as the protected `$allowed_batch_data_classes` property. + +To maintain backward compatibility the default value is `true`, meaning that any serialized object will be instantiated. Please note that this default behavior may change in a future major release. + +We encourage all users of this library to take advantage of setting a strict value for `$allowed_batch_data_classes`. If possible, set the value to `false` to disallow any objects from being instantiated, or a very limited list of class names, see examples below. + +Objects in the serialized string that are not allowed to be instantiated will instead get the class type `__PHP_Incomplete_Class`. + +##### Overriding the default `$allowed_batch_data_classes` + +The default behavior can be overridden by passing an array of allowed classes to the constructor: + +``` php +$allowed_batch_data_classes = array( MyCustomItem::class, MyItemHelper::class ); +$this->example_process = new WP_Example_Process( $allowed_batch_data_classes ); +``` + +Or, set the value to `false`: + +``` php +$this->example_process = new WP_Example_Process( false ); +``` + + +Another way to change the default is to override the `$allowed_batch_data_classes` property in your process class: + +``` php +class WP_Example_Process extends WP_Background_Process { + + /** + * @var string + */ + protected $prefix = 'my_plugin'; + + /** + * @var string + */ + protected $action = 'example_process'; + + /** + * + * @var bool|array + */ + protected $allowed_batch_data_classes = array( MyCustomItem::class, MyItemHelper::class ); + ... + +``` + +#### Background Process Status + +A background process can be queued, processing, paused, cancelled, or none of the above (not started or has completed). + +##### Queued + +To check whether a background process has queued items use `is_queued()`. + +```php +if ( $this->example_process->is_queued() ) { + // Do something because background process has queued items, e.g. add notice in admin UI. +} +``` + +##### Processing + +To check whether a background process is currently handling a queue of items use `is_processing()`. + +```php +if ( $this->example_process->is_processing() ) { + // Do something because background process is running, e.g. add notice in admin UI. +} +``` + +##### Paused + +You can pause a background process with `pause()`. + +```php +$this->example_process->pause(); +``` + +The currently processing batch will continue until it either completes or reaches the time or memory limit. At that point it'll unlock the process and either complete the batch if the queue is empty, or perform a dispatch that will result in the handler removing the healthcheck cron and firing a "paused" action. + +To check whether a background process is currently paused use `is_paused()`. + +```php +if ( $this->example_process->is_paused() ) { + // Do something because background process is paused, e.g. add notice in admin UI. +} +``` + +You can perform an action in response to background processing being paused by handling the "paused" action for the background process's identifier ($prefix + $action). + +```php +add_action( 'my_plugin_example_process_paused', function() { + // Do something because background process is paused, e.g. add notice in admin UI. +}); +``` + +You can resume a background process with `resume()`. + +```php +$this->example_process->resume(); +``` + +You can perform an action in response to background processing being resumed by handling the "resumed" action for the background process's identifier ($prefix + $action). + +```php +add_action( 'my_plugin_example_process_resumed', function() { + // Do something because background process is resumed, e.g. add notice in admin UI. +}); +``` + +##### Cancelled + +You can cancel a background process with `cancel()`. + +```php +$this->example_process->cancel(); +``` + +The currently processing batch will continue until it either completes or reaches the time or memory limit. At that point it'll unlock the process and either complete the batch if the queue is empty, or perform a dispatch that will result in the handler removing the healthcheck cron, deleting all batches of queued items and firing a "cancelled" action. + +To check whether a background process is currently cancelled use `is_cancelled()`. + +```php +if ( $this->example_process->is_cancelled() ) { + // Do something because background process is cancelled, e.g. add notice in admin UI. +} +``` + +You can perform an action in response to background processing being cancelled by handling the "cancelled" action for the background process's identifier ($prefix + $action). + +```php +add_action( 'my_plugin_example_process_cancelled', function() { + // Do something because background process is paused, e.g. add notice in admin UI. +}); +``` + +The "cancelled" action fires once the queue has been cleared down and cancelled status removed. After which `is_cancelled()` will no longer be true as the background process is now dormant. + +##### Active + +To check whether a background process has queued items, is processing, is paused, or is cancelling, use `is_active()`. + +```php +if ( $this->example_process->is_active() ) { + // Do something because background process is active, e.g. add notice in admin UI. +} +``` + +If a background process is not active, then it either has not had anything queued yet and not started, or has finished processing all queued items. ### BasicAuth -If your site is behind BasicAuth, both async requests and background processes will fail to complete. This is because WP Background Processing relies on the [WordPress HTTP API](http://codex.wordpress.org/HTTP_API), which requires you to attach your BasicAuth credentials to requests. The easiest way to do this is using the following filter: +If your site is behind BasicAuth, both async requests and background processes will fail to complete. This is because WP Background Processing relies on the [WordPress HTTP API](https://developer.wordpress.org/plugins/http-api/), which requires you to attach your BasicAuth credentials to requests. The easiest way to do this is using the following filter: ```php function wpbp_http_request_args( $r, $url ) { @@ -153,6 +353,86 @@ function wpbp_http_request_args( $r, $url ) { add_filter( 'http_request_args', 'wpbp_http_request_args', 10, 2); ``` +## Contributing + +Contributions are welcome via Pull Requests, but please do raise an issue before +working on anything to discuss the change if there isn't already an issue. If there +is an approved issue you'd like to tackle, please post a comment on it to let people know +you're going to have a go at it so that effort isn't wasted through duplicated work. + +### Unit & Style Tests + +When working on the library, please add unit tests to the appropriate file in the +`tests` directory that cover your changes. + +#### Setting Up + +We use the standard WordPress test libraries for running unit tests. + +Please run the following command to set up the libraries: + +```shell +bin/install-wp-tests.sh db_name db_user db_pass +``` + +Substitute `db_name`, `db_user` and `db_pass` as appropriate. + +Please be aware that running the unit tests is a **destructive operation**, *database +tables will be cleared*, so please use a database name dedicated to running unit tests. +The standard database name usually used by the WordPress community is `wordpress_test`, e.g. + +```shell +bin/install-wp-tests.sh wordpress_test root root +``` + +Please refer to the [Initialize the testing environment locally](https://make.wordpress.org/cli/handbook/misc/plugin-unit-tests/#3-initialize-the-testing-environment-locally) +section of the WordPress Handbook's [Plugin Integration Tests](https://make.wordpress.org/cli/handbook/misc/plugin-unit-tests/) +entry should you run into any issues. + +#### Running Unit Tests + +To run the unit tests, simply run: + +```shell +make test-unit +``` + +If the `composer` dependencies aren't in place, they'll be automatically installed first. + +#### Running Style Tests + +It's important that the code in the library use a consistent style to aid in quickly +understanding it, and to avoid some common issues. `PHP_Code_Sniffer` is used with +mostly standard WordPress rules to help check for consistency. + +To run the style tests, simply run: + +```shell +make test-style +``` + +If the `composer` dependencies aren't in place, they'll be automatically installed first. + +#### Running All Tests + +To make things super simple, just run the following to run all tests: + +```shell +make +``` + +If the `composer` dependencies aren't in place, they'll be automatically installed first. + +#### Creating a PR + +When creating a PR, please make sure to mention which GitHub issue is being resolved +at the top of the description, e.g.: + +`Resolves #123` + +The unit and style tests will be run automatically, the PR will not be eligible for +merge unless they pass, and the branch is up-to-date with `master`. + ## License [GPLv2+](http://www.gnu.org/licenses/gpl-2.0.html) diff --git a/bin/install-wp-tests.sh b/bin/install-wp-tests.sh new file mode 100755 index 0000000..d01967a --- /dev/null +++ b/bin/install-wp-tests.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash + +if [ $# -lt 3 ]; then + echo "usage: $0 [db-host] [wp-version] [skip-database-creation]" + exit 1 +fi + +DB_NAME=$1 +DB_USER=$2 +DB_PASS=$3 +DB_HOST=${4-localhost} +WP_VERSION=${5-latest} +SKIP_DB_CREATE=${6-false} + +TMPDIR=${TMPDIR-/tmp} +TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//") +WP_TESTS_DIR=${WP_TESTS_DIR-$TMPDIR/wordpress-tests-lib} +WP_CORE_DIR=${WP_CORE_DIR-$TMPDIR/wordpress} + +download() { + if [ `which curl` ]; then + curl -s "$1" > "$2"; + elif [ `which wget` ]; then + wget -nv -O "$2" "$1" + fi +} + +if [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+\-(beta|RC)[0-9]+$ ]]; then + WP_BRANCH=${WP_VERSION%\-*} + WP_TESTS_TAG="branches/$WP_BRANCH" + +elif [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+$ ]]; then + WP_TESTS_TAG="branches/$WP_VERSION" +elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then + if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then + # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x + WP_TESTS_TAG="tags/${WP_VERSION%??}" + else + WP_TESTS_TAG="tags/$WP_VERSION" + fi +elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then + WP_TESTS_TAG="trunk" +else + # http serves a single offer, whereas https serves multiple. we only want one + download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json + grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json + LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//') + if [[ -z "$LATEST_VERSION" ]]; then + echo "Latest WordPress version could not be found" + exit 1 + fi + WP_TESTS_TAG="tags/$LATEST_VERSION" +fi +set -ex + +install_wp() { + + if [ -d $WP_CORE_DIR ]; then + return; + fi + + mkdir -p $WP_CORE_DIR + + if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then + mkdir -p $TMPDIR/wordpress-trunk + rm -rf $TMPDIR/wordpress-trunk/* + svn export --quiet https://core.svn.wordpress.org/trunk $TMPDIR/wordpress-trunk/wordpress + mv $TMPDIR/wordpress-trunk/wordpress/* $WP_CORE_DIR + else + if [ $WP_VERSION == 'latest' ]; then + local ARCHIVE_NAME='latest' + elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+ ]]; then + # https serves multiple offers, whereas http serves single. + download https://api.wordpress.org/core/version-check/1.7/ $TMPDIR/wp-latest.json + if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then + # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x + LATEST_VERSION=${WP_VERSION%??} + else + # otherwise, scan the releases and get the most up to date minor version of the major release + local VERSION_ESCAPED=`echo $WP_VERSION | sed 's/\./\\\\./g'` + LATEST_VERSION=$(grep -o '"version":"'$VERSION_ESCAPED'[^"]*' $TMPDIR/wp-latest.json | sed 's/"version":"//' | head -1) + fi + if [[ -z "$LATEST_VERSION" ]]; then + local ARCHIVE_NAME="wordpress-$WP_VERSION" + else + local ARCHIVE_NAME="wordpress-$LATEST_VERSION" + fi + else + local ARCHIVE_NAME="wordpress-$WP_VERSION" + fi + download https://wordpress.org/${ARCHIVE_NAME}.tar.gz $TMPDIR/wordpress.tar.gz + tar --strip-components=1 -zxmf $TMPDIR/wordpress.tar.gz -C $WP_CORE_DIR + fi + + download https://raw.githubusercontent.com/markoheijnen/wp-mysqli/refs/heads/master/db.php $WP_CORE_DIR/wp-content/db.php +} + +install_test_suite() { + # portable in-place argument for both GNU sed and Mac OSX sed + if [[ $(uname -s) == 'Darwin' ]]; then + local ioption='-i.bak' + else + local ioption='-i' + fi + + # set up testing suite if it doesn't yet exist + if [ ! -d $WP_TESTS_DIR ]; then + # set up testing suite + mkdir -p $WP_TESTS_DIR + rm -rf $WP_TESTS_DIR/{includes,data} + svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes + svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data + fi + + if [ ! -f wp-tests-config.php ]; then + download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php + # remove all forward slashes in the end + WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::") + sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s:__DIR__ . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php + fi + +} + +recreate_db() { + shopt -s nocasematch + if [[ $1 =~ ^(y|yes)$ ]] + then + mysqladmin drop $DB_NAME -f --user="$DB_USER" --password="$DB_PASS"$EXTRA + create_db + echo "Recreated the database ($DB_NAME)." + else + echo "Leaving the existing database ($DB_NAME) in place." + fi + shopt -u nocasematch +} + +create_db() { + mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA +} + +install_db() { + + if [ ${SKIP_DB_CREATE} = "true" ]; then + return 0 + fi + + # parse DB_HOST for port or socket references + local PARTS=(${DB_HOST//\:/ }) + local DB_HOSTNAME=${PARTS[0]}; + local DB_SOCK_OR_PORT=${PARTS[1]}; + local EXTRA="" + + if ! [ -z $DB_HOSTNAME ] ; then + if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then + EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" + elif ! [ -z $DB_SOCK_OR_PORT ] ; then + EXTRA=" --socket=$DB_SOCK_OR_PORT" + elif ! [ -z $DB_HOSTNAME ] ; then + EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" + fi + fi + + # create database + if [ $(mysql --user="$DB_USER" --password="$DB_PASS"$EXTRA --execute='show databases;' | grep ^$DB_NAME$) ] + then + echo "Reinstalling will delete the existing test database ($DB_NAME)" + read -p 'Are you sure you want to proceed? [y/N]: ' DELETE_EXISTING_DB + recreate_db $DELETE_EXISTING_DB + else + create_db + fi +} + +install_wp +install_test_suite +install_db diff --git a/classes/wp-async-request.php b/classes/wp-async-request.php index f1bef83..1573c5b 100644 --- a/classes/wp-async-request.php +++ b/classes/wp-async-request.php @@ -5,159 +5,198 @@ * @package WP-Background-Processing */ -if ( ! class_exists( 'WP_Async_Request' ) ) { +/** + * Abstract WP_Async_Request class. + * + * @abstract + */ +abstract class WP_Async_Request { /** - * Abstract WP_Async_Request class. + * Prefix * - * @abstract + * (default value: 'wp') + * + * @var string + * @access protected */ - abstract class WP_Async_Request { + protected $prefix = 'wp'; - /** - * Prefix - * - * (default value: 'wp') - * - * @var string - * @access protected - */ - protected $prefix = 'wp'; + /** + * Action + * + * (default value: 'async_request') + * + * @var string + * @access protected + */ + protected $action = 'async_request'; - /** - * Action - * - * (default value: 'async_request') - * - * @var string - * @access protected - */ - protected $action = 'async_request'; + /** + * Identifier + * + * @var mixed + * @access protected + */ + protected $identifier; - /** - * Identifier - * - * @var mixed - * @access protected - */ - protected $identifier; + /** + * Data + * + * (default value: array()) + * + * @var array + * @access protected + */ + protected $data = array(); - /** - * Data - * - * (default value: array()) - * - * @var array - * @access protected - */ - protected $data = array(); + /** + * Initiate new async request. + */ + public function __construct() { + $this->identifier = $this->prefix . '_' . $this->action; - /** - * Initiate new async request - */ - public function __construct() { - $this->identifier = $this->prefix . '_' . $this->action; + add_action( 'wp_ajax_' . $this->identifier, array( $this, 'maybe_handle' ) ); + add_action( 'wp_ajax_nopriv_' . $this->identifier, array( $this, 'maybe_handle' ) ); + } - add_action( 'wp_ajax_' . $this->identifier, array( $this, 'maybe_handle' ) ); - add_action( 'wp_ajax_nopriv_' . $this->identifier, array( $this, 'maybe_handle' ) ); - } + /** + * Set data used during the request. + * + * @param array $data Data. + * + * @return $this + */ + public function data( $data ) { + $this->data = $data; - /** - * Set data used during the request - * - * @param array $data Data. - * - * @return $this - */ - public function data( $data ) { - $this->data = $data; + return $this; + } - return $this; - } + /** + * Dispatch the async request. + * + * @return array|WP_Error|false HTTP Response array, WP_Error on failure, or false if not attempted. + */ + public function dispatch() { + $url = add_query_arg( $this->get_query_args(), $this->get_query_url() ); + $args = $this->get_post_args(); - /** - * Dispatch the async request - * - * @return array|WP_Error - */ - public function dispatch() { - $url = add_query_arg( $this->get_query_args(), $this->get_query_url() ); - $args = $this->get_post_args(); + return wp_remote_post( esc_url_raw( $url ), $args ); + } - return wp_remote_post( esc_url_raw( $url ), $args ); + /** + * Get query args. + * + * @return array + */ + protected function get_query_args() { + if ( property_exists( $this, 'query_args' ) ) { + return $this->query_args; } - /** - * Get query args - * - * @return array - */ - protected function get_query_args() { - if ( property_exists( $this, 'query_args' ) ) { - return $this->query_args; - } - - return array( - 'action' => $this->identifier, - 'nonce' => wp_create_nonce( $this->identifier ), - ); - } + $args = array( + 'action' => $this->identifier, + 'nonce' => wp_create_nonce( $this->identifier ), + ); /** - * Get query URL + * Filters the query arguments used during an async request. * - * @return string + * @param array $args */ - protected function get_query_url() { - if ( property_exists( $this, 'query_url' ) ) { - return $this->query_url; - } + return apply_filters( $this->identifier . '_query_args', $args ); + } - return admin_url( 'admin-ajax.php' ); + /** + * Get query URL. + * + * @return string + */ + protected function get_query_url() { + if ( property_exists( $this, 'query_url' ) ) { + return $this->query_url; } + $url = admin_url( 'admin-ajax.php' ); + /** - * Get post args + * Filters the query URL used during an async request. * - * @return array + * @param string $url */ - protected function get_post_args() { - if ( property_exists( $this, 'post_args' ) ) { - return $this->post_args; - } - - return array( - 'timeout' => 0.01, - 'blocking' => false, - 'body' => $this->data, - 'cookies' => $_COOKIE, - 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), - ); + return apply_filters( $this->identifier . '_query_url', $url ); + } + + /** + * Get post args. + * + * @return array + */ + protected function get_post_args() { + if ( property_exists( $this, 'post_args' ) ) { + return $this->post_args; } + $args = array( + 'timeout' => 5, + 'blocking' => false, + 'body' => $this->data, + 'cookies' => $_COOKIE, // Passing cookies ensures request is performed as initiating user. + 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), // Local requests, fine to pass false. + ); + /** - * Maybe handle + * Filters the post arguments used during an async request. * - * Check for correct nonce and pass to handler. + * @param array $args */ - public function maybe_handle() { - // Don't lock up other requests while processing - session_write_close(); + return apply_filters( $this->identifier . '_post_args', $args ); + } - check_ajax_referer( $this->identifier, 'nonce' ); + /** + * Maybe handle a dispatched request. + * + * Check for correct nonce and pass to handler. + * + * @return void|mixed + */ + public function maybe_handle() { + // Don't lock up other requests while processing. + session_write_close(); - $this->handle(); + check_ajax_referer( $this->identifier, 'nonce' ); - wp_die(); - } + $this->handle(); + + return $this->maybe_wp_die(); + } + /** + * Should the process exit with wp_die? + * + * @param mixed $default_return What to return if filter says don't die, default is null. + * + * @return void|mixed + */ + protected function maybe_wp_die( $default_return = null ) { /** - * Handle + * Should wp_die be used? * - * Override this method to perform any actions required - * during the async request. + * @return bool */ - abstract protected function handle(); + if ( apply_filters( $this->identifier . '_wp_die', true ) ) { + wp_die(); + } + return $default_return; } + + /** + * Handle a dispatched request. + * + * Override this method to perform any actions required + * during the async request. + */ + abstract protected function handle(); } diff --git a/classes/wp-background-process.php b/classes/wp-background-process.php index 03e3a3e..2760b21 100644 --- a/classes/wp-background-process.php +++ b/classes/wp-background-process.php @@ -5,502 +5,994 @@ * @package WP-Background-Processing */ -if ( ! class_exists( 'WP_Background_Process' ) ) { +/** + * Abstract WP_Background_Process class. + * + * @abstract + * @extends WP_Async_Request + */ +abstract class WP_Background_Process extends WP_Async_Request { + /** + * The default query arg name used for passing the chain ID to new processes. + */ + const CHAIN_ID_ARG_NAME = 'chain_id'; /** - * Abstract WP_Background_Process class. + * Unique background process chain ID. * - * @abstract - * @extends WP_Async_Request + * @var string */ - abstract class WP_Background_Process extends WP_Async_Request { + private $chain_id; - /** - * Action - * - * (default value: 'background_process') - * - * @var string - * @access protected - */ - protected $action = 'background_process'; + /** + * Action + * + * (default value: 'background_process') + * + * @var string + * @access protected + */ + protected $action = 'background_process'; - /** - * Start time of current process. - * - * (default value: 0) - * - * @var int - * @access protected - */ - protected $start_time = 0; + /** + * Start time of current process. + * + * (default value: 0) + * + * @var int + * @access protected + */ + protected $start_time = 0; - /** - * Cron_hook_identifier - * - * @var mixed - * @access protected - */ - protected $cron_hook_identifier; + /** + * Cron_hook_identifier + * + * @var string + * @access protected + */ + protected $cron_hook_identifier; - /** - * Cron_interval_identifier - * - * @var mixed - * @access protected - */ - protected $cron_interval_identifier; + /** + * Cron_interval_identifier + * + * @var string + * @access protected + */ + protected $cron_interval_identifier; - /** - * Initiate new background process - */ - public function __construct() { - parent::__construct(); + /** + * Restrict object instantiation when using unserialize. + * + * @var bool|array + */ + protected $allowed_batch_data_classes = true; - $this->cron_hook_identifier = $this->identifier . '_cron'; - $this->cron_interval_identifier = $this->identifier . '_cron_interval'; + /** + * The status set when process is cancelling. + * + * @var int + */ + const STATUS_CANCELLED = 1; - add_action( $this->cron_hook_identifier, array( $this, 'handle_cron_healthcheck' ) ); - add_filter( 'cron_schedules', array( $this, 'schedule_cron_healthcheck' ) ); - } + /** + * The status set when process is paused or pausing. + * + * @var int; + */ + const STATUS_PAUSED = 2; - /** - * Dispatch - * - * @access public - * @return void - */ - public function dispatch() { - // Schedule the cron healthcheck. - $this->schedule_event(); + /** + * Initiate new background process. + * + * @param bool|array $allowed_batch_data_classes Optional. Array of class names that can be unserialized. Default true (any class). + */ + public function __construct( $allowed_batch_data_classes = true ) { + parent::__construct(); - // Perform remote post. - return parent::dispatch(); + if ( empty( $allowed_batch_data_classes ) && false !== $allowed_batch_data_classes ) { + $allowed_batch_data_classes = true; } - /** - * Push to queue - * - * @param mixed $data Data. - * - * @return $this - */ - public function push_to_queue( $data ) { - $this->data[] = $data; + if ( ! is_bool( $allowed_batch_data_classes ) && ! is_array( $allowed_batch_data_classes ) ) { + $allowed_batch_data_classes = true; + } - return $this; + // If allowed_batch_data_classes property set in subclass, + // only apply override if not allowing any class. + if ( true === $this->allowed_batch_data_classes || true !== $allowed_batch_data_classes ) { + $this->allowed_batch_data_classes = $allowed_batch_data_classes; } - /** - * Save queue - * - * @return $this - */ - public function save() { - $key = $this->generate_key(); + $this->cron_hook_identifier = $this->identifier . '_cron'; + $this->cron_interval_identifier = $this->identifier . '_cron_interval'; - if ( ! empty( $this->data ) ) { - update_site_option( $key, $this->data ); - } + add_action( $this->cron_hook_identifier, array( $this, 'handle_cron_healthcheck' ) ); + add_filter( 'cron_schedules', array( $this, 'schedule_cron_healthcheck' ) ); + + // Ensure dispatch query args included extra data. + add_filter( $this->identifier . '_query_args', array( $this, 'filter_dispatch_query_args' ) ); + } - return $this; + /** + * Schedule the cron healthcheck and dispatch an async request to start processing the queue. + * + * @access public + * @return array|WP_Error|false HTTP Response array, WP_Error on failure, or false if not attempted. + */ + public function dispatch() { + if ( $this->is_processing() ) { + // Process already running. + return false; } /** - * Update queue - * - * @param string $key Key. - * @param array $data Data. + * Filter fired before background process dispatches its next process. * - * @return $this + * @param bool $cancel Should the dispatch be cancelled? Default false. + * @param string $chain_id The background process chain ID. */ - public function update( $key, $data ) { - if ( ! empty( $data ) ) { - update_site_option( $key, $data ); - } + $cancel = apply_filters( $this->identifier . '_pre_dispatch', false, $this->get_chain_id() ); - return $this; + if ( $cancel ) { + return false; } - /** - * Delete queue - * - * @param string $key Key. - * - * @return $this - */ - public function delete( $key ) { - delete_site_option( $key ); + // Schedule the cron healthcheck. + $this->schedule_event(); + + // Perform remote post. + return parent::dispatch(); + } + + /** + * Push to the queue. + * + * Note, save must be called in order to persist queued items to a batch for processing. + * + * @param mixed $data Data. + * + * @return $this + */ + public function push_to_queue( $data ) { + $this->data[] = $data; - return $this; + return $this; + } + + /** + * Save the queued items for future processing. + * + * @return $this + */ + public function save() { + $key = $this->generate_key(); + + if ( ! empty( $this->data ) ) { + update_site_option( $key, $this->data ); } - /** - * Generate key - * - * Generates a unique key based on microtime. Queue items are - * given a unique key so that they can be merged upon save. - * - * @param int $length Length. - * - * @return string - */ - protected function generate_key( $length = 64 ) { - $unique = md5( microtime() . rand() ); - $prepend = $this->identifier . '_batch_'; + // Clean out data so that new data isn't prepended with closed session's data. + $this->data = array(); + + return $this; + } - return substr( $prepend . $unique, 0, $length ); + /** + * Update a batch's queued items. + * + * @param string $key Key. + * @param array $data Data. + * + * @return $this + */ + public function update( $key, $data ) { + if ( ! empty( $data ) ) { + update_site_option( $key, $data ); } - /** - * Maybe process queue - * - * Checks whether data exists within the queue and that - * the process is not already running. - */ - public function maybe_handle() { - // Don't lock up other requests while processing - session_write_close(); + return $this; + } - if ( $this->is_process_running() ) { - // Background process already running. - wp_die(); - } + /** + * Delete a batch of queued items. + * + * @param string $key Key. + * + * @return $this + */ + public function delete( $key ) { + delete_site_option( $key ); - if ( $this->is_queue_empty() ) { - // No data to process. - wp_die(); - } + return $this; + } - check_ajax_referer( $this->identifier, 'nonce' ); + /** + * Delete entire job queue. + */ + public function delete_all() { + $batches = $this->get_batches(); + + foreach ( $batches as $batch ) { + $this->delete( $batch->key ); + } + + delete_site_option( $this->get_status_key() ); + + $this->cancelled(); + } + + /** + * Cancel job on next batch. + */ + public function cancel() { + update_site_option( $this->get_status_key(), self::STATUS_CANCELLED ); + + // Just in case the job was paused at the time. + $this->dispatch(); + } + + /** + * Has the process been cancelled? + * + * @return bool + */ + public function is_cancelled() { + return $this->get_status() === self::STATUS_CANCELLED; + } + + /** + * Called when background process has been cancelled. + */ + protected function cancelled() { + do_action( $this->identifier . '_cancelled', $this->get_chain_id() ); + } + + /** + * Pause job on next batch. + */ + public function pause() { + update_site_option( $this->get_status_key(), self::STATUS_PAUSED ); + } + + /** + * Has the process been paused? + * + * @return bool + */ + public function is_paused() { + return $this->get_status() === self::STATUS_PAUSED; + } + + /** + * Called when background process has been paused. + */ + protected function paused() { + do_action( $this->identifier . '_paused', $this->get_chain_id() ); + } + + /** + * Resume job. + */ + public function resume() { + delete_site_option( $this->get_status_key() ); + + $this->schedule_event(); + $this->dispatch(); + $this->resumed(); + } + + /** + * Called when background process has been resumed. + */ + protected function resumed() { + do_action( $this->identifier . '_resumed', $this->get_chain_id() ); + } + + /** + * Is queued? + * + * @return bool + */ + public function is_queued() { + return ! $this->is_queue_empty(); + } + + /** + * Is the tool currently active, e.g. starting, working, paused or cleaning up? + * + * @return bool + */ + public function is_active() { + return $this->is_queued() || $this->is_processing() || $this->is_paused() || $this->is_cancelled(); + } + + /** + * Generate key for a batch. + * + * Generates a unique key based on microtime. Queue items are + * given a unique key so that they can be merged upon save. + * + * @param int $length Optional max length to trim key to, defaults to 64 characters. + * @param string $key Optional string to append to identifier before hash, defaults to "batch". + * + * @return string + */ + protected function generate_key( $length = 64, $key = 'batch' ) { + $unique = md5( microtime() . wp_rand() ); + $prepend = $this->identifier . '_' . $key . '_'; + + return substr( $prepend . $unique, 0, $length ); + } - $this->handle(); + /** + * Get the status key. + * + * @return string + */ + protected function get_status_key() { + return $this->identifier . '_status'; + } - wp_die(); + /** + * Get the status value for the process. + * + * @return int + */ + protected function get_status() { + global $wpdb; + + if ( is_multisite() ) { + $status = $wpdb->get_var( + $wpdb->prepare( + "SELECT meta_value FROM $wpdb->sitemeta WHERE meta_key = %s AND site_id = %d LIMIT 1", + $this->get_status_key(), + get_current_network_id() + ) + ); + } else { + $status = $wpdb->get_var( + $wpdb->prepare( + "SELECT option_value FROM $wpdb->options WHERE option_name = %s LIMIT 1", + $this->get_status_key() + ) + ); } - /** - * Is queue empty - * - * @return bool - */ - protected function is_queue_empty() { - global $wpdb; + return absint( $status ); + } - $table = $wpdb->options; - $column = 'option_name'; + /** + * Maybe process a batch of queued items. + * + * Checks whether data exists within the queue and that + * the process is not already running. + */ + public function maybe_handle() { + // Don't lock up other requests while processing. + session_write_close(); - if ( is_multisite() ) { - $table = $wpdb->sitemeta; - $column = 'meta_key'; - } + check_ajax_referer( $this->identifier, 'nonce' ); - $key = $wpdb->esc_like( $this->identifier . '_batch_' ) . '%'; + // Background process already running. + if ( $this->is_processing() ) { + return $this->maybe_wp_die(); + } - $count = $wpdb->get_var( $wpdb->prepare( " - SELECT COUNT(*) - FROM {$table} - WHERE {$column} LIKE %s - ", $key ) ); + // Cancel requested. + if ( $this->is_cancelled() ) { + $this->clear_scheduled_event(); + $this->delete_all(); - return ( $count > 0 ) ? false : true; + return $this->maybe_wp_die(); } - /** - * Is process running - * - * Check whether the current process is already running - * in a background process. - */ - protected function is_process_running() { - if ( get_site_transient( $this->identifier . '_process_lock' ) ) { - // Process already running. - return true; - } + // Pause requested. + if ( $this->is_paused() ) { + $this->clear_scheduled_event(); + $this->paused(); - return false; + return $this->maybe_wp_die(); } - /** - * Lock process - * - * Lock the process so that multiple instances can't run simultaneously. - * Override if applicable, but the duration should be greater than that - * defined in the time_exceeded() method. - */ - protected function lock_process() { - $this->start_time = time(); // Set start time of current process. + // No data to process. + if ( $this->is_queue_empty() ) { + return $this->maybe_wp_die(); + } + + $this->handle(); + + return $this->maybe_wp_die(); + } + + /** + * Is queue empty? + * + * @return bool + */ + protected function is_queue_empty() { + return empty( $this->get_batch() ); + } + + /** + * Is process running? + * + * Check whether the current process is already running + * in a background process. + * + * @return bool + * + * @deprecated 1.1.0 Superseded. + * @see is_processing() + */ + protected function is_process_running() { + return $this->is_processing(); + } + + /** + * Is the background process currently running? + * + * @return bool + */ + public function is_processing() { + if ( get_site_transient( $this->identifier . '_process_lock' ) ) { + // Process already running. + return true; + } - $lock_duration = ( property_exists( $this, 'queue_lock_time' ) ) ? $this->queue_lock_time : 60; // 1 minute - $lock_duration = apply_filters( $this->identifier . '_queue_lock_time', $lock_duration ); + return false; + } - set_site_transient( $this->identifier . '_process_lock', microtime(), $lock_duration ); + /** + * Lock process. + * + * Lock the process so that multiple instances can't run simultaneously. + * Override if applicable, but the duration should be greater than that + * defined in the time_exceeded() method. + * + * @param bool $reset_start_time Optional, default true. + */ + public function lock_process( $reset_start_time = true ) { + if ( $reset_start_time ) { + $this->start_time = time(); // Set start time of current process. } + $lock_duration = ( property_exists( $this, 'queue_lock_time' ) ) ? $this->queue_lock_time : 60; // 1 minute + $lock_duration = apply_filters( $this->identifier . '_queue_lock_time', $lock_duration ); + + $microtime = microtime(); + $locked = set_site_transient( $this->identifier . '_process_lock', $microtime, $lock_duration ); + /** - * Unlock process + * Action to note whether the background process managed to create its lock. * - * Unlock the process so that other instances can spawn. + * The lock is used to signify that a process is running a task and no other + * process should be allowed to run the same task until the lock is released. * - * @return $this + * @param bool $locked Whether the lock was successfully created. + * @param string $microtime Microtime string value used for the lock. + * @param int $lock_duration Max number of seconds that the lock will live for. + * @param string $chain_id Current background process chain ID. */ - protected function unlock_process() { - delete_site_transient( $this->identifier . '_process_lock' ); + do_action( + $this->identifier . '_process_locked', + $locked, + $microtime, + $lock_duration, + $this->get_chain_id() + ); + } - return $this; - } + /** + * Unlock process. + * + * Unlock the process so that other instances can spawn. + * + * @return $this + */ + protected function unlock_process() { + $unlocked = delete_site_transient( $this->identifier . '_process_lock' ); /** - * Get batch + * Action to note whether the background process managed to release its lock. * - * @return stdClass Return the first batch from the queue + * The lock is used to signify that a process is running a task and no other + * process should be allowed to run the same task until the lock is released. + * + * @param bool $unlocked Whether the lock was released. + * @param string $chain_id Current background process chain ID. */ - protected function get_batch() { - global $wpdb; - - $table = $wpdb->options; - $column = 'option_name'; - $key_column = 'option_id'; - $value_column = 'option_value'; - - if ( is_multisite() ) { - $table = $wpdb->sitemeta; - $column = 'meta_key'; - $key_column = 'meta_id'; - $value_column = 'meta_value'; - } + do_action( $this->identifier . '_process_unlocked', $unlocked, $this->get_chain_id() ); + + return $this; + } + + /** + * Get batch. + * + * @return stdClass Return the first batch of queued items. + */ + protected function get_batch() { + return array_reduce( + $this->get_batches( 1 ), + static function ( $carry, $batch ) { + return $batch; + }, + array() + ); + } + + /** + * Get batches. + * + * @param int $limit Number of batches to return, defaults to all. + * + * @return array of stdClass + */ + public function get_batches( $limit = 0 ) { + global $wpdb; + + if ( empty( $limit ) || ! is_int( $limit ) ) { + $limit = 0; + } - $key = $wpdb->esc_like( $this->identifier . '_batch_' ) . '%'; + $table = $wpdb->options; + $column = 'option_name'; + $key_column = 'option_id'; + $value_column = 'option_value'; - $query = $wpdb->get_row( $wpdb->prepare( " + if ( is_multisite() ) { + $table = $wpdb->sitemeta; + $column = 'meta_key'; + $key_column = 'meta_id'; + $value_column = 'meta_value'; + } + + $key = $wpdb->esc_like( $this->identifier . '_batch_' ) . '%'; + + $sql = ' SELECT * - FROM {$table} - WHERE {$column} LIKE %s - ORDER BY {$key_column} ASC - LIMIT 1 - ", $key ) ); + FROM ' . $table . ' + WHERE ' . $column . ' LIKE %s + ORDER BY ' . $key_column . ' ASC + '; + + $args = array( $key ); - $batch = new stdClass(); - $batch->key = $query->$column; - $batch->data = maybe_unserialize( $query->$value_column ); + if ( ! empty( $limit ) ) { + $sql .= ' LIMIT %d'; - return $batch; + $args[] = $limit; } - /** - * Handle - * - * Pass each queue item to the task handler, while remaining - * within server memory and time limit constraints. - */ - protected function handle() { - $this->lock_process(); + $items = $wpdb->get_results( + $wpdb->prepare( + $sql, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $args + ) + ); - do { - $batch = $this->get_batch(); + $batches = array(); - foreach ( $batch->data as $key => $value ) { - $task = $this->task( $value ); + if ( ! empty( $items ) ) { + $allowed_classes = $this->allowed_batch_data_classes; - if ( false !== $task ) { - $batch->data[ $key ] = $task; - } else { - unset( $batch->data[ $key ] ); - } + $batches = array_map( + static function ( $item ) use ( $column, $value_column, $allowed_classes ) { + $batch = new stdClass(); + $batch->key = $item->{$column}; + $batch->data = static::maybe_unserialize( $item->{$value_column}, $allowed_classes ); - if ( $this->time_exceeded() || $this->memory_exceeded() ) { - // Batch limits reached. - break; - } + return $batch; + }, + $items + ); + } + + return $batches; + } + + /** + * Handle a dispatched request. + * + * Pass each queue item to the task handler, while remaining + * within server memory and time limit constraints. + */ + protected function handle() { + $this->lock_process(); + + /** + * Number of seconds to sleep between batches. Defaults to 0 seconds, minimum 0. + * + * @param int $seconds + */ + $throttle_seconds = max( + 0, + apply_filters( + $this->identifier . '_seconds_between_batches', + apply_filters( + $this->prefix . '_seconds_between_batches', + 0 + ) + ) + ); + + do { + $batch = $this->get_batch(); + + foreach ( $batch->data as $key => $value ) { + $task = $this->task( $value ); + + if ( false !== $task ) { + $batch->data[ $key ] = $task; + } else { + unset( $batch->data[ $key ] ); } - // Update or delete current batch. + // Keep the batch up to date while processing it. if ( ! empty( $batch->data ) ) { $this->update( $batch->key, $batch->data ); - } else { - $this->delete( $batch->key ); } - } while ( ! $this->time_exceeded() && ! $this->memory_exceeded() && ! $this->is_queue_empty() ); - $this->unlock_process(); + // Let the server breathe a little. + sleep( $throttle_seconds ); + + // Batch limits reached, or pause or cancel requested. + if ( ! $this->should_continue() ) { + break; + } + } - // Start next batch or complete process. - if ( ! $this->is_queue_empty() ) { - $this->dispatch(); - } else { - $this->complete(); + // Delete current batch if fully processed. + if ( empty( $batch->data ) ) { + $this->delete( $batch->key ); } + } while ( ! $this->is_queue_empty() && $this->should_continue() ); + + $this->unlock_process(); - wp_die(); + // Start next batch or complete process. + if ( ! $this->is_queue_empty() ) { + $this->dispatch(); + } else { + $this->complete(); } - /** - * Memory exceeded - * - * Ensures the batch process never exceeds 90% - * of the maximum WordPress memory. - * - * @return bool - */ - protected function memory_exceeded() { - $memory_limit = $this->get_memory_limit() * 0.9; // 90% of max memory - $current_memory = memory_get_usage( true ); - $return = false; + return $this->maybe_wp_die(); + } - if ( $current_memory >= $memory_limit ) { - $return = true; - } + /** + * Memory exceeded? + * + * Ensures the batch process never exceeds 90% + * of the maximum WordPress memory. + * + * @return bool + */ + protected function memory_exceeded() { + $memory_limit = $this->get_memory_limit() * 0.9; // 90% of max memory + $current_memory = memory_get_usage( true ); + $return = false; - return apply_filters( $this->identifier . '_memory_exceeded', $return ); + if ( $current_memory >= $memory_limit ) { + $return = true; } - /** - * Get memory limit - * - * @return int - */ - protected function get_memory_limit() { - if ( function_exists( 'ini_get' ) ) { - $memory_limit = ini_get( 'memory_limit' ); - } else { - // Sensible default. - $memory_limit = '128M'; - } + return apply_filters( $this->identifier . '_memory_exceeded', $return ); + } - if ( ! $memory_limit || -1 === intval( $memory_limit ) ) { - // Unlimited, set to 32GB. - $memory_limit = '32000M'; - } + /** + * Get memory limit in bytes. + * + * @return int + */ + protected function get_memory_limit() { + if ( function_exists( 'ini_get' ) ) { + $memory_limit = ini_get( 'memory_limit' ); + } else { + // Sensible default. + $memory_limit = '128M'; + } - return intval( $memory_limit ) * 1024 * 1024; + if ( ! $memory_limit || -1 === intval( $memory_limit ) ) { + // Unlimited, set to 32GB. + $memory_limit = '32000M'; } - /** - * Time exceeded. - * - * Ensures the batch never exceeds a sensible time limit. - * A timeout limit of 30s is common on shared hosting. - * - * @return bool - */ - protected function time_exceeded() { - $finish = $this->start_time + apply_filters( $this->identifier . '_default_time_limit', 20 ); // 20 seconds - $return = false; + return wp_convert_hr_to_bytes( $memory_limit ); + } - if ( time() >= $finish ) { - $return = true; - } + /** + * Time limit exceeded? + * + * Ensures the batch never exceeds a sensible time limit. + * A timeout limit of 30s is common on shared hosting. + * + * @return bool + */ + protected function time_exceeded() { + $finish = $this->start_time + apply_filters( $this->identifier . '_default_time_limit', 20 ); // 20 seconds + $return = false; - return apply_filters( $this->identifier . '_time_exceeded', $return ); + if ( time() >= $finish ) { + $return = true; } - /** - * Complete. - * - * Override if applicable, but ensure that the below actions are - * performed, or, call parent::complete(). - */ - protected function complete() { - // Unschedule the cron healthcheck. - $this->clear_scheduled_event(); + return apply_filters( $this->identifier . '_time_exceeded', $return ); + } + + /** + * Complete processing. + * + * Override if applicable, but ensure that the below actions are + * performed, or, call parent::complete(). + */ + protected function complete() { + delete_site_option( $this->get_status_key() ); + + // Remove the cron healthcheck job from the cron schedule. + $this->clear_scheduled_event(); + + $this->completed(); + } + + /** + * Called when background process has completed. + */ + protected function completed() { + do_action( $this->identifier . '_completed', $this->get_chain_id() ); + } + + /** + * Get the cron healthcheck interval in minutes. + * + * Default is 5 minutes, minimum is 1 minute. + * + * @return int + */ + public function get_cron_interval() { + $interval = 5; + + if ( property_exists( $this, 'cron_interval' ) ) { + $interval = $this->cron_interval; } - /** - * Schedule cron healthcheck - * - * @access public - * @param mixed $schedules Schedules. - * @return mixed - */ - public function schedule_cron_healthcheck( $schedules ) { - $interval = apply_filters( $this->identifier . '_cron_interval', 5 ); + $interval = apply_filters( $this->cron_interval_identifier, $interval ); - if ( property_exists( $this, 'cron_interval' ) ) { - $interval = apply_filters( $this->identifier . '_cron_interval', $this->cron_interval ); - } + return is_int( $interval ) && 0 < $interval ? $interval : 5; + } - // Adds every 5 minutes to the existing schedules. - $schedules[ $this->identifier . '_cron_interval' ] = array( - 'interval' => MINUTE_IN_SECONDS * $interval, - 'display' => sprintf( __( 'Every %d Minutes' ), $interval ), - ); + /** + * Schedule the cron healthcheck job. + * + * @access public + * + * @param mixed $schedules Schedules. + * + * @return mixed + */ + public function schedule_cron_healthcheck( $schedules ) { + $interval = $this->get_cron_interval(); - return $schedules; + if ( 1 === $interval ) { + $display = __( 'Every Minute' ); + } else { + $display = sprintf( __( 'Every %d Minutes' ), $interval ); } - /** - * Handle cron healthcheck - * - * Restart the background process if not already running - * and data exists in the queue. - */ - public function handle_cron_healthcheck() { - if ( $this->is_process_running() ) { - // Background process already running. - exit; - } + // Adds an "Every NNN Minute(s)" schedule to the existing cron schedules. + $schedules[ $this->cron_interval_identifier ] = array( + 'interval' => MINUTE_IN_SECONDS * $interval, + 'display' => $display, + ); - if ( $this->is_queue_empty() ) { - // No data to process. - $this->clear_scheduled_event(); - exit; - } + return $schedules; + } - $this->handle(); + /** + * Handle cron healthcheck event. + * + * Restart the background process if not already running + * and data exists in the queue. + */ + public function handle_cron_healthcheck() { + if ( $this->is_processing() ) { + // Background process already running. + exit; + } + if ( $this->is_queue_empty() ) { + // No data to process. + $this->clear_scheduled_event(); exit; } - /** - * Schedule event - */ - protected function schedule_event() { - if ( ! wp_next_scheduled( $this->cron_hook_identifier ) ) { - wp_schedule_event( time(), $this->cron_interval_identifier, $this->cron_hook_identifier ); - } + $this->dispatch(); + } + + /** + * Schedule the cron healthcheck event. + */ + protected function schedule_event() { + if ( ! wp_next_scheduled( $this->cron_hook_identifier ) ) { + wp_schedule_event( + time() + ( $this->get_cron_interval() * MINUTE_IN_SECONDS ), + $this->cron_interval_identifier, + $this->cron_hook_identifier + ); } + } - /** - * Clear scheduled event - */ - protected function clear_scheduled_event() { - $timestamp = wp_next_scheduled( $this->cron_hook_identifier ); + /** + * Clear scheduled cron healthcheck event. + */ + protected function clear_scheduled_event() { + $timestamp = wp_next_scheduled( $this->cron_hook_identifier ); + + if ( $timestamp ) { + wp_unschedule_event( $timestamp, $this->cron_hook_identifier ); + } + } + + /** + * Cancel the background process. + * + * Stop processing queue items, clear cron job and delete batch. + * + * @deprecated 1.1.0 Superseded. + * @see cancel() + */ + public function cancel_process() { + $this->cancel(); + } - if ( $timestamp ) { - wp_unschedule_event( $timestamp, $this->cron_hook_identifier ); + /** + * Perform task with queued item. + * + * Override this method to perform any actions required on each + * queue item. Return the modified item for further processing + * in the next pass through. Or, return false to remove the + * item from the queue. + * + * @param mixed $item Queue item to iterate over. + * + * @return mixed + */ + abstract protected function task( $item ); + + /** + * Maybe unserialize data, but not if an object. + * + * @param mixed $data Data to be unserialized. + * @param bool|array $allowed_classes Array of class names that can be unserialized. + * + * @return mixed + */ + protected static function maybe_unserialize( $data, $allowed_classes ) { + if ( is_serialized( $data ) ) { + $options = array(); + if ( is_bool( $allowed_classes ) || is_array( $allowed_classes ) ) { + $options['allowed_classes'] = $allowed_classes; } + + return @unserialize( $data, $options ); // @phpcs:ignore } + return $data; + } + + /** + * Should any processing continue? + * + * @return bool + */ + public function should_continue() { /** - * Cancel Process + * Filter whether the current background process should continue running the task + * if there is data to be processed. + * + * If the processing time or memory limits have been exceeded, the value will be false. + * If pause or cancel have been requested, the value will be false. + * + * It is very unlikely that you would want to override a false value with true. * - * Stop processing queue items, clear cronjob and delete batch. + * If false is returned here, it does not necessarily mean background processing is + * complete. If there is batch data still to be processed and pause or cancel have not + * been requested, it simply means this background process should spawn a new process + * for the chain to continue processing and then close itself down. * + * @param bool $continue Should the current process continue processing the task? + * @param string $chain_id The current background process chain's ID. + * + * @return bool */ - public function cancel_process() { - if ( ! $this->is_queue_empty() ) { - $batch = $this->get_batch(); + return apply_filters( + $this->identifier . '_should_continue', + ! ( $this->time_exceeded() || $this->memory_exceeded() || $this->is_paused() || $this->is_cancelled() ), + $this->get_chain_id() + ); + } - $this->delete( $batch->key ); + /** + * Get the string used to identify this type of background process. + * + * @return string + */ + public function get_identifier() { + return $this->identifier; + } + + /** + * Return the current background process chain's ID. + * + * If the chain's ID hasn't been set before this function is first used, + * and hasn't been passed as a query arg during dispatch, + * the chain ID will be generated before being returned. + * + * @return string + */ + public function get_chain_id() { + if ( empty( $this->chain_id ) && wp_doing_ajax() && isset( $_REQUEST['action'] ) && $_REQUEST['action'] === $this->identifier ) { + check_ajax_referer( $this->identifier, 'nonce' ); + + if ( ! empty( $_GET[ $this->get_chain_id_arg_name() ] ) ) { + $chain_id = sanitize_key( $_GET[ $this->get_chain_id_arg_name() ] ); + + if ( wp_is_uuid( $chain_id ) ) { + $this->chain_id = $chain_id; - wp_clear_scheduled_hook( $this->cron_hook_identifier ); + return $this->chain_id; + } } + } + if ( empty( $this->chain_id ) ) { + $this->chain_id = wp_generate_uuid4(); + } + + return $this->chain_id; + } + + /** + * Filters the query arguments used during an async request. + * + * @param array $args Current query args. + * + * @return array + */ + public function filter_dispatch_query_args( $args ) { + $args[ $this->get_chain_id_arg_name() ] = $this->get_chain_id(); + + return $args; + } + + /** + * Get the query arg name used for passing the chain ID to new processes. + * + * @return string + */ + private function get_chain_id_arg_name() { + static $chain_id_arg_name; + + if ( ! empty( $chain_id_arg_name ) ) { + return $chain_id_arg_name; } /** - * Task + * Filter the query arg name used for passing the chain ID to new processes. * - * Override this method to perform any actions required on each - * queue item. Return the modified item for further processing - * in the next pass through. Or, return false to remove the - * item from the queue. + * If you encounter problems with using the default query arg name, you can + * change it with this filter. * - * @param mixed $item Queue item to iterate over. + * @param string $chain_id_arg_name Default "chain_id". * - * @return mixed + * @return string */ - abstract protected function task( $item ); + $chain_id_arg_name = apply_filters( $this->identifier . '_chain_id_arg_name', self::CHAIN_ID_ARG_NAME ); + + if ( ! is_string( $chain_id_arg_name ) || empty( $chain_id_arg_name ) ) { + $chain_id_arg_name = self::CHAIN_ID_ARG_NAME; + } + return $chain_id_arg_name; } } diff --git a/composer.json b/composer.json index 42956d6..1fbd054 100644 --- a/composer.json +++ b/composer.json @@ -1,18 +1,35 @@ { - "name": "a5hleyrich/wp-background-processing", + "name": "deliciousbrains/wp-background-processing", "description": "WP Background Processing can be used to fire off non-blocking asynchronous requests or as a background processing tool, allowing you to queue tasks.", "type": "library", "require": { - "php": ">=5.2" + "php": ">=7.0" }, - "license": "GPL-2.0-only", + "suggest": { + "coenjacobs/mozart": "Easily wrap this library with your own prefix, to prevent collisions when multiple plugins use this library" + }, + "license": "GPL-2.0-or-later", "authors": [ { - "name": "Ashley Rich", - "email": "hello@ashleyrich.com" + "name": "Delicious Brains", + "email": "nom@deliciousbrains.com" } ], "autoload": { - "classmap": [ "classes/" ] + "classmap": [ + "classes/" + ] + }, + "require-dev": { + "phpunit/phpunit": "^8.0", + "yoast/phpunit-polyfills": "^1.0", + "spryker/code-sniffer": "^0.17.18", + "phpcompatibility/phpcompatibility-wp": "*", + "wp-coding-standards/wpcs": "^3" + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } } -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..93063a7 --- /dev/null +++ b/composer.lock @@ -0,0 +1,2360 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "3b2dd684b3ecb0ae7e96c30492e3f4f7", + "packages": [], + "packages-dev": [ + { + "name": "dealerdirect/phpcodesniffer-composer-installer", + "version": "v1.2.1", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/composer-installer.git", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.2", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.1.0 || ^4.0" + }, + "require-dev": { + "composer/composer": "^2.2", + "ext-json": "*", + "ext-zip": "*", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev", + "yoast/phpunit-polyfills": "^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Franck Nijhof", + "email": "opensource@frenck.dev", + "homepage": "https://frenck.dev", + "role": "Open source developer" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer Standards Composer Installer Plugin", + "keywords": [ + "PHPCodeSniffer", + "PHP_CodeSniffer", + "code quality", + "codesniffer", + "composer", + "installer", + "phpcbf", + "phpcs", + "plugin", + "qa", + "quality", + "standard", + "standards", + "style guide", + "stylecheck", + "tests" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/composer-installer/issues", + "security": "https://github.com/PHPCSStandards/composer-installer/security/policy", + "source": "https://github.com/PHPCSStandards/composer-installer" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-05-06T08:26:05+00:00" + }, + { + "name": "doctrine/instantiator", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b", + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^11", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^0.16 || ^1", + "phpstan/phpstan": "^1.4", + "phpstan/phpstan-phpunit": "^1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "vimeo/psalm": "^4.30 || ^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/1.5.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2022-12-30T00:15:36+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpcompatibility/php-compatibility", + "version": "9.3.5", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibility.git", + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/9fb324479acf6f39452e0655d2429cc0d3914243", + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "squizlabs/php_codesniffer": "^2.3 || ^3.0.2" + }, + "conflict": { + "squizlabs/php_codesniffer": "2.6.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.5 || ^5.0 || ^6.0 || ^7.0" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.5 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "homepage": "https://github.com/wimg", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCompatibility/PHPCompatibility/graphs/contributors" + } + ], + "description": "A set of sniffs for PHP_CodeSniffer that checks for PHP cross-version compatibility.", + "homepage": "http://techblog.wimgodden.be/tag/codesniffer/", + "keywords": [ + "compatibility", + "phpcs", + "standards" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibility/issues", + "source": "https://github.com/PHPCompatibility/PHPCompatibility" + }, + "time": "2019-12-27T09:44:58+00:00" + }, + { + "name": "phpcompatibility/phpcompatibility-paragonie", + "version": "1.3.4", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie.git", + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", + "shasum": "" + }, + "require": { + "phpcompatibility/php-compatibility": "^9.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "paragonie/random_compat": "dev-master", + "paragonie/sodium_compat": "dev-master" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "lead" + } + ], + "description": "A set of rulesets for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by the Paragonie polyfill libraries.", + "homepage": "http://phpcompatibility.com/", + "keywords": [ + "compatibility", + "paragonie", + "phpcs", + "polyfill", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/issues", + "security": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/security/policy", + "source": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie" + }, + "funding": [ + { + "url": "https://github.com/PHPCompatibility", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcompatibility", + "type": "thanks_dev" + } + ], + "time": "2025-09-19T17:43:28+00:00" + }, + { + "name": "phpcompatibility/phpcompatibility-wp", + "version": "2.1.8", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibilityWP.git", + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/7c8d18b4d90dac9e86b0869a608fa09158e168fa", + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa", + "shasum": "" + }, + "require": { + "phpcompatibility/php-compatibility": "^9.0", + "phpcompatibility/phpcompatibility-paragonie": "^1.0", + "squizlabs/php_codesniffer": "^3.3" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "lead" + } + ], + "description": "A ruleset for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by WordPress.", + "homepage": "http://phpcompatibility.com/", + "keywords": [ + "compatibility", + "phpcs", + "standards", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibilityWP/issues", + "security": "https://github.com/PHPCompatibility/PHPCompatibilityWP/security/policy", + "source": "https://github.com/PHPCompatibility/PHPCompatibilityWP" + }, + "funding": [ + { + "url": "https://github.com/PHPCompatibility", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcompatibility", + "type": "thanks_dev" + } + ], + "time": "2025-10-18T00:05:59+00:00" + }, + { + "name": "phpcsstandards/phpcsextra", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", + "reference": "b598aa890815b8df16363271b659d73280129101" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/b598aa890815b8df16363271b659d73280129101", + "reference": "b598aa890815b8df16363271b659d73280129101", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "phpcsstandards/phpcsutils": "^1.2.0", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "phpcsstandards/phpcsdevtools": "^1.2.1", + "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSExtra/graphs/contributors" + } + ], + "description": "A collection of sniffs and standards for use with PHP_CodeSniffer.", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHPCSExtra/issues", + "security": "https://github.com/PHPCSStandards/PHPCSExtra/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSExtra" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-12T23:06:57+00:00" + }, + { + "name": "phpcsstandards/phpcsutils", + "version": "1.2.2", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", + "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", + "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", + "shasum": "" + }, + "require": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + }, + "require-dev": { + "ext-filter": "*", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "autoload": { + "classmap": [ + "PHPCSUtils/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSUtils/graphs/contributors" + } + ], + "description": "A suite of utility functions for use with PHP_CodeSniffer", + "homepage": "https://phpcsutils.com/", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "phpcs3", + "phpcs4", + "standards", + "static analysis", + "tokens", + "utility" + ], + "support": { + "docs": "https://phpcsutils.com/", + "issues": "https://github.com/PHPCSStandards/PHPCSUtils/issues", + "security": "https://github.com/PHPCSStandards/PHPCSUtils/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSUtils" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-12-08T14:27:58+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "1.33.0", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "82a311fd3690fb2bf7b64d5c98f912b3dd746140" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/82a311fd3690fb2bf7b64d5c98f912b3dd746140", + "reference": "82a311fd3690fb2bf7b64d5c98f912b3dd746140", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^4.15", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.5", + "phpstan/phpstan-phpunit": "^1.1", + "phpstan/phpstan-strict-rules": "^1.0", + "phpunit/phpunit": "^9.5", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.33.0" + }, + "time": "2024-10-13T11:25:22+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "7.0.17", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "40a4ed114a4aea5afd6df8d0f0c9cd3033097f66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/40a4ed114a4aea5afd6df8d0f0c9cd3033097f66", + "reference": "40a4ed114a4aea5afd6df8d0f0c9cd3033097f66", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-xmlwriter": "*", + "php": ">=7.2", + "phpunit/php-file-iterator": "^2.0.2", + "phpunit/php-text-template": "^1.2.1", + "phpunit/php-token-stream": "^3.1.3 || ^4.0", + "sebastian/code-unit-reverse-lookup": "^1.0.1", + "sebastian/environment": "^4.2.2", + "sebastian/version": "^2.0.1", + "theseer/tokenizer": "^1.1.3" + }, + "require-dev": { + "phpunit/phpunit": "^8.2.2" + }, + "suggest": { + "ext-xdebug": "^2.7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/7.0.17" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:09:37+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "2.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "69deeb8664f611f156a924154985fbd4911eb36b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/69deeb8664f611f156a924154985fbd4911eb36b", + "reference": "69deeb8664f611f156a924154985fbd4911eb36b", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/2.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-01T13:39:50+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/31f8b717e51d9a2afca6c9f046f5d69fc27c8686", + "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/1.2.1" + }, + "time": "2015-06-21T13:50:34+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "2.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "a691211e94ff39a34811abd521c31bd5b305b0bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a691211e94ff39a34811abd521c31bd5b305b0bb", + "reference": "a691211e94ff39a34811abd521c31bd5b305b0bb", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/2.1.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-01T13:42:41+00:00" + }, + { + "name": "phpunit/php-token-stream", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-token-stream.git", + "reference": "a853a0e183b9db7eed023d7933a858fa1c8d25a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/a853a0e183b9db7eed023d7933a858fa1c8d25a3", + "reference": "a853a0e183b9db7eed023d7933a858fa1c8d25a3", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.3 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Wrapper around PHP's tokenizer extension.", + "homepage": "https://github.com/sebastianbergmann/php-token-stream/", + "keywords": [ + "tokenizer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-token-stream/issues", + "source": "https://github.com/sebastianbergmann/php-token-stream/tree/master" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-08-04T08:28:15+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "8.5.52", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "1015741814413c156abb0f53d7db7bbd03c6e858" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/1015741814413c156abb0f53d7db7bbd03c6e858", + "reference": "1015741814413c156abb0f53d7db7bbd03c6e858", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.5.0", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=7.2", + "phpunit/php-code-coverage": "^7.0.17", + "phpunit/php-file-iterator": "^2.0.6", + "phpunit/php-text-template": "^1.2.1", + "phpunit/php-timer": "^2.1.4", + "sebastian/comparator": "^3.0.7", + "sebastian/diff": "^3.0.6", + "sebastian/environment": "^4.2.5", + "sebastian/exporter": "^3.1.8", + "sebastian/global-state": "^3.0.6", + "sebastian/object-enumerator": "^3.0.5", + "sebastian/resource-operations": "^2.0.3", + "sebastian/type": "^1.1.5", + "sebastian/version": "^2.0.1" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage", + "phpunit/php-invoker": "To allow enforcing time limits" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "8.5-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/8.5.52" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-01-27T05:20:18+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "92a1a52e86d34cde6caa54f1b5ffa9fda18e5d54" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/92a1a52e86d34cde6caa54f1b5ffa9fda18e5d54", + "reference": "92a1a52e86d34cde6caa54f1b5ffa9fda18e5d54", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/1.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-01T13:45:45+00:00" + }, + { + "name": "sebastian/comparator", + "version": "3.0.7", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "bc7d8ac2fe1cce229bff9b5fd4efe65918a1ff52" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/bc7d8ac2fe1cce229bff9b5fd4efe65918a1ff52", + "reference": "bc7d8ac2fe1cce229bff9b5fd4efe65918a1ff52", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "sebastian/diff": "^3.0", + "sebastian/exporter": "^3.1" + }, + "require-dev": { + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "source": "https://github.com/sebastianbergmann/comparator/tree/3.0.7" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:20:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "3.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "98ff311ca519c3aa73ccd3de053bdb377171d7b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/98ff311ca519c3aa73ccd3de053bdb377171d7b6", + "reference": "98ff311ca519c3aa73ccd3de053bdb377171d7b6", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8.0", + "symfony/process": "^2 || ^3.3 || ^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "source": "https://github.com/sebastianbergmann/diff/tree/3.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:16:36+00:00" + }, + { + "name": "sebastian/environment", + "version": "4.2.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "56932f6049a0482853056ffd617c91ffcc754205" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/56932f6049a0482853056ffd617c91ffcc754205", + "reference": "56932f6049a0482853056ffd617c91ffcc754205", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.5" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "source": "https://github.com/sebastianbergmann/environment/tree/4.2.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-01T13:49:59+00:00" + }, + { + "name": "sebastian/exporter", + "version": "3.1.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "64cfeaa341951ceb2019d7b98232399d57bb2296" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/64cfeaa341951ceb2019d7b98232399d57bb2296", + "reference": "64cfeaa341951ceb2019d7b98232399d57bb2296", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "sebastian/recursion-context": "^3.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "http://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "source": "https://github.com/sebastianbergmann/exporter/tree/3.1.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T05:55:14+00:00" + }, + { + "name": "sebastian/global-state", + "version": "3.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "800689427e3e8cf57a8fe38fcd1d4344c9b2f046" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/800689427e3e8cf57a8fe38fcd1d4344c9b2f046", + "reference": "800689427e3e8cf57a8fe38fcd1d4344c9b2f046", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "sebastian/object-reflector": "^1.1.1", + "sebastian/recursion-context": "^3.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^8.0" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "source": "https://github.com/sebastianbergmann/global-state/tree/3.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2025-08-10T05:40:12+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "ac5b293dba925751b808e02923399fb44ff0d541" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/ac5b293dba925751b808e02923399fb44ff0d541", + "reference": "ac5b293dba925751b808e02923399fb44ff0d541", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "sebastian/object-reflector": "^1.1.1", + "sebastian/recursion-context": "^3.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/3.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-01T13:54:02+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "1.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "1d439c229e61f244ff1f211e5c99737f90c67def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/1d439c229e61f244ff1f211e5c99737f90c67def", + "reference": "1d439c229e61f244ff1f211e5c99737f90c67def", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/1.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-01T13:56:04+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "8fe7e75986a9d24b4cceae847314035df7703a5a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/8fe7e75986a9d24b4cceae847314035df7703a5a", + "reference": "8fe7e75986a9d24b4cceae847314035df7703a5a", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "http://www.github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-10T05:25:53+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "72a7f7674d053d548003b16ff5a106e7e0e06eee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/72a7f7674d053d548003b16ff5a106e7e0e06eee", + "reference": "72a7f7674d053d548003b16ff5a106e7e0e06eee", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "support": { + "source": "https://github.com/sebastianbergmann/resource-operations/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-01T13:59:09+00:00" + }, + { + "name": "sebastian/type", + "version": "1.1.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "18f071c3a29892b037d35e6b20ddf3ea39b42874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/18f071c3a29892b037d35e6b20ddf3ea39b42874", + "reference": "18f071c3a29892b037d35e6b20ddf3ea39b42874", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "phpunit/phpunit": "^8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/1.1.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-01T14:04:07+00:00" + }, + { + "name": "sebastian/version", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/99732be0ddb3361e16ad77b68ba41efc8e979019", + "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/master" + }, + "time": "2016-10-03T07:35:21+00:00" + }, + { + "name": "slevomat/coding-standard", + "version": "8.15.0", + "source": { + "type": "git", + "url": "https://github.com/slevomat/coding-standard.git", + "reference": "7d1d957421618a3803b593ec31ace470177d7817" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/7d1d957421618a3803b593ec31ace470177d7817", + "reference": "7d1d957421618a3803b593ec31ace470177d7817", + "shasum": "" + }, + "require": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7 || ^1.0", + "php": "^7.2 || ^8.0", + "phpstan/phpdoc-parser": "^1.23.1", + "squizlabs/php_codesniffer": "^3.9.0" + }, + "require-dev": { + "phing/phing": "2.17.4", + "php-parallel-lint/php-parallel-lint": "1.3.2", + "phpstan/phpstan": "1.10.60", + "phpstan/phpstan-deprecation-rules": "1.1.4", + "phpstan/phpstan-phpunit": "1.3.16", + "phpstan/phpstan-strict-rules": "1.5.2", + "phpunit/phpunit": "8.5.21|9.6.8|10.5.11" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-master": "8.x-dev" + } + }, + "autoload": { + "psr-4": { + "SlevomatCodingStandard\\": "SlevomatCodingStandard/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Slevomat Coding Standard for PHP_CodeSniffer complements Consistence Coding Standard by providing sniffs with additional checks.", + "keywords": [ + "dev", + "phpcs" + ], + "support": { + "issues": "https://github.com/slevomat/coding-standard/issues", + "source": "https://github.com/slevomat/coding-standard/tree/8.15.0" + }, + "funding": [ + { + "url": "https://github.com/kukulich", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/slevomat/coding-standard", + "type": "tidelift" + } + ], + "time": "2024-03-09T15:20:58+00:00" + }, + { + "name": "spryker/code-sniffer", + "version": "0.17.31", + "source": { + "type": "git", + "url": "https://github.com/spryker/code-sniffer.git", + "reference": "60ecf65981b5424f62be49d809d3865ef513ed8b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spryker/code-sniffer/zipball/60ecf65981b5424f62be49d809d3865ef513ed8b", + "reference": "60ecf65981b5424f62be49d809d3865ef513ed8b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "phpstan/phpdoc-parser": "^1.33.0", + "slevomat/coding-standard": "^8.14.0", + "squizlabs/php_codesniffer": "^3.6.2" + }, + "require-dev": { + "phpstan/phpstan": "^2.1.0", + "phpunit/phpunit": "^9.5" + }, + "bin": [ + "bin/tokenize" + ], + "type": "phpcodesniffer-standard", + "autoload": { + "psr-4": { + "Spryker\\": "Spryker/", + "SprykerStrict\\": "SprykerStrict/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Spryker", + "homepage": "https://spryker.com" + } + ], + "description": "Spryker Code Sniffer Standards", + "homepage": "https://spryker.com", + "keywords": [ + "codesniffer", + "framework", + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/spryker/code-sniffer/issues", + "source": "https://github.com/spryker/code-sniffer" + }, + "time": "2026-02-20T12:25:44+00:00" + }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.13.5", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-04T16:30:35+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + }, + { + "name": "wp-coding-standards/wpcs", + "version": "3.3.0", + "source": { + "type": "git", + "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", + "reference": "7795ec6fa05663d716a549d0b44e47ffc8b0d4a6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/7795ec6fa05663d716a549d0b44e47ffc8b0d4a6", + "reference": "7795ec6fa05663d716a549d0b44e47ffc8b0d4a6", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "ext-libxml": "*", + "ext-tokenizer": "*", + "ext-xmlreader": "*", + "php": ">=7.2", + "phpcsstandards/phpcsextra": "^1.5.0", + "phpcsstandards/phpcsutils": "^1.1.0", + "squizlabs/php_codesniffer": "^3.13.4" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^10.0.0@dev", + "phpcsstandards/phpcsdevtools": "^1.2.0", + "phpunit/phpunit": "^8.0 || ^9.0" + }, + "suggest": { + "ext-iconv": "For improved results", + "ext-mbstring": "For improved results" + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Contributors", + "homepage": "https://github.com/WordPress/WordPress-Coding-Standards/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer rules (sniffs) to enforce WordPress coding conventions", + "keywords": [ + "phpcs", + "standards", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/WordPress/WordPress-Coding-Standards/issues", + "source": "https://github.com/WordPress/WordPress-Coding-Standards", + "wiki": "https://github.com/WordPress/WordPress-Coding-Standards/wiki" + }, + "funding": [ + { + "url": "https://opencollective.com/php_codesniffer", + "type": "custom" + } + ], + "time": "2025-11-25T12:08:04+00:00" + }, + { + "name": "yoast/phpunit-polyfills", + "version": "1.1.5", + "source": { + "type": "git", + "url": "https://github.com/Yoast/PHPUnit-Polyfills.git", + "reference": "41aaac462fbd80feb8dd129e489f4bbc53fe26b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/41aaac462fbd80feb8dd129e489f4bbc53fe26b0", + "reference": "41aaac462fbd80feb8dd129e489f4bbc53fe26b0", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "phpunit/phpunit": "^4.8.36 || ^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.0" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "yoast/yoastcs": "^3.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.x-dev" + } + }, + "autoload": { + "files": [ + "phpunitpolyfills-autoload.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Team Yoast", + "email": "support@yoast.com", + "homepage": "https://yoast.com" + }, + { + "name": "Contributors", + "homepage": "https://github.com/Yoast/PHPUnit-Polyfills/graphs/contributors" + } + ], + "description": "Set of polyfills for changed PHPUnit functionality to allow for creating PHPUnit cross-version compatible tests", + "homepage": "https://github.com/Yoast/PHPUnit-Polyfills", + "keywords": [ + "phpunit", + "polyfill", + "testing" + ], + "support": { + "issues": "https://github.com/Yoast/PHPUnit-Polyfills/issues", + "security": "https://github.com/Yoast/PHPUnit-Polyfills/security/policy", + "source": "https://github.com/Yoast/PHPUnit-Polyfills" + }, + "time": "2025-08-10T04:54:36+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=7.0" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..d621876 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,15 @@ + + + + + ./tests/ + + + diff --git a/tests/Test_Setup.php b/tests/Test_Setup.php new file mode 100644 index 0000000..cfbaa8e --- /dev/null +++ b/tests/Test_Setup.php @@ -0,0 +1,19 @@ +assertTrue( true ); + } +} diff --git a/tests/Test_WP_Background_Process.php b/tests/Test_WP_Background_Process.php new file mode 100644 index 0000000..81608a2 --- /dev/null +++ b/tests/Test_WP_Background_Process.php @@ -0,0 +1,679 @@ +wpbp = $this->getMockForAbstractClass( WP_Background_Process::class ); + + $this->wpbp->expects( $this->any() ) + ->method( 'task' ) + ->will( $this->returnValue( false ) ); + } + + /** + * Get a property value from WPBP regardless of accessibility. + * + * @param string $name + * + * @return mixed + */ + private function getWPBPProperty( string $name ) { + try { + $property = new ReflectionProperty( 'WP_Background_Process', $name ); + } catch ( Exception $e ) { + return new WP_Error( $e->getCode(), $e->getMessage() ); + } + $property->setAccessible( true ); + + return $property->getValue( $this->wpbp ); + } + + /** + * Set a property value on WPBP regardless of accessibility. + * + * @param string $name + * @param mixed $value + * + * @return mixed + */ + private function setWPBPProperty( string $name, $value ) { + try { + $property = new ReflectionProperty( 'WP_Background_Process', $name ); + } catch ( Exception $e ) { + return new WP_Error( $e->getCode(), $e->getMessage() ); + } + $property->setAccessible( true ); + + return $property->setValue( $this->wpbp, $value ); + } + + /** + * Execute a method of WPBP regardless of accessibility. + * + * @param string $name Method name. + * @param mixed $args None, one or more args to pass to method. + * + * @return mixed + */ + private function executeWPBPMethod( string $name, ...$args ) { + try { + $method = new ReflectionMethod( 'WP_Background_Process', $name ); + $method->setAccessible( true ); + + return $method->invoke( $this->wpbp, ...$args ); + } catch ( Exception $e ) { + return new WP_Error( $e->getCode(), $e->getMessage() ); + } + } + + /** + * Test push_to_queue. + * + * @return void + */ + public function test_push_to_queue() { + $this->assertClassHasAttribute( 'data', 'WP_Background_Process', 'class has data property' ); + $this->assertEmpty( $this->getWPBPProperty( 'data' ) ); + + $this->wpbp->push_to_queue( 'wibble' ); + $this->assertNotEmpty( $this->getWPBPProperty( 'data' ) ); + $this->assertEquals( array( 'wibble' ), $this->getWPBPProperty( 'data' ) ); + + $this->wpbp->push_to_queue( 'wobble' ); + $this->assertEquals( array( 'wibble', 'wobble' ), $this->getWPBPProperty( 'data' ) ); + } + + /** + * Test save. + * + * @return void + */ + public function test_save() { + $this->assertClassHasAttribute( 'data', 'WP_Background_Process', 'class has data property' ); + $this->assertEmpty( $this->getWPBPProperty( 'data' ) ); + $this->assertEmpty( $this->wpbp->get_batches(), 'no batches until save' ); + + $this->wpbp->push_to_queue( 'wibble' ); + $this->assertNotEmpty( $this->getWPBPProperty( 'data' ) ); + $this->assertEquals( array( 'wibble' ), $this->getWPBPProperty( 'data' ) ); + $this->wpbp->save(); + $this->assertEmpty( $this->getWPBPProperty( 'data' ), 'data emptied after save' ); + $this->assertNotEmpty( $this->wpbp->get_batches(), 'batches exist after save' ); + } + + /** + * Test get_batches. + * + * @return void + */ + public function test_get_batches() { + $this->assertEmpty( $this->wpbp->get_batches(), 'no batches until save' ); + + $this->wpbp->push_to_queue( 'wibble' ); + $this->assertNotEmpty( $this->getWPBPProperty( 'data' ) ); + $this->assertEquals( array( 'wibble' ), $this->getWPBPProperty( 'data' ) ); + $this->assertEmpty( $this->wpbp->get_batches(), 'no batches until save' ); + + $this->wpbp->push_to_queue( 'wobble' ); + $this->assertEquals( array( 'wibble', 'wobble' ), $this->getWPBPProperty( 'data' ) ); + $this->assertEmpty( $this->wpbp->get_batches(), 'no batches until save' ); + + $this->wpbp->save(); + $first_batch = $this->wpbp->get_batches(); + $this->assertNotEmpty( $first_batch ); + $this->assertCount( 1, $first_batch ); + + $this->wpbp->push_to_queue( 'more wibble' ); + $this->wpbp->save(); + $this->assertCount( 2, $this->wpbp->get_batches() ); + + $this->wpbp->push_to_queue( 'Wibble wobble all day long.' ); + $this->wpbp->save(); + $this->assertCount( 3, $this->wpbp->get_batches() ); + + $this->assertEquals( $first_batch, $this->wpbp->get_batches( 1 ) ); + $this->assertNotEquals( $first_batch, $this->wpbp->get_batches( 2 ) ); + $this->assertCount( 2, $this->wpbp->get_batches( 2 ) ); + $this->assertCount( 3, $this->wpbp->get_batches( 3 ) ); + $this->assertCount( 3, $this->wpbp->get_batches( 5 ) ); + } + + /** + * Test get_batch. + * + * @return void + */ + public function test_get_batch() { + $this->assertEmpty( $this->executeWPBPMethod( 'get_batch' ), 'no batches until save' ); + + $this->wpbp->push_to_queue( 'wibble' ); + $this->assertNotEmpty( $this->getWPBPProperty( 'data' ) ); + $this->assertEquals( array( 'wibble' ), $this->getWPBPProperty( 'data' ) ); + $this->assertEmpty( $this->executeWPBPMethod( 'get_batch' ), 'no batches until save' ); + + $this->wpbp->push_to_queue( 'wobble' ); + $this->assertEquals( array( 'wibble', 'wobble' ), $this->getWPBPProperty( 'data' ) ); + $this->assertEmpty( $this->executeWPBPMethod( 'get_batch' ), 'no batches until save' ); + + $this->wpbp->save(); + $first_batch = $this->executeWPBPMethod( 'get_batch' ); + $this->assertNotEmpty( $first_batch ); + $this->assertInstanceOf( 'stdClass', $first_batch ); + $this->assertEquals( array( 'wibble', 'wobble' ), $first_batch->data ); + + $this->wpbp->push_to_queue( 'more wibble' ); + $this->wpbp->save(); + $second_batch = $this->executeWPBPMethod( 'get_batch' ); + $this->assertNotEmpty( $second_batch ); + $this->assertInstanceOf( 'stdClass', $second_batch ); + $this->assertEquals( $first_batch, $second_batch, 'same 1st batch returned until deleted' ); + + $this->wpbp->delete( $first_batch->key ); + $second_batch = $this->executeWPBPMethod( 'get_batch' ); + $this->assertNotEmpty( $second_batch ); + $this->assertInstanceOf( 'stdClass', $second_batch ); + $this->assertNotEquals( $first_batch, $second_batch, '2nd batch returned as 1st deleted' ); + $this->assertEquals( array( 'more wibble' ), $second_batch->data ); + + // Tests using a custom class for the $item. + $this->wpbp->delete( $second_batch->key ); + $batch_data_object = new Test_Batch_Data(); + $this->wpbp->push_to_queue( $batch_data_object ); + $this->assertNotEmpty( $this->getWPBPProperty( 'data' ) ); + $this->assertEquals( array( $batch_data_object ), $this->getWPBPProperty( 'data' ) ); + $this->wpbp->save(); + $third_batch = $this->executeWPBPMethod( 'get_batch' ); + $this->assertCount( 1, $third_batch->data ); + $this->assertInstanceOf( Test_Batch_Data::class, $third_batch->data[0] ); + + // Explicitly set allowed classes to Test_Batch_Data. + $this->setWPBPProperty( 'allowed_batch_data_classes', array( Test_Batch_Data::class ) ); + $third_batch = $this->executeWPBPMethod( 'get_batch' ); + $this->assertCount( 1, $third_batch->data ); + $this->assertInstanceOf( Test_Batch_Data::class, $third_batch->data[0] ); + + // Allow a different class. + $this->setWPBPProperty( 'allowed_batch_data_classes', array( stdClass::class ) ); + $third_batch = $this->executeWPBPMethod( 'get_batch' ); + $this->assertCount( 1, $third_batch->data ); + $this->assertInstanceOf( __PHP_Incomplete_Class::class, $third_batch->data[0] ); + + // Disallow all classes. + $this->setWPBPProperty( 'allowed_batch_data_classes', false ); + $third_batch = $this->executeWPBPMethod( 'get_batch' ); + $this->assertCount( 1, $third_batch->data ); + $this->assertInstanceOf( __PHP_Incomplete_Class::class, $third_batch->data[0] ); + + // Allow everything. + $this->setWPBPProperty( 'allowed_batch_data_classes', true ); + $third_batch = $this->executeWPBPMethod( 'get_batch' ); + $this->assertCount( 1, $third_batch->data ); + $this->assertInstanceOf( Test_Batch_Data::class, $third_batch->data[0] ); + } + + /** + * Test cancel. + * + * @return void + */ + public function test_cancel() { + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertFalse( $this->wpbp->is_cancelled() ); + $this->wpbp->cancel(); + $this->assertTrue( $this->wpbp->is_cancelled() ); + } + + /** + * Test pause. + * + * @return void + */ + public function test_pause() { + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertFalse( $this->wpbp->is_paused() ); + $this->wpbp->pause(); + $this->assertTrue( $this->wpbp->is_paused() ); + } + + /** + * Test resume. + * + * @return void + */ + public function test_resume() { + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertFalse( $this->wpbp->is_paused() ); + $this->wpbp->pause(); + $this->assertTrue( $this->wpbp->is_paused() ); + $this->wpbp->resume(); + $this->assertFalse( $this->wpbp->is_paused() ); + } + + /** + * Test delete. + * + * @return void + */ + public function test_delete() { + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertCount( 1, $this->wpbp->get_batches() ); + $this->wpbp->push_to_queue( 'wobble' ); + $this->wpbp->save(); + $this->assertCount( 2, $this->wpbp->get_batches() ); + $first_batch = $this->executeWPBPMethod( 'get_batch' ); + $this->wpbp->delete( $first_batch->key ); + $this->assertCount( 1, $this->wpbp->get_batches() ); + $second_batch = $this->executeWPBPMethod( 'get_batch' ); + $this->assertNotEquals( $first_batch, $second_batch, '2nd batch returned as 1st deleted' ); + } + + /** + * Test delete_all. + * + * @return void + */ + public function test_delete_all() { + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertCount( 1, $this->wpbp->get_batches() ); + $this->wpbp->push_to_queue( 'wobble' ); + $this->wpbp->save(); + $this->assertCount( 2, $this->wpbp->get_batches() ); + $this->wpbp->delete_all(); + $this->assertCount( 0, $this->wpbp->get_batches() ); + } + + /** + * Test update. + * + * @return void + */ + public function test_update() { + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertCount( 1, $this->wpbp->get_batches() ); + $this->wpbp->push_to_queue( 'wobble' ); + $this->wpbp->save(); + $this->assertCount( 2, $this->wpbp->get_batches() ); + $first_batch = $this->executeWPBPMethod( 'get_batch' ); + $this->wpbp->update( $first_batch->key, array( 'Wibble wobble all day long!' ) ); + $this->assertCount( 2, $this->wpbp->get_batches() ); + $updated_batch = $this->executeWPBPMethod( 'get_batch' ); + $this->assertNotEquals( $first_batch, $updated_batch, 'fetched updated batch different to 1st fetch' ); + $this->assertEquals( + array( 'Wibble wobble all day long!' ), + $updated_batch->data, + 'fetched updated batch has expected data' + ); + } + + /** + * Test maybe_handle when cancelling. + * + * @return void + */ + public function test_maybe_handle_cancelled() { + // Cancelled status results in cleared batches and action fired. + $cancelled_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_cancelled', function () use ( &$cancelled_fired ) { + $cancelled_fired = true; + } ); + // Paused action should not be fired though. + $paused_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_paused', function () use ( &$paused_fired ) { + $paused_fired = true; + } ); + // Completed action should not be fired though. + $completed_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_completed', function () use ( &$completed_fired ) { + $completed_fired = true; + } ); + add_filter( $this->getWPBPProperty( 'identifier' ) . '_wp_die', '__return_false' ); + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertCount( 1, $this->wpbp->get_batches() ); + $this->wpbp->push_to_queue( 'wobble' ); + $this->wpbp->save(); + $this->assertCount( 2, $this->wpbp->get_batches() ); + update_site_option( $this->executeWPBPMethod( 'get_status_key' ), WP_Background_Process::STATUS_CANCELLED ); + $this->assertTrue( $this->wpbp->is_cancelled(), 'is_cancelled' ); + $this->assertCount( 2, $this->wpbp->get_batches() ); + $this->assertFalse( $cancelled_fired, 'cancelled action not fired yet' ); + $this->assertFalse( $paused_fired, 'paused action not fired yet' ); + $this->assertFalse( $completed_fired, 'completed action not fired yet' ); + $_REQUEST['nonce'] = wp_create_nonce( $this->getWPBPProperty( 'identifier' ) ); + $this->wpbp->maybe_handle(); + $this->assertCount( 0, $this->wpbp->get_batches() ); + $this->assertTrue( $cancelled_fired, 'cancelled action fired' ); + $this->assertFalse( $paused_fired, 'paused action still not fired yet' ); + $this->assertFalse( $completed_fired, 'completed action not fired yet' ); + } + + /** + * Test maybe_handle when pausing and resuming. + * + * @return void + */ + public function test_maybe_handle_paused_resumed() { + // Cancelled action should not be fired. + $cancelled_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_cancelled', function () use ( &$cancelled_fired ) { + $cancelled_fired = true; + } ); + // Paused action should fire and batches remain intact. + $paused_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_paused', function () use ( &$paused_fired ) { + $paused_fired = true; + } ); + // Resumed action should fire on resume before batches handled. + $resumed_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_resumed', function () use ( &$resumed_fired ) { + $resumed_fired = true; + } ); + // Completed action should fire after batches handled. + $completed_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_completed', function () use ( &$completed_fired ) { + $completed_fired = true; + } ); + add_filter( $this->getWPBPProperty( 'identifier' ) . '_wp_die', '__return_false' ); + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertCount( 1, $this->wpbp->get_batches() ); + $this->wpbp->push_to_queue( 'wobble' ); + $this->wpbp->save(); + $this->assertCount( 2, $this->wpbp->get_batches() ); + $this->wpbp->pause(); + $this->assertTrue( $this->wpbp->is_paused(), 'is_paused' ); + $this->assertCount( 2, $this->wpbp->get_batches() ); + $this->assertFalse( $cancelled_fired, 'cancelled action not fired yet' ); + $this->assertFalse( $paused_fired, 'paused action not fired yet' ); + $this->assertFalse( $resumed_fired, 'resumed action not fired yet' ); + $this->assertFalse( $completed_fired, 'completed action not fired yet' ); + $_REQUEST['nonce'] = wp_create_nonce( $this->getWPBPProperty( 'identifier' ) ); + $this->wpbp->maybe_handle(); + $this->assertCount( 2, $this->wpbp->get_batches() ); + $this->assertFalse( $cancelled_fired, 'cancelled action still not fired yet' ); + $this->assertTrue( $paused_fired, 'paused action fired' ); + $this->assertFalse( $resumed_fired, 'resumed action still not fired yet' ); + $this->assertFalse( $completed_fired, 'completed action not fired yet' ); + + // Reset for resume and ensure dispatch does nothing to that maybe_handle can be monitored. + $paused_fired = false; + add_filter( 'pre_http_request', '__return_true' ); + $this->wpbp->resume(); + remove_filter( 'pre_http_request', '__return_true' ); + $this->assertFalse( $this->wpbp->is_paused(), 'not is_paused after resume' ); + $this->assertCount( 2, $this->wpbp->get_batches() ); + $this->assertFalse( $cancelled_fired, 'cancelled action not fired yet' ); + $this->assertFalse( $paused_fired, 'paused action not fired yet' ); + $this->assertTrue( $resumed_fired, 'resumed action fired' ); + $this->assertFalse( $completed_fired, 'completed action not fired yet' ); + + // Don't expect resumed to be fired again, and batches to be handled with valid security. + $resumed_fired = false; + $this->wpbp->maybe_handle(); + $this->assertCount( 0, $this->wpbp->get_batches(), 'after resume all batches processed with maybe_handle' ); + $this->assertFalse( $cancelled_fired, 'cancelled action still not fired yet' ); + $this->assertFalse( $paused_fired, 'paused action not fired yet' ); + $this->assertFalse( $resumed_fired, 'resumed action still not fired yet' ); + $this->assertTrue( $completed_fired, 'completed action fired' ); + } + + /** + * Test maybe_handle when handling a single batch. + * + * @return void + */ + public function test_maybe_handle_single_batch() { + // Cancelled action should not be fired. + $cancelled_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_cancelled', function () use ( &$cancelled_fired ) { + $cancelled_fired = true; + } ); + // Paused action should not be fired. + $paused_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_paused', function () use ( &$paused_fired ) { + $paused_fired = true; + } ); + // Resumed action should not be fired. + $resumed_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_resumed', function () use ( &$resumed_fired ) { + $resumed_fired = true; + } ); + // Completed action should fire after batches handled. + $completed_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_completed', function () use ( &$completed_fired ) { + $completed_fired = true; + } ); + add_filter( $this->getWPBPProperty( 'identifier' ) . '_wp_die', '__return_false' ); + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertCount( 1, $this->wpbp->get_batches() ); + $this->assertFalse( $cancelled_fired, 'cancelled action not fired yet' ); + $this->assertFalse( $paused_fired, 'paused action not fired yet' ); + $this->assertFalse( $resumed_fired, 'resumed action not fired yet' ); + $this->assertFalse( $completed_fired, 'completed action not fired yet' ); + + $_REQUEST['nonce'] = wp_create_nonce( $this->getWPBPProperty( 'identifier' ) ); + $this->wpbp->maybe_handle(); + $this->assertCount( 0, $this->wpbp->get_batches(), 'after resume all batches processed with maybe_handle' ); + $this->assertFalse( $cancelled_fired, 'cancelled action still not fired yet' ); + $this->assertFalse( $paused_fired, 'paused action not fired yet' ); + $this->assertFalse( $resumed_fired, 'resumed action still not fired yet' ); + $this->assertTrue( $completed_fired, 'completed action fired' ); + } + + /** + * Test maybe_handle when handling nothing. + * + * @return void + */ + public function test_maybe_handle_nothing() { + // Cancelled action should not be fired. + $cancelled_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_cancelled', function () use ( &$cancelled_fired ) { + $cancelled_fired = true; + } ); + // Paused action should not be fired. + $paused_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_paused', function () use ( &$paused_fired ) { + $paused_fired = true; + } ); + // Resumed action should not be fired. + $resumed_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_resumed', function () use ( &$resumed_fired ) { + $resumed_fired = true; + } ); + // Completed action should not be fired. + $completed_fired = false; + add_action( $this->getWPBPProperty( 'identifier' ) . '_completed', function () use ( &$completed_fired ) { + $completed_fired = true; + } ); + add_filter( $this->getWPBPProperty( 'identifier' ) . '_wp_die', '__return_false' ); + $this->assertCount( 0, $this->wpbp->get_batches() ); + $this->assertFalse( $cancelled_fired, 'cancelled action not fired yet' ); + $this->assertFalse( $paused_fired, 'paused action not fired yet' ); + $this->assertFalse( $resumed_fired, 'resumed action not fired yet' ); + $this->assertFalse( $completed_fired, 'completed action not fired yet' ); + + $_REQUEST['nonce'] = wp_create_nonce( $this->getWPBPProperty( 'identifier' ) ); + $this->wpbp->maybe_handle(); + $this->assertCount( 0, $this->wpbp->get_batches(), 'after resume all batches processed with maybe_handle' ); + $this->assertFalse( $cancelled_fired, 'cancelled action still not fired yet' ); + $this->assertFalse( $paused_fired, 'paused action not fired yet' ); + $this->assertFalse( $resumed_fired, 'resumed action still not fired yet' ); + $this->assertFalse( $completed_fired, 'completed action not fired yet' ); + } + + /** + * Test is_processing. + * + * @return void + */ + public function test_is_processing() { + $this->assertFalse( $this->wpbp->is_processing(), 'not processing yet' ); + $this->executeWPBPMethod( 'lock_process' ); + $this->assertTrue( $this->wpbp->is_processing(), 'processing' ); + + // With batches to be processed, maybe_handle does nothing as "another instance is processing". + add_filter( $this->getWPBPProperty( 'identifier' ) . '_wp_die', '__return_false' ); + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertCount( 1, $this->wpbp->get_batches() ); + $_REQUEST['nonce'] = wp_create_nonce( $this->getWPBPProperty( 'identifier' ) ); + $this->wpbp->maybe_handle(); + $this->assertCount( 1, $this->wpbp->get_batches() ); + + // Unlock and maybe_handle can process the batch. + $this->executeWPBPMethod( 'unlock_process' ); + $this->assertFalse( $this->wpbp->is_processing(), 'not processing yet' ); + $this->assertCount( 1, $this->wpbp->get_batches() ); + $this->wpbp->maybe_handle(); + $this->assertCount( 0, $this->wpbp->get_batches() ); + $this->assertFalse( $this->wpbp->is_processing(), 'not left processing on complete' ); + } + + /** + * Test is_queued. + * + * @return void + */ + public function test_is_queued() { + $this->assertFalse( $this->wpbp->is_queued(), 'nothing queued until save' ); + + $this->wpbp->push_to_queue( 'wibble' ); + $this->assertFalse( $this->wpbp->is_queued(), 'nothing queued until save' ); + + $this->wpbp->save(); + $this->assertTrue( $this->wpbp->is_queued(), 'queued items exist' ); + + $this->wpbp->push_to_queue( 'wobble' ); + $this->wpbp->save(); + $this->assertTrue( $this->wpbp->is_queued(), 'queued items exist' ); + + $this->wpbp->delete_all(); + $this->assertFalse( $this->wpbp->is_queued(), 'queue emptied' ); + } + + /** + * Test is_active. + * + * @return void + */ + public function test_is_active() { + $this->assertFalse( $this->wpbp->is_active(), 'not queued, processing, paused or cancelling' ); + + // Queued. + $this->wpbp->push_to_queue( 'wibble' ); + $this->assertFalse( $this->wpbp->is_active(), 'nothing queued until save' ); + + $this->wpbp->save(); + $this->assertTrue( $this->wpbp->is_active(), 'queued items exist, so now active' ); + + $this->wpbp->delete_all(); + $this->assertFalse( $this->wpbp->is_active(), 'queue emptied, so no longer active' ); + + // Processing. + $this->executeWPBPMethod( 'lock_process' ); + $this->assertTrue( $this->wpbp->is_active(), 'processing, so now active' ); + + $this->executeWPBPMethod( 'unlock_process' ); + $this->assertFalse( $this->wpbp->is_active(), 'not processing, so no longer active' ); + + // Paused. + $this->wpbp->pause(); + $this->assertTrue( $this->wpbp->is_active(), 'paused, so now active' ); + + $this->wpbp->resume(); + $this->assertFalse( $this->wpbp->is_active(), 'not paused, nothing queued, so no longer active' ); + + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertTrue( $this->wpbp->is_active(), 'queued items exist, so now active' ); + $this->wpbp->pause(); + $this->assertTrue( $this->wpbp->is_active(), 'paused, so still active' ); + add_filter( 'pre_http_request', '__return_true' ); + $this->wpbp->resume(); + remove_filter( 'pre_http_request', '__return_true' ); + $this->assertTrue( $this->wpbp->is_active(), 'resumed but with queued items, so still active' ); + $this->wpbp->delete_all(); + $this->assertFalse( $this->wpbp->is_active(), 'queue emptied, so no longer active' ); + + // Cancelled. + add_filter( 'pre_http_request', '__return_true' ); + $this->wpbp->cancel(); + remove_filter( 'pre_http_request', '__return_true' ); + $this->assertTrue( $this->wpbp->is_active(), 'cancelling, so now active' ); + + add_filter( $this->getWPBPProperty( 'identifier' ) . '_wp_die', '__return_false' ); + $_REQUEST['nonce'] = wp_create_nonce( $this->getWPBPProperty( 'identifier' ) ); + $this->wpbp->maybe_handle(); + $this->assertFalse( $this->wpbp->is_active(), 'cancel handled, so no longer active' ); + + $this->wpbp->push_to_queue( 'wibble' ); + $this->wpbp->save(); + $this->assertTrue( $this->wpbp->is_active(), 'queued items exist, so now active' ); + add_filter( 'pre_http_request', '__return_true' ); + $this->wpbp->cancel(); + remove_filter( 'pre_http_request', '__return_true' ); + $this->assertTrue( $this->wpbp->is_active(), 'cancelling, so still active' ); + $this->wpbp->maybe_handle(); + $this->assertFalse( $this->wpbp->is_active(), 'cancel handled, queue emptied, so no longer active' ); + } + + /** + * Test get_cron_interval. + * + * @return void + */ + public function test_get_cron_interval() { + // Default value. + $this->assertEquals( 5, $this->wpbp->get_cron_interval() ); + + // Override via property (usually on subclass). + $this->wpbp->cron_interval = 3; + $this->assertEquals( 3, $this->wpbp->get_cron_interval() ); + + // Override via filter. + $callback = function ( $interval ) { + return 1; + }; + add_filter( $this->getWPBPProperty( 'identifier' ) . '_cron_interval', $callback ); + $this->assertEquals( 1, $this->wpbp->get_cron_interval() ); + + remove_filter( $this->getWPBPProperty( 'identifier' ) . '_cron_interval', $callback ); + $this->assertEquals( 3, $this->wpbp->get_cron_interval() ); + + unset( $this->wpbp->cron_interval ); + $this->assertEquals( 5, $this->wpbp->get_cron_interval() ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..94036dc --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,38 @@ +