diff --git a/.distignore b/.distignore index 43bf6bc..10c645b 100644 --- a/.distignore +++ b/.distignore @@ -17,6 +17,7 @@ grumphp.yml phpcs.xml.dist phpunit.xml.dist +wp-tests-config.php psalm.xml.dist # Dependency management diff --git a/.github/workflows/test-php.yml b/.github/workflows/test-php.yml new file mode 100644 index 0000000..973337f --- /dev/null +++ b/.github/workflows/test-php.yml @@ -0,0 +1,75 @@ +name: PHP Tests + +on: + pull_request: + push: + branches: + - main + - develop + +# Cancels all previous workflow runs for pull requests that have not completed. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }} + cancel-in-progress: true + +jobs: + phpunit: + name: PHPUnit (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + # The full range the plugin declares support for. wp-env selects the PHP + # version through WP_ENV_PHP_VERSION, which picks the matching upstream + # `wordpress:php8.x` image. + php: [ '8.1', '8.2', '8.3', '8.4' ] + + env: + # Keep off 8888/8889 for no reason other than matching the documented local setup. + WP_ENV_PORT: 8890 + WP_ENV_TESTS_PORT: 8891 + WP_ENV_PHP_VERSION: ${{ matrix.php }} + + steps: + - name: Checkout project + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install npm dependencies + run: npm ci + + # The plugin calls register_block_type() on its build/ directory. build/ is + # git-ignored, so without this the block never registers and every rendering + # test receives an empty string. + - name: Build block assets + run: npm run build + + - name: Setup Composer + run: | + composer config --global http-basic.composer.beapi.fr ${{ secrets.COMPOSER_USER }} ${{ secrets.COMPOSER_PASS }} + composer config --global http-basic.packages.beapi.fr ${{ secrets.COMPOSER_USER }} ${{ secrets.COMPOSER_PASS }} + + # Installed on the host: the plugin directory is mounted into the wp-env + # containers, so vendor/ is visible from inside them. The platform pin in + # composer.json keeps resolution identical across the matrix. + - name: Install composer dependencies + run: composer install --no-interaction --no-progress + + # Supplies WordPress, the WordPress PHPUnit library and a database, so there + # is no wp-tests-config.php to hand-roll and the suite runs exactly the way + # it does locally. + - name: Start wp-env + run: npx wp-env start + + - name: Run PHPUnit + run: npm run test:php + + - name: Show wp-env logs on failure + if: failure() + run: npx wp-env logs tests --no-watch diff --git a/.gitignore b/.gitignore index e03513b..6bd3701 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,8 @@ build/ .DS_Store .nvmrc phpcs.xml + +# PHPUnit: a local test configuration is per-machine (see tests/wp-tests-config-sample.php) +/wp-tests-config.php +/phpunit.xml +/.phpunit.result.cache diff --git a/CHANGELOG.md b/CHANGELOG.md index 4756984..bd1c62e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ This plugin **doesn't run any sanitization on SVGs** before using them. Only use ## Changelog +### Unreleased + +- Add a PHPUnit integration test suite covering every SVG source (folder, sprite, single file), the collection container, the registration API, front-end block rendering, both REST controllers, the object-cache wrapper and the KSES allowances — 176 tests. See `tests/README.md` +- Run the suite in CI on PHP 8.1 through 8.4 + ### 1.1.1 - 2026-08-26 - Fix deprecated warning for nullable string parameters in `CollectionItem` diff --git a/composer.json b/composer.json index a272305..8ecec0d 100644 --- a/composer.json +++ b/composer.json @@ -59,10 +59,13 @@ "php-parallel-lint/php-parallel-lint": "^1.3", "phpcompatibility/phpcompatibility-wp": "^2.1", "phpro/grumphp-shim": "^1.5", + "phpunit/phpunit": "^9.6", "roave/security-advisories": "dev-latest", "roots/wordpress-no-content": "^6.0", "vimeo/psalm": "^5.20", - "wp-coding-standards/wpcs": "^3.0" + "wp-coding-standards/wpcs": "^3.0", + "wp-phpunit/wp-phpunit": "^6.4", + "yoast/phpunit-polyfills": "^2.0" }, "autoload": { "psr-4": { @@ -72,7 +75,8 @@ "scripts": { "cs": "./vendor/bin/phpcs", "cb": "./vendor/bin/phpcbf", - "psalm": "./vendor/bin/psalm" + "psalm": "./vendor/bin/psalm", + "test": "./vendor/bin/phpunit" }, "scripts-descriptions": { "cs": "Run PHP CodeSniffer on codebase using custom ruleset.", diff --git a/composer.lock b/composer.lock index 382c535..f103e80 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "45a3287a5c944ea32a91f02011a65098", + "content-hash": "6342a808eb901ed9f478b953791a89ae", "packages": [ { "name": "composer/installers", @@ -920,6 +920,76 @@ }, "time": "2026-02-07T07:09:04+00:00" }, + { + "name": "doctrine/instantiator", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^11", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^1.9.4", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5.27", + "vimeo/psalm": "^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/2.0.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:23:10+00:00" + }, { "name": "felixfbecker/advanced-json-rpc", "version": "v3.2.1", @@ -1143,6 +1213,66 @@ }, "time": "2024-04-01T10:36:11+00:00" }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^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.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, { "name": "netresearch/jsonmapper", "version": "v4.5.0", @@ -1338,6 +1468,124 @@ ], "time": "2025-11-27T13:49:59+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": "php-parallel-lint/php-parallel-lint", "version": "v1.4.0", @@ -2206,133 +2454,445 @@ "time": "2026-01-25T14:56:51+00:00" }, { - "name": "psr/cache", - "version": "3.0.0", + "name": "phpunit/php-code-coverage", + "version": "9.2.32", "source": { "type": "git", - "url": "https://github.com/php-fig/cache.git", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", "shasum": "" }, "require": { - "php": ">=8.0.0" + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=7.3", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-text-template": "^2.0.4", + "sebastian/code-unit-reverse-lookup": "^2.0.3", + "sebastian/complexity": "^2.0.3", + "sebastian/environment": "^5.1.5", + "sebastian/lines-of-code": "^1.0.4", + "sebastian/version": "^3.0.2", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-main": "9.2.x-dev" } }, "autoload": { - "psr-4": { - "Psr\\Cache\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Common interface for caching libraries", + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", "keywords": [ - "cache", - "psr", - "psr-6" + "coverage", + "testing", + "xunit" ], "support": { - "source": "https://github.com/php-fig/cache/tree/3.0.0" + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" }, - "time": "2021-02-03T23:26:27+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:23:01+00:00" }, { - "name": "psr/container", - "version": "2.0.2", + "name": "phpunit/php-file-iterator", + "version": "3.0.6", "source": { "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", "shasum": "" }, "require": { - "php": ">=7.4.0" + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "3.0-dev" } }, "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" + "filesystem", + "iterator" ], "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" }, - "time": "2021-11-05T16:47:00+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2021-12-02T12:48:52+00:00" }, { - "name": "psr/event-dispatcher", - "version": "1.0.0", + "name": "phpunit/php-invoker", + "version": "3.1.1", "source": { "type": "git", - "url": "https://github.com/php-fig/event-dispatcher.git", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", "shasum": "" }, "require": { - "php": ">=7.2.0" + "php": ">=7.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-pcntl": "*" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "3.1-dev" } }, "autoload": { - "psr-4": { - "Psr\\EventDispatcher\\": "src/" + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:58:55+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "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", + "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/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T05:33:50+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.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": "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/5.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:16:10+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "9.6.36", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "abab27ed286d3e1246fbbfe6b56bfd732d945ec9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/abab27ed286d3e1246fbbfe6b56bfd732d945ec9", + "reference": "abab27ed286d3e1246fbbfe6b56bfd732d945ec9", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.5.0 || ^2", + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=7.3", + "phpunit/php-code-coverage": "^9.2.32", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-invoker": "^3.1.1", + "phpunit/php-text-template": "^2.0.4", + "phpunit/php-timer": "^5.0.3", + "sebastian/cli-parser": "^1.0.2", + "sebastian/code-unit": "^1.0.8", + "sebastian/comparator": "^4.0.10", + "sebastian/diff": "^4.0.6", + "sebastian/environment": "^5.1.5", + "sebastian/exporter": "^4.0.9", + "sebastian/global-state": "^5.0.8", + "sebastian/object-enumerator": "^4.0.4", + "sebastian/resource-operations": "^3.0.4", + "sebastian/type": "^3.2.1", + "sebastian/version": "^3.0.2" + }, + "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" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.6-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "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/9.6.36" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-08-11T06:25:15+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2342,47 +2902,46 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "homepage": "https://www.php-fig.org/" } ], - "description": "Standard interfaces for event handling.", + "description": "Common interface for caching libraries", "keywords": [ - "events", + "cache", "psr", - "psr-14" + "psr-6" ], "support": { - "issues": "https://github.com/php-fig/event-dispatcher/issues", - "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + "source": "https://github.com/php-fig/cache/tree/3.0.0" }, - "time": "2019-01-08T18:20:26+00:00" + "time": "2021-02-03T23:26:27+00:00" }, { - "name": "psr/log", - "version": "3.0.2", + "name": "psr/container", + "version": "2.0.2", "source": { "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", "shasum": "" }, "require": { - "php": ">=8.0.0" + "php": ">=7.4.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Log\\": "src" + "Psr\\Container\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2395,38 +2954,141 @@ "homepage": "https://www.php-fig.org/" } ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", "keywords": [ - "log", - "psr", - "psr-3" + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" ], "support": { - "source": "https://github.com/php-fig/log/tree/3.0.2" + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" }, - "time": "2024-09-11T13:17:53+00:00" + "time": "2021-11-05T16:47:00+00:00" }, { - "name": "roave/security-advisories", - "version": "dev-latest", + "name": "psr/event-dispatcher", + "version": "1.0.0", "source": { "type": "git", - "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "1476a499c47192447981cb2a9b63f87eca5abc83" + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/1476a499c47192447981cb2a9b63f87eca5abc83", - "reference": "1476a499c47192447981cb2a9b63f87eca5abc83", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", "shasum": "" }, - "conflict": { - "3f/pygmentize": "<1.2", - "adaptcms/adaptcms": "<=1.3", - "admidio/admidio": "<=4.3.16", - "adodb/adodb-php": "<=5.22.9", - "aheinze/cockpit": "<2.2", + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "roave/security-advisories", + "version": "dev-latest", + "source": { + "type": "git", + "url": "https://github.com/Roave/SecurityAdvisories.git", + "reference": "1476a499c47192447981cb2a9b63f87eca5abc83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/1476a499c47192447981cb2a9b63f87eca5abc83", + "reference": "1476a499c47192447981cb2a9b63f87eca5abc83", + "shasum": "" + }, + "conflict": { + "3f/pygmentize": "<1.2", + "adaptcms/adaptcms": "<=1.3", + "admidio/admidio": "<=4.3.16", + "adodb/adodb-php": "<=5.22.9", + "aheinze/cockpit": "<2.2", "aimeos/ai-admin-graphql": ">=2022.04.1,<2022.10.10|>=2023.04.1,<2023.10.6|>=2024.04.1,<2024.07.2", "aimeos/ai-admin-jsonadm": "<2020.10.13|>=2021.04.1,<2021.10.6|>=2022.04.1,<2022.10.3|>=2023.04.1,<2023.10.4|==2024.04.1", "aimeos/ai-client-html": ">=2020.04.1,<2020.10.27|>=2021.04.1,<2021.10.22|>=2022.04.1,<2022.10.13|>=2023.04.1,<2023.10.15|>=2024.04.1,<2024.04.7", @@ -3394,140 +4056,1041 @@ "zfr/zfr-oauth2-server-module": "<0.1.2", "zoujingli/thinkadmin": "<=6.1.53" }, - "default-branch": true, - "type": "metapackage", + "default-branch": true, + "type": "metapackage", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "role": "maintainer" + }, + { + "name": "Ilya Tribusean", + "email": "slash3b@gmail.com", + "role": "maintainer" + } + ], + "description": "Prevents installation of composer packages with known security vulnerabilities: no API, simply require it", + "keywords": [ + "dev" + ], + "support": { + "issues": "https://github.com/Roave/SecurityAdvisories/issues", + "source": "https://github.com/Roave/SecurityAdvisories/tree/latest" + }, + "funding": [ + { + "url": "https://github.com/Ocramius", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/roave/security-advisories", + "type": "tidelift" + } + ], + "time": "2026-02-18T01:37:03+00:00" + }, + { + "name": "roots/wordpress-no-content", + "version": "6.9.1", + "source": { + "type": "git", + "url": "https://github.com/WordPress/WordPress.git", + "reference": "6.9.1" + }, + "dist": { + "type": "zip", + "url": "https://downloads.wordpress.org/release/wordpress-6.9.1-no-content.zip", + "reference": "6.9.1", + "shasum": "27121719e788b7c9e35cfcf8984ad8b42bd423d6" + }, + "require": { + "php": ">= 7.2.24" + }, + "provide": { + "wordpress/core-implementation": "6.9.1" + }, + "suggest": { + "ext-curl": "Performs remote request operations.", + "ext-dom": "Used to validate Text Widget content and to automatically configuring IIS7+.", + "ext-exif": "Works with metadata stored in images.", + "ext-fileinfo": "Used to detect mimetype of file uploads.", + "ext-hash": "Used for hashing, including passwords and update packages.", + "ext-imagick": "Provides better image quality for media uploads.", + "ext-json": "Used for communications with other servers.", + "ext-libsodium": "Validates Signatures and provides securely random bytes.", + "ext-mbstring": "Used to properly handle UTF8 text.", + "ext-mysqli": "Connects to MySQL for database interactions.", + "ext-openssl": "Permits SSL-based connections to other hosts.", + "ext-pcre": "Increases performance of pattern matching in code searches.", + "ext-xml": "Used for XML parsing, such as from a third-party site.", + "ext-zip": "Used for decompressing Plugins, Themes, and WordPress update packages." + }, + "type": "wordpress-core", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "WordPress Community", + "homepage": "https://wordpress.org/about/" + } + ], + "description": "WordPress is open source software you can use to create a beautiful website, blog, or app.", + "homepage": "https://wordpress.org/", + "keywords": [ + "blog", + "cms", + "wordpress" + ], + "support": { + "docs": "https://developer.wordpress.org/", + "forum": "https://wordpress.org/support/", + "irc": "irc://irc.freenode.net/wordpress", + "issues": "https://core.trac.wordpress.org/", + "rss": "https://wordpress.org/news/feed/", + "source": "https://core.trac.wordpress.org/browser", + "wiki": "https://codex.wordpress.org/" + }, + "funding": [ + { + "url": "https://wordpressfoundation.org/donate/", + "type": "other" + } + ], + "time": "2026-02-03T17:39:17+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.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 for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:27:43+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "1.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.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": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:08:54+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "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": "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/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:30:19+00:00" + }, + { + "name": "sebastian/comparator", + "version": "4.0.10", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/diff": "^4.0", + "sebastian/exporter": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "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" + }, + { + "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/4.0.10" + }, + "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:22:56+00:00" + }, + { + "name": "sebastian/complexity", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "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", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:19:30+00:00" + }, + { + "name": "sebastian/diff", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3", + "symfony/process": "^4.2 || ^5" + }, + "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" + }, + { + "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/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:30:58+00:00" + }, + { + "name": "sebastian/environment", + "version": "5.1.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.1-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/5.1.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:03:51+00:00" + }, + { + "name": "sebastian/exporter", + "version": "4.0.9", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "4352c1a3df741a7ba9e61af6fed51d1fee41cbf7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/4352c1a3df741a7ba9e61af6fed51d1fee41cbf7", + "reference": "4352c1a3df741a7ba9e61af6fed51d1fee41cbf7", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "^9.3" + }, + "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" + }, + { + "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": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.9" + }, + "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": "2026-08-11T04:55:59+00:00" + }, + { + "name": "sebastian/global-state", + "version": "5.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.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/5.0.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/global-state", + "type": "tidelift" + } + ], + "time": "2025-08-10T07:10:35+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "1.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.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 for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:20:34+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "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": "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/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:12:34+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "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": "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/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:14:26+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "4.0.7", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "c85be6922b7fd365942b986b9a50397d65407611" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/c85be6922b7fd365942b986b9a50397d65407611", + "reference": "c85be6922b7fd365942b986b9a50397d65407611", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com", - "role": "maintainer" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" }, { - "name": "Ilya Tribusean", - "email": "slash3b@gmail.com", - "role": "maintainer" + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" } ], - "description": "Prevents installation of composer packages with known security vulnerabilities: no API, simply require it", - "keywords": [ - "dev" - ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { - "issues": "https://github.com/Roave/SecurityAdvisories/issues", - "source": "https://github.com/Roave/SecurityAdvisories/tree/latest" + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.7" }, "funding": [ { - "url": "https://github.com/Ocramius", + "url": "https://github.com/sebastianbergmann", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/roave/security-advisories", + "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": "2026-02-18T01:37:03+00:00" + "time": "2026-08-11T05:25:24+00:00" }, { - "name": "roots/wordpress-no-content", - "version": "6.9.1", + "name": "sebastian/resource-operations", + "version": "3.0.4", "source": { "type": "git", - "url": "https://github.com/WordPress/WordPress.git", - "reference": "6.9.1" + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" }, "dist": { "type": "zip", - "url": "https://downloads.wordpress.org/release/wordpress-6.9.1-no-content.zip", - "reference": "6.9.1", - "shasum": "27121719e788b7c9e35cfcf8984ad8b42bd423d6" + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "shasum": "" }, "require": { - "php": ">= 7.2.24" + "php": ">=7.3" }, - "provide": { - "wordpress/core-implementation": "6.9.1" + "require-dev": { + "phpunit/phpunit": "^9.0" }, - "suggest": { - "ext-curl": "Performs remote request operations.", - "ext-dom": "Used to validate Text Widget content and to automatically configuring IIS7+.", - "ext-exif": "Works with metadata stored in images.", - "ext-fileinfo": "Used to detect mimetype of file uploads.", - "ext-hash": "Used for hashing, including passwords and update packages.", - "ext-imagick": "Provides better image quality for media uploads.", - "ext-json": "Used for communications with other servers.", - "ext-libsodium": "Validates Signatures and provides securely random bytes.", - "ext-mbstring": "Used to properly handle UTF8 text.", - "ext-mysqli": "Connects to MySQL for database interactions.", - "ext-openssl": "Permits SSL-based connections to other hosts.", - "ext-pcre": "Increases performance of pattern matching in code searches.", - "ext-xml": "Used for XML parsing, such as from a third-party site.", - "ext-zip": "Used for decompressing Plugins, Themes, and WordPress update packages." + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] }, - "type": "wordpress-core", "notification-url": "https://packagist.org/downloads/", "license": [ - "GPL-2.0-or-later" + "BSD-3-Clause" ], "authors": [ { - "name": "WordPress Community", - "homepage": "https://wordpress.org/about/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" } ], - "description": "WordPress is open source software you can use to create a beautiful website, blog, or app.", - "homepage": "https://wordpress.org/", - "keywords": [ - "blog", - "cms", - "wordpress" - ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", "support": { - "docs": "https://developer.wordpress.org/", - "forum": "https://wordpress.org/support/", - "irc": "irc://irc.freenode.net/wordpress", - "issues": "https://core.trac.wordpress.org/", - "rss": "https://wordpress.org/news/feed/", - "source": "https://core.trac.wordpress.org/browser", - "wiki": "https://codex.wordpress.org/" + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" }, "funding": [ { - "url": "https://wordpressfoundation.org/donate/", - "type": "other" + "url": "https://github.com/sebastianbergmann", + "type": "github" } ], - "time": "2026-02-03T17:39:17+00:00" + "time": "2024-03-14T16:00:52+00:00" }, { - "name": "sebastian/diff", - "version": "5.1.1", + "name": "sebastian/type", + "version": "3.2.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", - "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^10.0", - "symfony/process": "^6.4" + "phpunit/phpunit": "^9.5" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.1-dev" + "dev-master": "3.2-dev" } }, "autoload": { @@ -3542,25 +5105,68 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, + "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/3.2.1" + }, + "funding": [ { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" + "url": "https://github.com/sebastianbergmann", + "type": "github" } ], - "description": "Diff implementation", - "homepage": "https://github.com/sebastianbergmann/diff", - "keywords": [ - "diff", - "udiff", - "unidiff", - "unified diff" + "time": "2023-02-03T06:13:03+00:00" + }, + { + "name": "sebastian/version", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c6c1022351a901512170118436c764e473f6de8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", + "reference": "c6c1022351a901512170118436c764e473f6de8c", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "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", + "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/diff/issues", - "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" }, "funding": [ { @@ -3568,7 +5174,7 @@ "type": "github" } ], - "time": "2024-03-02T07:15:17+00:00" + "time": "2020-09-28T06:39:44+00:00" }, { "name": "spatie/array-to-xml", @@ -5160,6 +6766,56 @@ ], "time": "2025-12-02T11:50:18+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": "vimeo/psalm", "version": "5.26.1", @@ -5475,6 +7131,117 @@ } ], "time": "2025-12-03T22:11:59+00:00" + }, + { + "name": "wp-phpunit/wp-phpunit", + "version": "6.9.7", + "source": { + "type": "git", + "url": "https://github.com/wp-phpunit/wp-phpunit.git", + "reference": "15fd216bf6516670d8d07b938675925bfa5c15b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wp-phpunit/wp-phpunit/zipball/15fd216bf6516670d8d07b938675925bfa5c15b0", + "reference": "15fd216bf6516670d8d07b938675925bfa5c15b0", + "shasum": "" + }, + "type": "library", + "autoload": { + "files": [ + "__loaded.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "Evan Mattson", + "email": "me@aaemnnost.tv" + }, + { + "name": "WordPress Community", + "homepage": "https://wordpress.org/about/" + } + ], + "description": "WordPress core PHPUnit library", + "homepage": "https://github.com/wp-phpunit", + "keywords": [ + "phpunit", + "test", + "wordpress" + ], + "support": { + "docs": "https://github.com/wp-phpunit/docs", + "issues": "https://github.com/wp-phpunit/issues", + "source": "https://github.com/wp-phpunit/wp-phpunit" + }, + "time": "2026-02-04T01:48:23+00:00" + }, + { + "name": "yoast/phpunit-polyfills", + "version": "2.0.5", + "source": { + "type": "git", + "url": "https://github.com/Yoast/PHPUnit-Polyfills.git", + "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27", + "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27", + "shasum": "" + }, + "require": { + "php": ">=5.6", + "phpunit/phpunit": "^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.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-10T05:13:49+00:00" } ], "aliases": [], @@ -5492,5 +7259,5 @@ "platform-overrides": { "php": "8.1" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/package.json b/package.json index 0c18322..bd0052c 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "make-pot": "wp i18n make-pot . languages/blockparty-icons.pot --exclude=\"src\" --domain=blockparty-icons", "make-json": "wp i18n make-json languages/blockparty-icons-fr_FR.po languages/ --no-purge", "env:start": "wp-env start --config=./.wp-env.json", - "env:stop": "wp-env stop --config=./.wp-env.json" + "env:stop": "wp-env stop --config=./.wp-env.json", + "test:php": "bash tests/bin/phpunit.sh" }, "devDependencies": { "@wordpress/env": "^10.39.0", diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..2a24f1a --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,28 @@ + + + + + ./tests/phpunit + + + + + + ./includes + ./blockparty-icons.php + + + ./includes/Cli + + + diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..8f0464c --- /dev/null +++ b/tests/README.md @@ -0,0 +1,111 @@ +# Test suite + +Integration tests against a real WordPress install — not isolated unit tests. The +plugin leans on `WP_Query`, the object cache, `WP_HTML_Tag_Processor`, +`WP_REST_Controller` and a set of filters; mocking that surface would test the +mocks rather than the plugin. + +## Running + +The block assets must be compiled first, whichever way you run the suite: the +plugin registers its block from `build/block.json`, and without it nothing renders. +`build/` is git-ignored, so a fresh clone needs: + +```bash +npm ci && npm run build +``` + +The bootstrap checks for it and says so rather than letting two dozen rendering +tests fail for the wrong reason. + +Through wp-env, which supplies WordPress, the test library and a database: + +```bash +WP_ENV_PORT=8890 WP_ENV_TESTS_PORT=8891 npx wp-env start +npm run test:php +``` + +This is also what CI runs, so a green run locally means the same thing as a green +run on the pull request. + +Without Docker, you can run against the Composer copy of WordPress instead, and +supply the database yourself: + +```bash +cp tests/wp-tests-config-sample.php wp-tests-config.php +# edit the credentials, pointing at a throwaway database +composer test +``` + +Arguments pass straight through: + +```bash +npm run test:php -- --filter BlockRendererTest +composer test -- --filter test_search +``` + +To exercise a specific PHP version, set `WP_ENV_PHP_VERSION` — this is how the CI +matrix covers 8.1 through 8.4: + +```bash +WP_ENV_PHP_VERSION=8.1 npx wp-env start --update +npm run test:php +``` + +> The suite **drops and recreates** the tables in the configured database. Never +> point it at anything you want to keep. + +## What is covered + +| file | subject | +|---|---| +| `Icon/CollectionItemsFactoryTest.php` | every SVG source: folder, sprite, single file | +| `Icon/CollectionTest.php` | the collection container: lookup, count, search, named constructors | +| `Icon/CollectionItemTest.php` | a single icon, and what survives the object cache | +| `BlockRendererTest.php` | front-end markup: raw and sprite icons, size, radius, link, colour, escaping | +| `RegistrationTest.php` | the public API a theme calls, plus editor preload paths | +| `Rest/CollectionsControllerTest.php` | collection discovery endpoint and its permissions | +| `Rest/IconsControllerTest.php` | icon listing, pagination, search, single item, permissions | +| `Helpers/CacheTest.php` | the object-cache wrapper every source goes through | +| `Helpers/KsesTest.php` | the KSES allowances that keep saved block markup valid | + +### These are characterization tests + +They describe **observable behaviour** — how many icons come back, what their +names, labels, types and contents are, what markup reaches a page — and say +nothing about *when* or *how often* files are read. + +That is deliberate. It means the suite keeps its meaning across a refactor of how +loading and caching work: if a change alters what a caller can observe, a test +fails; if it only alters the mechanism, the suite stays green. + +## Known gaps + +- **Development-mode cache bypass.** WordPress resolves `wp_is_development_mode()` + from the `WP_DEVELOPMENT_MODE` constant behind a static cache, and only lets its + own core suite vary it. The suite therefore runs with caching active, which is the + production path anyway. See the docblock in `Helpers/CacheTest.php`. +- **`Collection`'s Iterator.** It is broken — positions are integers, keys are + names, so `foreach` over a populated collection yields nothing. Nothing in the + plugin iterates a collection, so this is latent. It is recorded by + `test_known_defect_iteration_yields_nothing_even_when_populated`, which will fail + when someone fixes it. Update the test then; do not delete it. +- **The WP-CLI migration command** (`includes/Cli`, `includes/Migration`) has no + coverage and is excluded from the coverage report. +- **JavaScript.** The editor sources under `src/` are not covered here. + +## Fixtures + +`TestCase` builds fixtures on demand and removes them afterwards: + +- `make_icon_folder( [ 'name.svg' => $contents ] )` — a folder of SVGs +- `make_file( $name, $contents )` — a single file +- `svg( $marker )` — a minimal valid SVG, with a marker so icons can be told apart +- `sprite( [ $id, … ] )` — a sprite containing those symbol ids + +They live under `WP_CONTENT_DIR` on purpose: `CollectionItemsFactory` derives a +sprite's public URL by swapping `WP_CONTENT_DIR` for `WP_CONTENT_URL`, so fixtures +placed anywhere else would make that path untestable. + +`TestCase::set_up()` also empties the collections global and flushes the object +cache, so no test can be influenced by another. diff --git a/tests/bin/phpunit.sh b/tests/bin/phpunit.sh new file mode 100755 index 0000000..8accc03 --- /dev/null +++ b/tests/bin/phpunit.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# +# Run the PHPUnit suite inside the wp-env tests container. +# +# tests/bin/phpunit.sh [phpunit arguments...] +# +# The suite needs a real WordPress install, which wp-env's `tests-wordpress` +# service provides along with the WordPress PHPUnit library at /wordpress-phpunit. +# +# wp-env's own `run` subcommand cannot be pointed at a specific environment, so we +# resolve the container from the port it publishes and talk to it with docker exec. +# +# Nothing below exports WP_TESTS_DIR, and it does not need to: wp-env sets it to +# /wordpress-phpunit on the service itself, and `docker exec` inherits the +# container's environment. That variable is what makes tests/bootstrap.php take its +# "managed environment" branch and use wp-env's WordPress, test library and +# database — rather than falling back to the Composer copy and a wp-tests-config.php +# in the project root. If you ever see the suite reaching for a root config while +# running through this script, that variable is what went missing. +# +set -euo pipefail + +ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/../.." && pwd )" +PORT="${WP_ENV_TESTS_PORT:-8891}" + +cd "$ROOT" + +CONTAINER="$( docker ps \ + --filter "publish=${PORT}" \ + --filter "name=-tests-wordpress-1" \ + --format '{{.Names}}' | head -n1 )" + +if [ -z "$CONTAINER" ]; then + cat >&2 <folder = $this->make_icon_folder( + [ + 'alpha.svg' => '', + ] + ); + $this->sprite = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-star' ] ) ); + + register_icon_collection( + 'raw-icons', + [ + 'type' => 'folder', + 'source' => $this->folder, + ] + ); + register_icon_collection( + 'sprite-icons', + [ + 'type' => 'sprite', + 'source' => $this->sprite, + ] + ); + } + + /** + * Render the block the way WordPress does. + * + * Going through render_block() rather than calling BlockRenderer::render() + * directly matters: the renderer calls get_block_wrapper_attributes(), which + * reads WP_Block_Supports::$block_to_render. Outside a real render pass that is + * null, and PHP 8.4 warns on it — a bug in the test, not the plugin. + * + * @param array $attributes + * + * @return string + */ + private function render( array $attributes ): string { + return render_block( + [ + 'blockName' => 'blockparty/icon', + 'attrs' => $attributes, + 'innerBlocks' => [], + 'innerHTML' => '', + 'innerContent' => [], + ] + ); + } + + /** + * @param string $collection + * @param string $name + * + * @return array + */ + private function icon( string $collection = 'raw-icons', string $name = 'alpha' ): array { + return [ + 'collection' => $collection, + 'name' => $name, + ]; + } + + /* ------------------------------------------------------- missing input */ + + public function test_no_icon_attribute_renders_a_comment(): void { + $this->assertSame( '', $this->render( [] ) ); + } + + public function test_missing_collection_renders_a_comment(): void { + $html = $this->render( [ 'icon' => [ 'name' => 'alpha' ] ] ); + + $this->assertSame( '', $html ); + } + + public function test_missing_icon_name_renders_a_comment(): void { + $html = $this->render( [ 'icon' => [ 'collection' => 'raw-icons' ] ] ); + + $this->assertSame( '', $html ); + } + + public function test_unknown_collection_renders_a_comment(): void { + $html = $this->render( [ 'icon' => $this->icon( 'nope', 'alpha' ) ] ); + + $this->assertSame( '', $html ); + } + + public function test_unknown_icon_renders_a_comment(): void { + $html = $this->render( [ 'icon' => $this->icon( 'raw-icons', 'nope' ) ] ); + + $this->assertSame( '', $html ); + } + + /* ----------------------------------------------------------- raw icons */ + + public function test_raw_icon_inlines_the_svg(): void { + $html = $this->render( [ 'icon' => $this->icon() ] ); + + $this->assertStringContainsString( 'assertStringContainsString( 'assertStringContainsString( 'viewBox="0 0 24 24"', $html ); + } + + public function test_raw_icon_carries_component_classes(): void { + $html = $this->render( [ 'icon' => $this->icon() ] ); + + $this->assertStringContainsString( 'wp-block-blockparty-icons__icon-component', $html ); + $this->assertStringContainsString( 'wp-block-blockparty-icons__icon-component--raw', $html ); + $this->assertStringContainsString( 'alpha', $html, 'The icon name is used as a class.' ); + } + + public function test_raw_icon_is_wrapped_in_the_block_and_a_container_span(): void { + $html = $this->render( [ 'icon' => $this->icon() ] ); + + $this->assertStringContainsString( 'wp-block-blockparty-icon__icon-container', $html ); + $this->assertStringStartsWith( '
assertStringEndsWith( '
', $html ); + } + + public function test_size_becomes_width_and_height(): void { + $html = $this->render( + [ + 'icon' => $this->icon(), + 'size' => 64, + ] + ); + + $this->assertStringContainsString( 'width:64px', $html ); + $this->assertStringContainsString( 'height:64px', $html ); + } + + public function test_size_defaults_to_24(): void { + $html = $this->render( [ 'icon' => $this->icon() ] ); + + $this->assertStringContainsString( 'width:24px', $html ); + $this->assertStringContainsString( 'height:24px', $html ); + } + + public function test_border_radius_is_applied_to_the_wrapper(): void { + $html = $this->render( + [ + 'icon' => $this->icon(), + 'borderRadius' => '12px', + ] + ); + + $this->assertStringContainsString( 'border-radius: 12px', $html ); + } + + /* -------------------------------------------------------- sprite icons */ + + public function test_sprite_icon_references_the_symbol_by_url(): void { + $html = $this->render( [ 'icon' => $this->icon( 'sprite-icons', 'ico-star' ) ] ); + + $this->assertStringContainsString( 'assertStringContainsString( 'focusable="false"', $html ); + } + + /* ---------------------------------------------------------------- link */ + + public function test_url_wraps_the_icon_in_a_link(): void { + $html = $this->render( + [ + 'icon' => $this->icon(), + 'url' => 'https://example.org/somewhere', + ] + ); + + $this->assertStringContainsString( 'assertStringContainsString( 'href="https://example.org/somewhere"', $html ); + $this->assertStringContainsString( '', $html ); + } + + public function test_label_becomes_the_link_aria_label(): void { + $html = $this->render( + [ + 'icon' => $this->icon(), + 'url' => 'https://example.org', + 'label' => 'Go somewhere', + ] + ); + + $this->assertStringContainsString( 'aria-label="Go somewhere"', $html ); + } + + public function test_label_without_a_url_produces_no_link(): void { + $html = $this->render( + [ + 'icon' => $this->icon(), + 'label' => 'Orphan label', + ] + ); + + $this->assertStringNotContainsString( 'assertStringNotContainsString( 'aria-label', $html ); + } + + public function test_link_without_a_label_has_no_aria_label(): void { + $html = $this->render( + [ + 'icon' => $this->icon(), + 'url' => 'https://example.org', + ] + ); + + $this->assertStringContainsString( 'assertStringNotContainsString( 'aria-label', $html ); + } + + /* --------------------------------------------------------------- color */ + + /** + * @dataProvider provide_accepted_colors + */ + public function test_accepted_icon_colors( string $input, string $expected ): void { + $html = $this->render( + [ + 'icon' => $this->icon(), + 'iconColor' => $input, + ] + ); + + $this->assertStringContainsString( 'color:' . $expected, $html ); + $this->assertStringContainsString( 'fill:' . $expected, $html ); + } + + public function provide_accepted_colors(): array { + return [ + 'hex' => [ '#ff0000', '#ff0000' ], + 'short hex' => [ '#f00', '#f00' ], + 'editor preset' => [ 'var:preset|color|primary', 'var(--wp--preset--color--primary)' ], + 'css variable' => [ 'var(--brand-accent)', 'var(--brand-accent)' ], + 'variable w/ fallback' => [ 'var(--brand-accent, #123456)', 'var(--brand-accent, #123456)' ], + ]; + } + + /** + * @dataProvider provide_rejected_colors + */ + public function test_rejected_icon_colors( string $input ): void { + $html = $this->render( + [ + 'icon' => $this->icon(), + 'iconColor' => $input, + ] + ); + + $this->assertStringNotContainsString( 'color:', $html ); + $this->assertStringNotContainsString( 'fill:', $html ); + } + + public function provide_rejected_colors(): array { + return [ + 'empty' => [ '' ], + 'named color' => [ 'red' ], + 'rgb function' => [ 'rgb(255, 0, 0)' ], + 'javascript url' => [ 'javascript:alert(1)' ], + 'expression' => [ 'expression(alert(1))' ], + 'quote break' => [ '#fff;" onload="alert(1)' ], + ]; + } + + public function test_no_color_leaves_the_svg_fill_untouched(): void { + $html = $this->render( [ 'icon' => $this->icon() ] ); + + $this->assertStringNotContainsString( 'color:', $html ); + } + + /* ------------------------------------------------------------ escaping */ + + public function test_a_malicious_url_is_escaped(): void { + $html = $this->render( + [ + 'icon' => $this->icon(), + 'url' => 'javascript:alert(1)', + ] + ); + + $this->assertStringNotContainsString( 'href="javascript:alert(1)"', $html ); + } + + public function test_a_malicious_label_is_escaped(): void { + $html = $this->render( + [ + 'icon' => $this->icon(), + 'url' => 'https://example.org', + 'label' => '" onmouseover="alert(1)', + ] + ); + + $this->assertStringNotContainsString( 'onmouseover="alert(1)"', $html ); + } +} diff --git a/tests/phpunit/Helpers/CacheTest.php b/tests/phpunit/Helpers/CacheTest.php new file mode 100644 index 0000000..8321a72 --- /dev/null +++ b/tests/phpunit/Helpers/CacheTest.php @@ -0,0 +1,79 @@ +assertSame( [ 'a', 'b' ], Cache::get_cache( 'key', self::GROUP, 'salt' ) ); + } + + public function test_a_missing_key_returns_false(): void { + $this->assertFalse( Cache::get_cache( 'never-written', self::GROUP, 'salt' ) ); + } + + public function test_a_different_salt_misses(): void { + Cache::set_cache( 'key', 'value', self::GROUP, 'salt-one' ); + + $this->assertFalse( + Cache::get_cache( 'key', self::GROUP, 'salt-two' ), + 'A changed salt must behave as a miss, never serve the stale value.' + ); + } + + public function test_a_different_group_misses(): void { + Cache::set_cache( 'key', 'value', self::GROUP, 'salt' ); + + $this->assertFalse( Cache::get_cache( 'key', 'another-group', 'salt' ) ); + } + + public function test_salts_can_be_given_as_an_array(): void { + Cache::set_cache( 'key', 'value', self::GROUP, [ 'one', 'two' ] ); + + $this->assertSame( 'value', Cache::get_cache( 'key', self::GROUP, [ 'one', 'two' ] ) ); + } + + public function test_an_array_salt_is_order_sensitive(): void { + Cache::set_cache( 'key', 'value', self::GROUP, [ 'one', 'two' ] ); + + $this->assertFalse( Cache::get_cache( 'key', self::GROUP, [ 'two', 'one' ] ) ); + } + + public function test_an_empty_array_round_trips_as_a_hit(): void { + Cache::set_cache( 'key', [], self::GROUP, 'salt' ); + + $this->assertSame( + [], + Cache::get_cache( 'key', self::GROUP, 'salt' ), + 'An empty result is a real answer and must not be mistaken for a miss.' + ); + } + + public function test_a_later_write_replaces_the_earlier_one(): void { + Cache::set_cache( 'key', 'first', self::GROUP, 'salt' ); + Cache::set_cache( 'key', 'second', self::GROUP, 'salt' ); + + $this->assertSame( 'second', Cache::get_cache( 'key', self::GROUP, 'salt' ) ); + } +} diff --git a/tests/phpunit/Helpers/KsesTest.php b/tests/phpunit/Helpers/KsesTest.php new file mode 100644 index 0000000..dc6b6b5 --- /dev/null +++ b/tests/phpunit/Helpers/KsesTest.php @@ -0,0 +1,173 @@ +allowed_post_tags(); + + $this->assertArrayHasKey( 'svg', $tags ); + } + + /** + * @dataProvider provide_required_svg_attributes + */ + public function test_required_svg_attributes_are_allowed( string $attribute ): void { + $tags = $this->allowed_post_tags(); + + $this->assertArrayHasKey( $attribute, $tags['svg'] ); + } + + public function provide_required_svg_attributes(): array { + return array_map( + static fn( $a ) => [ $a ], + [ 'aria-hidden', 'class', 'fill', 'focusable', 'height', 'style', 'viewbox', 'width', 'version', 'xmlns' ] + ); + } + + public function test_path_and_its_geometry_are_allowed(): void { + $tags = $this->allowed_post_tags(); + + $this->assertArrayHasKey( 'path', $tags ); + $this->assertArrayHasKey( 'd', $tags['path'] ); + $this->assertArrayHasKey( 'fill', $tags['path'] ); + $this->assertArrayHasKey( 'transform', $tags['path'] ); + } + + public function test_use_and_its_references_are_allowed(): void { + $tags = $this->allowed_post_tags(); + + $this->assertArrayHasKey( 'use', $tags ); + $this->assertArrayHasKey( 'href', $tags['use'] ); + $this->assertArrayHasKey( 'xlink:href', $tags['use'] ); + } + + public function test_other_contexts_are_left_alone(): void { + $this->assertArrayNotHasKey( + 'svg', + wp_kses_allowed_html( 'strip' ), + 'The allowance is scoped to post content.' + ); + } + + public function test_a_rendered_icon_survives_kses(): void { + $html = ''; + + $filtered = wp_kses_post( $html ); + + $this->assertStringContainsString( 'assertStringContainsString( 'd="M0 0h24v24H0z"', $filtered ); + $this->assertStringContainsString( 'viewbox="0 0 24 24"', $filtered ); + $this->assertStringContainsString( 'aria-hidden="true"', $filtered ); + } + + public function test_a_sprite_reference_survives_kses(): void { + $html = ''; + + $filtered = wp_kses_post( $html ); + + $this->assertStringContainsString( 'href="https://example.org/sprite.svg#star"', $filtered ); + } + + public function test_a_script_inside_an_svg_is_still_stripped(): void { + $html = ''; + + $filtered = wp_kses_post( $html ); + + $this->assertStringNotContainsString( 'assertStringContainsString( 'd="M0 0"', $filtered ); + } + + /* ------------------------------------------------------- safe_style_css */ + + public function test_display_is_an_allowed_style_property(): void { + $this->assertContains( 'display', apply_filters( 'safe_style_css', [] ) ); + } + + public function test_a_display_declaration_survives_kses(): void { + $filtered = wp_kses_post( '' ); + + $this->assertStringContainsString( 'display:block', $filtered ); + } + + /* ------------------------------------------------------------- SvgKses */ + + public function test_svg_kses_lists_the_root_and_basic_shapes(): void { + $allowed = SvgKses::get_allowed_svg_kses(); + + foreach ( [ 'svg', 'path', 'circle', 'ellipse', 'line', 'rect', 'polygon', 'polyline' ] as $tag ) { + $this->assertArrayHasKey( $tag, $allowed ); + } + } + + public function test_svg_kses_lists_structure_gradients_and_masks(): void { + $allowed = SvgKses::get_allowed_svg_kses(); + + foreach ( [ 'g', 'defs', 'symbol', 'use', 'linearGradient', 'radialGradient', 'stop', 'clipPath', 'mask' ] as $tag ) { + $this->assertArrayHasKey( $tag, $allowed ); + } + } + + /** + * @dataProvider provide_dangerous_svg_elements + */ + public function test_svg_kses_excludes_dangerous_elements( string $tag ): void { + $this->assertArrayNotHasKey( $tag, SvgKses::get_allowed_svg_kses() ); + } + + public function provide_dangerous_svg_elements(): array { + return array_map( + static fn( $t ) => [ $t ], + [ 'script', 'foreignObject', 'iframe', 'image', 'animate', 'set', 'handler' ] + ); + } + + public function test_svg_kses_allows_no_event_handlers(): void { + foreach ( SvgKses::get_allowed_svg_kses() as $tag => $attributes ) { + foreach ( array_keys( $attributes ) as $attribute ) { + $this->assertStringStartsNotWith( + 'on', + strtolower( (string) $attribute ), + "Tag <{$tag}> must not allow the event attribute {$attribute}." + ); + } + } + } + + public function test_svg_kses_is_filterable(): void { + add_filter( + 'blockparty_icons_allowed_svg_kses', + static function ( array $allowed ) { + $allowed['marker'] = [ 'id' => true ]; + + return $allowed; + } + ); + + $this->assertArrayHasKey( 'marker', SvgKses::get_allowed_svg_kses() ); + } +} diff --git a/tests/phpunit/Icon/CollectionItemTest.php b/tests/phpunit/Icon/CollectionItemTest.php new file mode 100644 index 0000000..f34c0a6 --- /dev/null +++ b/tests/phpunit/Icon/CollectionItemTest.php @@ -0,0 +1,88 @@ +', 'A star', '1.2.3' ); + + $this->assertSame( 'star', $item->name() ); + $this->assertSame( 'raw', $item->type() ); + $this->assertSame( '', $item->content() ); + $this->assertSame( 'A star', $item->label() ); + $this->assertSame( '1.2.3', $item->version() ); + } + + public function test_the_label_defaults_to_the_name(): void { + $item = new CollectionItem( 'star', 'raw', '' ); + + $this->assertSame( 'star', $item->label() ); + } + + public function test_the_version_is_null_by_default(): void { + $item = new CollectionItem( 'star', 'raw', '' ); + + $this->assertNull( $item->version() ); + } + + public function test_a_null_label_falls_back_to_the_name(): void { + $item = new CollectionItem( 'star', 'raw', '', null ); + + $this->assertSame( 'star', $item->label() ); + } + + public function test_empty_content_is_allowed(): void { + $item = new CollectionItem( 'star', 'raw', '' ); + + $this->assertSame( '', $item->content() ); + } + + public function test_content_is_returned_verbatim(): void { + $svg = ''; + + $item = new CollectionItem( 'star', 'raw', $svg ); + + $this->assertSame( + $svg, + $item->content(), + 'The plugin does not sanitize SVGs; content must pass through untouched.' + ); + } + + public function test_it_survives_a_serialization_round_trip(): void { + $item = new CollectionItem( 'star', 'sprite', 'https://example.org/s.svg#star', 'A star', '2.0' ); + + // Serializing is the point of the test: this is how an item reaches the + // object cache and comes back. + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize,WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize + $restored = unserialize( serialize( $item ) ); + + $this->assertInstanceOf( CollectionItem::class, $restored ); + $this->assertSame( 'star', $restored->name() ); + $this->assertSame( 'sprite', $restored->type() ); + $this->assertSame( 'https://example.org/s.svg#star', $restored->content() ); + $this->assertSame( 'A star', $restored->label() ); + $this->assertSame( '2.0', $restored->version() ); + } + + public function test_it_survives_a_trip_through_the_object_cache(): void { + $item = new CollectionItem( 'star', 'raw', $this->svg( 'MARKER' ), 'A star' ); + + wp_cache_set( 'item', [ $item ], 'blockparty-icons-test' ); + $restored = wp_cache_get( 'item', 'blockparty-icons-test' ); + + $this->assertSame( 'star', $restored[0]->name() ); + $this->assertSame( $this->svg( 'MARKER' ), $restored[0]->content() ); + } +} diff --git a/tests/phpunit/Icon/CollectionItemsFactoryTest.php b/tests/phpunit/Icon/CollectionItemsFactoryTest.php new file mode 100644 index 0000000..07ce069 --- /dev/null +++ b/tests/phpunit/Icon/CollectionItemsFactoryTest.php @@ -0,0 +1,352 @@ +make_icon_folder( + [ + 'alpha.svg' => $this->svg( 'M1 1' ), + 'beta.svg' => $this->svg( 'M2 2' ), + 'gamma.svg' => $this->svg( 'M3 3' ), + ] + ); + + $items = CollectionItemsFactory::from_folder( $folder ); + + $this->assertCount( 3, $items ); + $this->assertContainsOnlyInstancesOf( CollectionItem::class, $items ); + $this->assertSame( + [ 'alpha', 'beta', 'gamma' ], + $this->names( $items ), + 'Item names come from the file name without its extension.' + ); + } + + public function test_from_folder_exposes_each_file_contents(): void { + $folder = $this->make_icon_folder( + [ + 'alpha.svg' => $this->svg( 'M1 1' ), + 'beta.svg' => $this->svg( 'M2 2' ), + ] + ); + + $items = $this->by_name( CollectionItemsFactory::from_folder( $folder ) ); + + $this->assertSame( $this->svg( 'M1 1' ), $items['alpha']->content() ); + $this->assertSame( $this->svg( 'M2 2' ), $items['beta']->content() ); + } + + public function test_from_folder_items_are_raw_type(): void { + $folder = $this->make_icon_folder( [ 'alpha.svg' => $this->svg() ] ); + + $items = CollectionItemsFactory::from_folder( $folder ); + + $this->assertSame( 'raw', $items[0]->type() ); + $this->assertNull( $items[0]->version() ); + } + + public function test_from_folder_labels_default_to_the_file_name(): void { + $folder = $this->make_icon_folder( [ 'arrow-left.svg' => $this->svg() ] ); + + $items = CollectionItemsFactory::from_folder( $folder ); + + $this->assertSame( 'arrow-left', $items[0]->label() ); + } + + public function test_from_folder_icon_map_overrides_labels(): void { + $folder = $this->make_icon_folder( + [ + 'alpha.svg' => $this->svg(), + 'beta.svg' => $this->svg(), + ] + ); + + $items = $this->by_name( + CollectionItemsFactory::from_folder( $folder, [ 'alpha' => 'First letter' ] ) + ); + + $this->assertSame( 'First letter', $items['alpha']->label() ); + $this->assertSame( 'beta', $items['beta']->label(), 'Unmapped icons keep their file name.' ); + } + + public function test_from_folder_ignores_non_svg_files(): void { + $folder = $this->make_icon_folder( + [ + 'alpha.svg' => $this->svg(), + 'notes.txt' => 'not an icon', + 'sprite.png' => 'not an icon either', + ] + ); + + $items = CollectionItemsFactory::from_folder( $folder ); + + $this->assertSame( [ 'alpha' ], $this->names( $items ) ); + } + + public function test_from_folder_skips_empty_files(): void { + $folder = $this->make_icon_folder( + [ + 'alpha.svg' => $this->svg(), + 'empty.svg' => '', + ] + ); + + $items = CollectionItemsFactory::from_folder( $folder ); + + $this->assertSame( [ 'alpha' ], $this->names( $items ) ); + } + + public function test_from_folder_on_empty_directory_returns_nothing(): void { + $folder = $this->make_temp_dir( 'empty' ); + + $this->assertSame( [], CollectionItemsFactory::from_folder( $folder ) ); + } + + public function test_from_folder_throws_on_unreadable_path(): void { + $this->expectException( CollectionCreationException::class ); + + CollectionItemsFactory::from_folder( '/definitely/not/a/folder' ); + } + + public function test_from_folder_is_stable_across_calls(): void { + $folder = $this->make_icon_folder( + [ + 'alpha.svg' => $this->svg( 'M1 1' ), + 'beta.svg' => $this->svg( 'M2 2' ), + ] + ); + + $first = $this->by_name( CollectionItemsFactory::from_folder( $folder ) ); + $second = $this->by_name( CollectionItemsFactory::from_folder( $folder ) ); + + $this->assertSame( array_keys( $first ), array_keys( $second ) ); + $this->assertSame( + $first['alpha']->content(), + $second['alpha']->content(), + 'A second call — cached or not — must describe the same icons.' + ); + } + + public function test_from_folder_caches_per_icon_map(): void { + $folder = $this->make_icon_folder( [ 'alpha.svg' => $this->svg() ] ); + + $plain = CollectionItemsFactory::from_folder( $folder ); + $labeled = CollectionItemsFactory::from_folder( $folder, [ 'alpha' => 'Renamed' ] ); + + $this->assertSame( 'alpha', $plain[0]->label() ); + $this->assertSame( + 'Renamed', + $labeled[0]->label(), + 'A different icon_map must not be served the previous cache entry.' + ); + } + + /* ------------------------------------------------------------- sprites */ + + public function test_from_sprite_returns_one_item_per_symbol(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-alpha', 'ico-beta' ] ) ); + + $items = CollectionItemsFactory::from_sprite( $path ); + + $this->assertCount( 2, $items ); + $this->assertSame( [ 'ico-alpha', 'ico-beta' ], $this->names( $items ) ); + } + + public function test_from_sprite_items_are_sprite_type(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-alpha' ] ) ); + + $items = CollectionItemsFactory::from_sprite( $path ); + + $this->assertSame( 'sprite', $items[0]->type() ); + } + + public function test_from_sprite_content_is_a_public_url_fragment(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-alpha' ] ) ); + + $items = CollectionItemsFactory::from_sprite( $path ); + + $expected = str_replace( WP_CONTENT_DIR, WP_CONTENT_URL, $path ) . '#ico-alpha'; + + $this->assertSame( + $expected, + $items[0]->content(), + 'A sprite icon is referenced by URL, not inlined.' + ); + $this->assertStringStartsWith( WP_CONTENT_URL, $items[0]->content() ); + } + + public function test_from_sprite_version_is_added_to_the_url_and_the_item(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-alpha' ] ) ); + + $items = CollectionItemsFactory::from_sprite( $path, [], '2.4.1' ); + + $this->assertStringContainsString( 'v=2.4.1', $items[0]->content() ); + $this->assertStringContainsString( '#ico-alpha', $items[0]->content() ); + $this->assertSame( '2.4.1', $items[0]->version() ); + } + + public function test_from_sprite_version_busts_the_cache(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-alpha' ] ) ); + + $v1 = CollectionItemsFactory::from_sprite( $path, [], '1.0.0' ); + $v2 = CollectionItemsFactory::from_sprite( $path, [], '2.0.0' ); + + $this->assertStringContainsString( 'v=1.0.0', $v1[0]->content() ); + $this->assertStringContainsString( 'v=2.0.0', $v2[0]->content() ); + } + + public function test_from_sprite_derives_a_readable_label_from_the_id(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-arrow-left' ] ) ); + + $items = CollectionItemsFactory::from_sprite( $path ); + + $this->assertSame( + 'Arrow left', + $items[0]->label(), + 'The first hyphen-separated segment is a prefix and is dropped.' + ); + } + + public function test_from_sprite_label_falls_back_to_the_id_when_nothing_remains(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'solo' ] ) ); + + $items = CollectionItemsFactory::from_sprite( $path ); + + $this->assertSame( 'solo', $items[0]->label() ); + } + + public function test_from_sprite_icon_map_overrides_labels(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-alpha' ] ) ); + + $items = CollectionItemsFactory::from_sprite( $path, [ 'ico-alpha' => 'Custom' ] ); + + $this->assertSame( 'Custom', $items[0]->label() ); + } + + public function test_from_sprite_without_symbols_returns_nothing(): void { + $path = $this->make_file( 'sprite.svg', '' ); + + $this->assertSame( [], CollectionItemsFactory::from_sprite( $path ) ); + } + + public function test_from_sprite_ignores_ids_outside_the_parsed_tags(): void { + // Only and survive the strip_tags() pass, so a is + // not an icon. + $path = $this->make_file( + 'sprite.svg', + '' . + '' . + '' + ); + + $items = CollectionItemsFactory::from_sprite( $path ); + + $this->assertSame( [ 'ico-real' ], $this->names( $items ) ); + } + + public function test_from_sprite_parsed_tags_are_filterable(): void { + $path = $this->make_file( + 'sprite.svg', + '' + ); + + add_filter( 'blockparty_icons_svg_parse_tags', static fn() => '' ); + + $this->assertSame( + [], + CollectionItemsFactory::from_sprite( $path ), + 'Restricting the parsed tags to must hide a id.' + ); + } + + public function test_from_sprite_throws_on_unreadable_path(): void { + $this->expectException( CollectionCreationException::class ); + + CollectionItemsFactory::from_sprite( '/definitely/not/a/sprite.svg' ); + } + + /* -------------------------------------------------------- single files */ + + public function test_from_file_returns_one_item(): void { + $path = $this->make_file( 'star.svg', $this->svg( 'M9 9' ) ); + + $items = CollectionItemsFactory::from_file( $path, [ 'name' => 'star' ] ); + + $this->assertCount( 1, $items ); + $this->assertSame( 'star', $items[0]->name() ); + $this->assertSame( 'raw', $items[0]->type() ); + $this->assertSame( $this->svg( 'M9 9' ), $items[0]->content() ); + } + + public function test_from_file_uses_the_provided_label(): void { + $path = $this->make_file( 'star.svg', $this->svg() ); + + $items = CollectionItemsFactory::from_file( + $path, + [ + 'name' => 'star', + 'label' => 'A shining star', + ] + ); + + $this->assertSame( 'A shining star', $items[0]->label() ); + } + + public function test_from_file_on_empty_file_returns_nothing(): void { + $path = $this->make_file( 'empty.svg', '' ); + + $this->assertSame( [], CollectionItemsFactory::from_file( $path, [ 'name' => 'empty' ] ) ); + } + + public function test_from_file_throws_on_unreadable_path(): void { + $this->expectException( CollectionCreationException::class ); + + CollectionItemsFactory::from_file( '/definitely/not/a/file.svg' ); + } + + /* ------------------------------------------------------------- helpers */ + + /** + * @param CollectionItem[] $items + * + * @return string[] + */ + private function names( array $items ): array { + return array_map( static fn( CollectionItem $item ) => $item->name(), $items ); + } + + /** + * @param CollectionItem[] $items + * + * @return array + */ + private function by_name( array $items ): array { + $out = []; + foreach ( $items as $item ) { + $out[ $item->name() ] = $item; + } + + return $out; + } +} diff --git a/tests/phpunit/Icon/CollectionTest.php b/tests/phpunit/Icon/CollectionTest.php new file mode 100644 index 0000000..305ca85 --- /dev/null +++ b/tests/phpunit/Icon/CollectionTest.php @@ -0,0 +1,254 @@ +' ): CollectionItem { + return new CollectionItem( $name, 'raw', $content, $label ); + } + + /* --------------------------------------------------------- constructor */ + + public function test_label_defaults_to_the_name(): void { + $collection = new Collection( 'my-icons' ); + + $this->assertSame( 'my-icons', $collection->name() ); + $this->assertSame( 'my-icons', $collection->label() ); + } + + public function test_label_can_be_given(): void { + $collection = new Collection( 'my-icons', 'My icons' ); + + $this->assertSame( 'My icons', $collection->label() ); + } + + /* ---------------------------------------------------------- membership */ + + public function test_a_new_collection_is_empty(): void { + $collection = new Collection( 'empty' ); + + $this->assertSame( 0, $collection->count() ); + $this->assertSame( [], $collection->all() ); + $this->assertNull( $collection->get( 'anything' ) ); + } + + public function test_items_are_retrievable_by_name(): void { + $collection = new Collection( 'icons' ); + $collection->add( $this->item( 'alpha' ) ); + $collection->add( $this->item( 'beta' ) ); + + $this->assertSame( 2, $collection->count() ); + $this->assertSame( 'alpha', $collection->get( 'alpha' )->name() ); + $this->assertSame( 'beta', $collection->get( 'beta' )->name() ); + } + + public function test_get_returns_null_for_an_unknown_name(): void { + $collection = new Collection( 'icons' ); + $collection->add( $this->item( 'alpha' ) ); + + $this->assertNull( $collection->get( 'nope' ) ); + } + + public function test_adding_the_same_name_twice_replaces_the_first(): void { + $collection = new Collection( 'icons' ); + $collection->add( $this->item( 'alpha', 'First' ) ); + $collection->add( $this->item( 'alpha', 'Second' ) ); + + $this->assertSame( 1, $collection->count() ); + $this->assertSame( 'Second', $collection->get( 'alpha' )->label() ); + } + + public function test_a_dotted_name_is_keyed_on_the_part_before_the_dot(): void { + $collection = new Collection( 'icons' ); + $collection->add( $this->item( 'alpha.svg' ) ); + + $this->assertSame( + 'alpha.svg', + $collection->get( 'alpha' )->name(), + 'Lookup uses the truncated key while the item keeps its full name.' + ); + } + + public function test_all_is_keyed_by_lookup_name(): void { + $collection = new Collection( 'icons' ); + $collection->add( $this->item( 'alpha' ) ); + $collection->add( $this->item( 'beta' ) ); + + $this->assertSame( [ 'alpha', 'beta' ], array_keys( $collection->all() ) ); + } + + /* -------------------------------------------------------------- search */ + + public function test_search_matches_on_name(): void { + $collection = new Collection( 'icons' ); + $collection->add( $this->item( 'arrow-left' ) ); + $collection->add( $this->item( 'arrow-right' ) ); + $collection->add( $this->item( 'star' ) ); + + $found = $collection->search( 'arrow' ); + + $this->assertSame( 2, $found->count() ); + $this->assertSame( [ 'arrow-left', 'arrow-right' ], array_keys( $found->all() ) ); + } + + public function test_search_matches_on_label(): void { + $collection = new Collection( 'icons' ); + $collection->add( $this->item( 'ico-1', 'Shopping cart' ) ); + $collection->add( $this->item( 'ico-2', 'Star' ) ); + + $found = $collection->search( 'cart' ); + + $this->assertSame( [ 'ico-1' ], array_keys( $found->all() ) ); + } + + public function test_search_is_case_insensitive(): void { + $collection = new Collection( 'icons' ); + $collection->add( $this->item( 'Arrow-Left' ) ); + + $this->assertSame( 1, $collection->search( 'arrow' )->count() ); + $this->assertSame( 1, $collection->search( 'ARROW' )->count() ); + } + + public function test_search_returns_an_empty_collection_when_nothing_matches(): void { + $collection = new Collection( 'icons', 'Icons' ); + $collection->add( $this->item( 'alpha' ) ); + + $found = $collection->search( 'zzz' ); + + $this->assertSame( 0, $found->count() ); + $this->assertSame( 'icons', $found->name(), 'The filtered collection keeps its identity.' ); + $this->assertSame( 'Icons', $found->label() ); + } + + public function test_search_does_not_modify_the_original(): void { + $collection = new Collection( 'icons' ); + $collection->add( $this->item( 'alpha' ) ); + $collection->add( $this->item( 'beta' ) ); + + $collection->search( 'alpha' ); + + $this->assertSame( 2, $collection->count() ); + } + + /* -------------------------------------------------------- named ctors */ + + public function test_from_folder_builds_a_populated_collection(): void { + $folder = $this->make_icon_folder( + [ + 'alpha.svg' => $this->svg( 'M1 1' ), + 'beta.svg' => $this->svg( 'M2 2' ), + ] + ); + + $collection = Collection::from_folder( 'folder-icons', $folder, [ 'label' => 'Folder icons' ] ); + + $this->assertSame( 'folder-icons', $collection->name() ); + $this->assertSame( 'Folder icons', $collection->label() ); + $this->assertSame( 2, $collection->count() ); + $this->assertSame( $this->svg( 'M1 1' ), $collection->get( 'alpha' )->content() ); + } + + public function test_from_folder_applies_the_icon_map(): void { + $folder = $this->make_icon_folder( [ 'alpha.svg' => $this->svg() ] ); + + $collection = Collection::from_folder( + 'folder-icons', + $folder, + [ 'icon_map' => [ 'alpha' => 'The first one' ] ] + ); + + $this->assertSame( 'The first one', $collection->get( 'alpha' )->label() ); + } + + public function test_from_folder_throws_on_unreadable_path(): void { + $this->expectException( CollectionCreationException::class ); + + Collection::from_folder( 'nope', '/definitely/not/a/folder' ); + } + + public function test_from_sprite_builds_a_populated_collection(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-alpha', 'ico-beta' ] ) ); + + $collection = Collection::from_sprite( 'sprite-icons', $path, [ 'label' => 'Sprite icons' ] ); + + $this->assertSame( 'Sprite icons', $collection->label() ); + $this->assertSame( 2, $collection->count() ); + $this->assertSame( 'sprite', $collection->get( 'ico-alpha' )->type() ); + } + + public function test_from_sprite_passes_the_version_through(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-alpha' ] ) ); + + $collection = Collection::from_sprite( 'sprite-icons', $path, [ 'version' => '3.1' ] ); + + $this->assertSame( '3.1', $collection->get( 'ico-alpha' )->version() ); + $this->assertStringContainsString( 'v=3.1', $collection->get( 'ico-alpha' )->content() ); + } + + public function test_from_sprite_throws_on_unreadable_path(): void { + $this->expectException( CollectionCreationException::class ); + + Collection::from_sprite( 'nope', '/definitely/not/a/sprite.svg' ); + } + + /* ------------------------------------------------------------ iterator */ + + public function test_collection_implements_iterator(): void { + $this->assertInstanceOf( \Iterator::class, new Collection( 'icons' ) ); + } + + public function test_iteration_over_an_empty_collection_yields_nothing(): void { + $seen = []; + foreach ( new Collection( 'icons' ) as $item ) { + $seen[] = $item; + } + + $this->assertSame( [], $seen ); + } + + /** + * Records a defect, not a specification. + * + * The Iterator implementation walks an integer $position, while add() keys items + * by name. valid() therefore asks for $items[0], which never exists, and foreach + * over a populated collection yields nothing at all. + * + * Nothing in the plugin iterates a Collection — every caller uses all() — so this + * is latent rather than broken in production. The test is here so that whoever + * fixes it sees this expectation fail and knows to update it deliberately. + * + * @see Collection::current() + * @see Collection::valid() + */ + public function test_known_defect_iteration_yields_nothing_even_when_populated(): void { + $collection = new Collection( 'icons' ); + $collection->add( $this->item( 'alpha' ) ); + $collection->add( $this->item( 'beta' ) ); + + $seen = []; + foreach ( $collection as $item ) { + $seen[] = $item->name(); + } + + $this->assertSame( 2, $collection->count(), 'The items really are in there.' ); + $this->assertSame( + [], + $seen, + 'Iteration is broken: positions are integers, keys are names. ' . + 'If this assertion starts failing, the Iterator was fixed — update this test.' + ); + } +} diff --git a/tests/phpunit/RegistrationTest.php b/tests/phpunit/RegistrationTest.php new file mode 100644 index 0000000..9e57b10 --- /dev/null +++ b/tests/phpunit/RegistrationTest.php @@ -0,0 +1,269 @@ +assertInstanceOf( Collection::class, $collection ); + $this->assertSame( 'plain', $collection->name() ); + $this->assertSame( 0, $collection->count() ); + } + + public function test_registering_uses_the_given_label(): void { + $collection = register_icon_collection( 'plain', [ 'label' => 'Plain icons' ] ); + + $this->assertSame( 'Plain icons', $collection->label() ); + } + + public function test_registering_a_folder_loads_its_icons(): void { + $folder = $this->make_icon_folder( + [ + 'alpha.svg' => $this->svg( 'M1 1' ), + 'beta.svg' => $this->svg( 'M2 2' ), + ] + ); + + $collection = register_icon_collection( + 'folder-icons', + [ + 'type' => 'folder', + 'source' => $folder, + ] + ); + + $this->assertSame( 2, $collection->count() ); + $this->assertSame( $this->svg( 'M1 1' ), $collection->get( 'alpha' )->content() ); + } + + public function test_registering_a_sprite_loads_its_symbols(): void { + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-alpha', 'ico-beta' ] ) ); + + $collection = register_icon_collection( + 'sprite-icons', + [ + 'type' => 'sprite', + 'source' => $path, + ] + ); + + $this->assertSame( 2, $collection->count() ); + $this->assertSame( 'sprite', $collection->get( 'ico-alpha' )->type() ); + } + + public function test_registering_an_unknown_type_yields_an_empty_collection(): void { + $collection = register_icon_collection( 'weird', [ 'type' => 'carrier-pigeon' ] ); + + $this->assertInstanceOf( Collection::class, $collection ); + $this->assertSame( 0, $collection->count() ); + } + + public function test_registering_a_folder_that_does_not_exist_stores_false(): void { + $result = register_icon_collection( + 'broken', + [ + 'type' => 'folder', + 'source' => '/definitely/not/a/folder', + ] + ); + + $this->assertFalse( $result, 'A source that cannot be read must not throw out of the API.' ); + } + + public function test_registering_a_prebuilt_collection_instance(): void { + $collection = new Collection( 'prebuilt', 'Prebuilt' ); + $collection->add( new CollectionItem( 'alpha', 'raw', '' ) ); + + $returned = register_icon_collection( $collection ); + + $this->assertSame( $collection, $returned ); + $this->assertSame( $collection, get_icon_collection( 'prebuilt' ) ); + $this->assertSame( 1, get_icon_collection( 'prebuilt' )->count() ); + } + + public function test_registering_a_duplicate_name_is_refused(): void { + register_icon_collection( 'once', [ 'label' => 'First' ] ); + + $second = register_icon_collection( 'once', [ 'label' => 'Second' ] ); + + $this->assertFalse( $second ); + $this->assertSame( 'First', get_icon_collection( 'once' )->label(), 'The first registration wins.' ); + } + + public function test_registering_a_duplicate_instance_is_refused(): void { + register_icon_collection( new Collection( 'once', 'First' ) ); + + $this->assertFalse( register_icon_collection( new Collection( 'once', 'Second' ) ) ); + $this->assertSame( 'First', get_icon_collection( 'once' )->label() ); + } + + /* ------------------------------------------------------------ add_icons */ + + public function test_add_icons_from_a_folder(): void { + register_icon_collection( 'target' ); + $folder = $this->make_icon_folder( + [ + 'alpha.svg' => $this->svg(), + 'beta.svg' => $this->svg(), + ] + ); + + $this->assertTrue( add_icons( 'target', 'folder', $folder ) ); + $this->assertSame( 2, get_icon_collection( 'target' )->count() ); + } + + public function test_add_icons_from_a_sprite(): void { + register_icon_collection( 'target' ); + $path = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-alpha' ] ) ); + + $this->assertTrue( add_icons( 'target', 'sprite', $path ) ); + $this->assertSame( 'sprite', get_icon_collection( 'target' )->get( 'ico-alpha' )->type() ); + } + + public function test_add_icons_from_a_single_file(): void { + register_icon_collection( 'target' ); + $path = $this->make_file( 'star.svg', $this->svg( 'M9 9' ) ); + + $added = add_icons( + 'target', + 'file', + $path, + [ + 'name' => 'star', + 'label' => 'A star', + ] + ); + + $this->assertTrue( $added ); + $this->assertSame( 'A star', get_icon_collection( 'target' )->get( 'star' )->label() ); + $this->assertSame( $this->svg( 'M9 9' ), get_icon_collection( 'target' )->get( 'star' )->content() ); + } + + public function test_add_icons_accumulates_across_sources(): void { + register_icon_collection( 'target' ); + $folder = $this->make_icon_folder( [ 'alpha.svg' => $this->svg() ] ); + $sprite = $this->make_file( 'sprite.svg', $this->sprite( [ 'ico-beta' ] ) ); + + add_icons( 'target', 'folder', $folder ); + add_icons( 'target', 'sprite', $sprite ); + + $collection = get_icon_collection( 'target' ); + + $this->assertSame( 2, $collection->count() ); + $this->assertSame( 'raw', $collection->get( 'alpha' )->type() ); + $this->assertSame( 'sprite', $collection->get( 'ico-beta' )->type() ); + } + + public function test_add_icons_to_an_unknown_collection_fails(): void { + $folder = $this->make_icon_folder( [ 'alpha.svg' => $this->svg() ] ); + + $this->assertFalse( add_icons( 'nope', 'folder', $folder ) ); + } + + public function test_add_icons_with_an_unknown_type_fails(): void { + register_icon_collection( 'target' ); + + $this->assertFalse( add_icons( 'target', 'carrier-pigeon', '/tmp' ) ); + $this->assertSame( 0, get_icon_collection( 'target' )->count() ); + } + + public function test_add_icons_from_an_unreadable_source_fails(): void { + register_icon_collection( 'target' ); + + $this->assertFalse( add_icons( 'target', 'folder', '/definitely/not/a/folder' ) ); + $this->assertSame( 0, get_icon_collection( 'target' )->count() ); + } + + /* -------------------------------------------------------------- lookups */ + + public function test_icon_collection_exists(): void { + $this->assertFalse( icon_collection_exists( 'ghost' ) ); + + register_icon_collection( 'ghost' ); + + $this->assertTrue( icon_collection_exists( 'ghost' ) ); + } + + public function test_get_icon_collection_returns_null_when_unknown(): void { + $this->assertNull( get_icon_collection( 'ghost' ) ); + } + + public function test_get_icon_collections_lists_everything_by_name(): void { + register_icon_collection( 'one' ); + register_icon_collection( 'two' ); + + $all = get_icon_collections(); + + $this->assertSame( [ 'one', 'two' ], array_keys( $all ) ); + $this->assertContainsOnlyInstancesOf( Collection::class, $all ); + } + + public function test_get_icon_collections_is_empty_by_default(): void { + $this->assertSame( [], get_icon_collections() ); + } + + /* ------------------------------------------------------------- editor */ + + public function test_preload_paths_cover_the_collections_endpoint(): void { + $paths = apply_filters( 'block_editor_rest_api_preload_paths', [], null ); + + $this->assertContains( '/icons/v1/collections?context=edit', $paths ); + } + + public function test_preload_paths_include_one_entry_per_collection(): void { + register_icon_collection( 'one' ); + register_icon_collection( 'two' ); + + $paths = apply_filters( 'block_editor_rest_api_preload_paths', [], null ); + + $this->assertContains( '/icons/v1/one?context=edit', $paths ); + $this->assertContains( '/icons/v1/two?context=edit', $paths ); + } + + public function test_preload_paths_keep_existing_entries(): void { + $paths = apply_filters( 'block_editor_rest_api_preload_paths', [ '/wp/v2/types' ], null ); + + $this->assertContains( '/wp/v2/types', $paths ); + } + + public function test_init_action_fires_for_third_parties(): void { + $fired = false; + add_action( + 'blockparty_icons_init', + static function () use ( &$fired ) { + $fired = true; + } + ); + + do_action( 'blockparty_icons_init' ); + + $this->assertTrue( $fired ); + } + + public function test_the_block_type_is_registered(): void { + $this->assertTrue( + \WP_Block_Type_Registry::get_instance()->is_registered( 'blockparty/icon' ), + 'The plugin registers its block on init.' + ); + } +} diff --git a/tests/phpunit/Rest/CollectionsControllerTest.php b/tests/phpunit/Rest/CollectionsControllerTest.php new file mode 100644 index 0000000..8f9214c --- /dev/null +++ b/tests/phpunit/Rest/CollectionsControllerTest.php @@ -0,0 +1,134 @@ +editor_id = self::factory()->user->create( [ 'role' => 'editor' ] ); + $this->subscriber_id = self::factory()->user->create( [ 'role' => 'subscriber' ] ); + } + + public function tear_down() { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + private function boot_rest(): void { + do_action( 'rest_api_init' ); + } + + private function get_collections() { + return rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/icons/v1/collections' ) ); + } + + /* --------------------------------------------------------- permissions */ + + public function test_the_endpoint_requires_edit_posts(): void { + $this->boot_rest(); + wp_set_current_user( 0 ); + + $this->assertSame( 401, $this->get_collections()->get_status() ); + } + + public function test_a_subscriber_is_refused(): void { + $this->boot_rest(); + wp_set_current_user( $this->subscriber_id ); + + $this->assertSame( 403, $this->get_collections()->get_status() ); + } + + /* --------------------------------------------------------------- shape */ + + public function test_no_collections_yields_an_empty_response(): void { + $this->boot_rest(); + wp_set_current_user( $this->editor_id ); + + $response = $this->get_collections(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( [], $response->get_data() ); + } + + public function test_collections_are_keyed_by_name(): void { + register_icon_collection( 'one', [ 'label' => 'First' ] ); + register_icon_collection( 'two', [ 'label' => 'Second' ] ); + $this->boot_rest(); + wp_set_current_user( $this->editor_id ); + + $data = $this->get_collections()->get_data(); + + $this->assertSame( [ 'one', 'two' ], array_keys( $data ) ); + } + + public function test_each_collection_reports_name_label_and_count(): void { + $folder = $this->make_icon_folder( + [ + 'alpha.svg' => $this->svg(), + 'beta.svg' => $this->svg(), + ] + ); + register_icon_collection( + 'folder-icons', + [ + 'label' => 'Folder icons', + 'type' => 'folder', + 'source' => $folder, + ] + ); + $this->boot_rest(); + wp_set_current_user( $this->editor_id ); + + $entry = $this->get_collections()->get_data()['folder-icons']; + + $this->assertSame( 'folder-icons', $entry['name'] ); + $this->assertSame( 'Folder icons', $entry['label'] ); + $this->assertSame( 2, $entry['count'] ); + } + + public function test_the_listing_carries_no_icon_payloads(): void { + $folder = $this->make_icon_folder( [ 'alpha.svg' => $this->svg( 'MARKER' ) ] ); + register_icon_collection( + 'folder-icons', + [ + 'type' => 'folder', + 'source' => $folder, + ] + ); + $this->boot_rest(); + wp_set_current_user( $this->editor_id ); + + $encoded = wp_json_encode( $this->get_collections()->get_data() ); + + $this->assertStringNotContainsString( + 'MARKER', + $encoded, + 'Listing collections must stay a directory, never ship the SVGs.' + ); + } + + public function test_the_route_is_registered(): void { + $this->boot_rest(); + + $this->assertArrayHasKey( '/icons/v1/collections', rest_get_server()->get_routes() ); + } +} diff --git a/tests/phpunit/Rest/IconsControllerTest.php b/tests/phpunit/Rest/IconsControllerTest.php new file mode 100644 index 0000000..28ea2bb --- /dev/null +++ b/tests/phpunit/Rest/IconsControllerTest.php @@ -0,0 +1,234 @@ +editor_id = self::factory()->user->create( [ 'role' => 'editor' ] ); + $this->subscriber_id = self::factory()->user->create( [ 'role' => 'subscriber' ] ); + + $folder = $this->make_icon_folder( + [ + 'arrow-left.svg' => $this->svg( 'M1 1' ), + 'arrow-right.svg' => $this->svg( 'M2 2' ), + 'star.svg' => $this->svg( 'M3 3' ), + ] + ); + + register_icon_collection( + 'test-icons', + [ + 'label' => 'Test icons', + 'type' => 'folder', + 'source' => $folder, + ] + ); + + // Routes are registered from the collections that exist at rest_api_init. + do_action( 'rest_api_init' ); + } + + public function tear_down() { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * @param string $route + * @param array $params + * + * @return \WP_REST_Response + */ + private function get( string $route, array $params = [] ) { + $request = new WP_REST_Request( 'GET', $route ); + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + + return rest_get_server()->dispatch( $request ); + } + + /* --------------------------------------------------------- permissions */ + + public function test_listing_requires_edit_posts(): void { + wp_set_current_user( 0 ); + + $this->assertSame( 401, $this->get( '/icons/v1/test-icons' )->get_status() ); + } + + public function test_a_subscriber_is_refused(): void { + wp_set_current_user( $this->subscriber_id ); + + $this->assertSame( 403, $this->get( '/icons/v1/test-icons' )->get_status() ); + } + + public function test_an_editor_is_allowed(): void { + wp_set_current_user( $this->editor_id ); + + $this->assertSame( 200, $this->get( '/icons/v1/test-icons' )->get_status() ); + } + + /* --------------------------------------------------------------- items */ + + public function test_listing_returns_every_icon_keyed_by_name(): void { + wp_set_current_user( $this->editor_id ); + + $data = $this->get( '/icons/v1/test-icons' )->get_data(); + + $this->assertSame( [ 'arrow-left', 'arrow-right', 'star' ], array_keys( $data ) ); + } + + public function test_each_icon_exposes_its_fields(): void { + wp_set_current_user( $this->editor_id ); + + $data = $this->get( '/icons/v1/test-icons' )->get_data(); + $icon = $data['arrow-left']; + + $this->assertSame( 'arrow-left', $icon['name'] ); + $this->assertSame( 'arrow-left', $icon['label'] ); + $this->assertSame( 'raw', $icon['type'] ); + $this->assertSame( $this->svg( 'M1 1' ), $icon['content'] ); + $this->assertArrayHasKey( 'version', $icon ); + } + + public function test_totals_are_reported_in_headers(): void { + wp_set_current_user( $this->editor_id ); + + $response = $this->get( '/icons/v1/test-icons' ); + $headers = $response->get_headers(); + + $this->assertSame( 3, $headers['X-WP-Total'] ); + $this->assertSame( 1.0, (float) $headers['X-WP-TotalPages'] ); + } + + /* ---------------------------------------------------------- pagination */ + + public function test_per_page_limits_the_result(): void { + wp_set_current_user( $this->editor_id ); + + $data = $this->get( '/icons/v1/test-icons', [ 'per_page' => 2 ] )->get_data(); + + $this->assertCount( 2, $data ); + $this->assertSame( [ 'arrow-left', 'arrow-right' ], array_keys( $data ) ); + } + + public function test_the_second_page_continues_where_the_first_stopped(): void { + wp_set_current_user( $this->editor_id ); + + $data = $this->get( + '/icons/v1/test-icons', + [ + 'per_page' => 2, + 'page' => 2, + ] + )->get_data(); + + $this->assertSame( [ 'star' ], array_keys( $data ) ); + } + + public function test_page_count_reflects_per_page(): void { + wp_set_current_user( $this->editor_id ); + + $headers = $this->get( '/icons/v1/test-icons', [ 'per_page' => 2 ] )->get_headers(); + + $this->assertSame( 3, $headers['X-WP-Total'] ); + $this->assertSame( 2.0, (float) $headers['X-WP-TotalPages'] ); + } + + public function test_a_page_past_the_end_is_empty(): void { + wp_set_current_user( $this->editor_id ); + + $data = $this->get( + '/icons/v1/test-icons', + [ + 'per_page' => 2, + 'page' => 99, + ] + )->get_data(); + + $this->assertSame( [], $data ); + } + + /* -------------------------------------------------------------- search */ + + public function test_search_narrows_the_list(): void { + wp_set_current_user( $this->editor_id ); + + $data = $this->get( '/icons/v1/test-icons', [ 'search' => 'arrow' ] )->get_data(); + + $this->assertSame( [ 'arrow-left', 'arrow-right' ], array_keys( $data ) ); + } + + public function test_search_totals_describe_the_filtered_set(): void { + wp_set_current_user( $this->editor_id ); + + $headers = $this->get( '/icons/v1/test-icons', [ 'search' => 'arrow' ] )->get_headers(); + + $this->assertSame( 2, $headers['X-WP-Total'] ); + } + + public function test_search_with_no_match_is_empty(): void { + wp_set_current_user( $this->editor_id ); + + $response = $this->get( '/icons/v1/test-icons', [ 'search' => 'zzzz' ] ); + + $this->assertSame( [], $response->get_data() ); + $this->assertSame( 0, $response->get_headers()['X-WP-Total'] ); + } + + /* ---------------------------------------------------------- single item */ + + public function test_a_single_icon_can_be_fetched(): void { + wp_set_current_user( $this->editor_id ); + + $response = $this->get( '/icons/v1/test-icons/star' ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'star', $response->get_data()['name'] ); + $this->assertSame( $this->svg( 'M3 3' ), $response->get_data()['content'] ); + } + + public function test_an_unknown_icon_is_a_404(): void { + wp_set_current_user( $this->editor_id ); + + $response = $this->get( '/icons/v1/test-icons/nope' ); + + $this->assertSame( 404, $response->get_status() ); + $this->assertSame( 'rest_icon_invalid_name', $response->get_data()['code'] ); + } + + /* --------------------------------------------------------------- routes */ + + public function test_a_route_exists_for_each_registered_collection(): void { + $routes = rest_get_server()->get_routes(); + + $this->assertArrayHasKey( '/icons/v1/test-icons', $routes ); + $this->assertArrayHasKey( '/icons/v1/test-icons/(?P[\w\-]+)', $routes ); + } + + public function test_no_route_exists_for_an_unregistered_collection(): void { + wp_set_current_user( $this->editor_id ); + + $this->assertSame( 404, $this->get( '/icons/v1/never-registered' )->get_status() ); + } +} diff --git a/tests/phpunit/TestCase.php b/tests/phpunit/TestCase.php new file mode 100644 index 0000000..55c46cb --- /dev/null +++ b/tests/phpunit/TestCase.php @@ -0,0 +1,157 @@ +temp_dirs as $dir ) { + $this->delete_tree( $dir ); + } + $this->temp_dirs = []; + + $GLOBALS['blockparty_icon_collections'] = []; + + parent::tear_down(); + } + + /** + * Create a directory for fixtures, removed automatically after the test. + * + * Fixtures live under WP_CONTENT_DIR because CollectionItemsFactory derives a + * sprite's public URL by swapping WP_CONTENT_DIR for WP_CONTENT_URL. Putting + * them anywhere else would make that path untestable. + * + * @param string $prefix + * + * @return string Absolute path, without a trailing slash. + */ + protected function make_temp_dir( string $prefix = 'icons' ): string { + $dir = WP_CONTENT_DIR . '/bpi-tests/' . $prefix . '-' . wp_generate_password( 8, false ); + + if ( ! wp_mkdir_p( $dir ) ) { + $this->fail( "Could not create fixture directory {$dir}" ); + } + + $this->temp_dirs[] = $dir; + + return $dir; + } + + /** + * Write a folder of SVG files. + * + * @param array $files Map of file name => file contents. + * + * @return string Absolute path to the folder. + */ + protected function make_icon_folder( array $files ): string { + $dir = $this->make_temp_dir( 'folder' ); + + foreach ( $files as $name => $contents ) { + file_put_contents( $dir . '/' . $name, $contents ); // phpcs:ignore WordPress.WP.AlternativeFunctions + } + + return $dir; + } + + /** + * Write a single file and return its path. + * + * @param string $name + * @param string $contents + * + * @return string + */ + protected function make_file( string $name, string $contents ): string { + $dir = $this->make_temp_dir( 'file' ); + $path = $dir . '/' . $name; + + file_put_contents( $path, $contents ); // phpcs:ignore WordPress.WP.AlternativeFunctions + + return $path; + } + + /** + * Build a minimal but valid single-icon SVG. + * + * @param string $marker Text placed in the path data so a test can tell icons apart. + * + * @return string + */ + protected function svg( string $marker = 'M0 0h24v24H0z' ): string { + return sprintf( + '', + $marker + ); + } + + /** + * Build a sprite containing the given symbol ids. + * + * @param string[] $ids + * + * @return string + */ + protected function sprite( array $ids ): string { + $symbols = ''; + foreach ( $ids as $id ) { + $symbols .= sprintf( + '', + $id + ); + } + + return '' . $symbols . ''; + } + + /** + * Recursively remove a directory. + * + * @param string $dir + */ + private function delete_tree( string $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + + foreach ( (array) glob( $dir . '/*' ) as $entry ) { + if ( is_dir( $entry ) ) { + $this->delete_tree( $entry ); + continue; + } + unlink( $entry ); // phpcs:ignore WordPress.WP.AlternativeFunctions + } + + rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions + } +} diff --git a/tests/wp-tests-config-sample.php b/tests/wp-tests-config-sample.php new file mode 100644 index 0000000..1d6b262 --- /dev/null +++ b/tests/wp-tests-config-sample.php @@ -0,0 +1,33 @@ +