diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 97506464..04b81446 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -14,7 +14,7 @@ - [ ] Tests added or updated (Unit and/or Integration as appropriate) - [ ] `composer quality-check` passes locally (PHPCS, PHPStan and the tests) -- [ ] Public API changes are documented in the affected package's `README.md` +- [ ] Changelog fragment added (`composer changelog:add`) - [ ] Breaking changes are explicitly justified below ## Breaking changes / migration notes diff --git a/AGENTS.md b/AGENTS.md index 68b7fca6..20bdf642 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # wordpress-framework -A WordPress plugin framework published as five Composer packages under `ahegyes/`, PHP namespace `DeepWebSolutions\Framework\`, GPL-3.0-or-later. splitsh splits each `packages//` to its read-only `wp-framework-` mirror on every push to `trunk`, and a `v*` tag to every mirror without `--force`; consumers install the mirrors through `vcs` repositories, not Packagist, and prefix them with php-scoper through wordpress-configs. The packages form a stack: core requires bootstrap and shared, settings requires core, and woocommerce requires settings, each requiring only the layer below it, while shared also installs alone; a plugin ships only the closure of the highest package it uses. +A WordPress plugin framework published as five Composer packages under `ahegyes/`, PHP namespace `DeepWebSolutions\Framework\`, GPL-3.0-or-later. splitsh splits each `packages//` to its read-only `wp-framework-` mirror on every push to `trunk` that changes `packages/`, and a `v*` tag to every mirror without `--force`; consumers install the mirrors through `vcs` repositories, not Packagist, and prefix them with php-scoper through wordpress-configs. The packages form a stack: core requires bootstrap and shared, settings requires core, and woocommerce requires settings, each requiring only the layer below it, while shared also installs alone; a plugin ships only the closure of the highest package it uses. ## Layout @@ -14,7 +14,7 @@ wordpress-framework/ ├── packages/shared/ ahegyes/wp-framework-shared `require.php` · no package require · WordPress-free │ └── src/ psr-4 DeepWebSolutions\Framework\Shared\ · Error/, Exception/, Result/, ValueObject/, Versioning/ ├── packages/core/ ahegyes/wp-framework-core `require.php`, ext-filter · bootstrap ^2.0, shared ^2.0, psr/clock ^1, psr/container ^2.0.2, psr/log ^3.0.2, psr/simple-cache ^3 -│ └── src/ psr-4 DeepWebSolutions\Framework\Core\ · PluginKernel, PluginBoot, ComponentInterface, CompositeComponentInterface, ConditionalComponentInterface +│ └── src/ psr-4 DeepWebSolutions\Framework\Core\ · PluginKernel, PluginBoot (is_development_environment (@internal)), ComponentInterface, CompositeComponentInterface, ConditionalComponentInterface │ ├── AdminNotice/{AdminNotices, Notice, NoticeType, NoticeScope} │ ├── Caching/{TransientCache, ObjectCache, CacheEntries (@internal), Exceptions/InvalidCacheKeyException} │ ├── Clock/SystemClock · Logging/{AbstractLogger, ErrorLogLogger, GuardedLogger (@internal)} @@ -34,7 +34,7 @@ wordpress-framework/ │ ├── ProductData/{ProductDataTab, ProductDataTabDefinitionInterface, ProductMeta} │ ├── Setting/{SettingsTab, SettingsSection, SectionFieldsDefinitionInterface, Fields (@internal)} │ └── Uninstall/MetaTypes -├── tests/{Unit, PHPStan, Integration, Php74, EndToEnd, Support, bin, Fixtures/{Core, Settings, Shared, WooCommerce, package-manifests, consumer-a, consumer-b, personas, end-to-end}} +├── tests/{Unit, PHPStan, Integration, Php74, EndToEnd, Support, bin, Fixtures/{Core, Settings, Shared, WooCommerce, PackageManifests, consumer-a, consumer-b, personas, end-to-end}} │ consumer-a: a WooCommerce plugin on all five packages, scoped through configs by its own composer.json │ consumer-b: a plugin on bootstrap, shared, core and settings, without WooCommerce, scoped the same way │ personas/{bootstrap, shared, core, settings, woocommerce}: one plugin per stack, shared a plain PHP project, each requiring only its top package, scoped the same way; each lists its stack's mirrors as `vcs` repositories after the path repository, the set its package README copies @@ -78,13 +78,13 @@ actionlint && zizmor .github/ 1. **Gates and toggles.** `should_load()` is static, pure and total: no hooks, writes, notices, translation, user reads or construction. Read options with an explicit default. Detect companions by include-time constants, classes or functions, aware of network activation. Gates read no request-type facts, because `REST_REQUEST` is defined only on `parse_request` and `is_admin()` holds in admin-ajax requests; callbacks check the request type. A feature's toggle and settings UI live outside its gate; a gate mixing an environment check with a setting exposes the environment half as a static method. 2. **Nodes and the container.** Node classes never extend, implement or `use` optional or late-loaded types. Never constructor-inject a gated component; inject ungated services. Every injected service that implements `ComponentInterface` is also a node, because only nodes run `register_hooks()`. Definitions are static closures, taking `ContainerInterface $c` only when they read the container, or autowiring, never PHP-DI helpers. Hold the built container as `ContainerInterface`. Container reads belong in the kernel and composition wiring: a class-entry read checks the entry's type before use, and a factory collecting contributors re-applies their `should_load()` before resolving them. Framework classes take their logger injected; the composition root chooses the logger. A failure is logged with its Throwable under `exception`, a `WP_Error`'s Throwable data included. The uninstall engine runs without a composition root, so it writes the jobs it could not unschedule to PHP's error log, one line per site. 3. **Constructors and `register_hooks()`.** Constructors take services only. Plugins boot on `plugins_loaded` at priority 15, after `woocommerce_loaded`, Action Scheduler's priorities 0 and 1 and `woocommerce_blocks_loaded` have fired, so work that waits for them hooks `init` or later. `register_hooks()` adds `array( $this, 'method' )` hooks, or constructs an `OptionsPage`, `ProductDataTab` or `ObjectForm` and calls its `register_hooks()`. It never fires actions, applies the plugin's own filters or reads a registry filled through them, because nodes later in the walk have not registered yet. Registration-time hooks register unconditionally and authorize in the callback. Scheduled-hook callbacks register on every request, because Action Scheduler runs actions in admin-ajax, WP-Cron and WP-CLI requests, and tolerate a duplicate or a missing invocation. -4. **Packages and `Shared\`.** Package edges stay as in the file map: a package requires only the layer below it, declaring a lower one only to raise its floor, and builds on everything beneath it, as woocommerce also builds on WooCommerce; a new edge needs the owner. Settings and WC helpers take data and a definition object, hosts take data, and neither implements a core type, because the kernel builds nodes by class-string. Every reachable `Shared\` symbol is API, and WordPress never enters the shared package. Shared holds kernel types that code without core can use; a primitive several packages need lives in the lowest of them, public only when it fills a gap that PHP, its extensions and PHPStan's typing leave, with its siblings decided together. A kernel type enters only when it would work unchanged in any PHP project, and on the owner's ruling. Result serves only an internal operation with two or more expected failure reasons a caller branches on, declared as `AbstractResult` under `#[\NoDiscard]`; WordPress and WooCommerce APIs, filters, REST and AJAX responses and v1 public functions use `X|WP_Error`. Carriers are `final readonly`; one whose identity is its values extends `AbstractValueObject`, while a snapshot of stored data does not. `Version` parses only versions the plugin controls, never on the boot path. Framework code throws the `Shared\Exception` mirrors. A runtime check stays where a violation fails silently, can lose or expose data, or breaks a standard the class implements. A violation the platform tolerates harmlessly gets no runtime check, at most a PHPStan type. +4. **Packages and `Shared\`.** Package edges stay as in the file map: a package requires only the layer below it, declaring a lower one only to raise its floor, and builds on everything beneath it, as woocommerce also builds on WooCommerce; a new edge needs the owner. Settings and WC helpers take data and a definition object, hosts take data, and neither implements a core type, because the kernel builds nodes by class-string. Every reachable `Shared\` symbol is API, and WordPress never enters the shared package. Shared holds kernel types that code without core can use; a primitive several packages need lives in the lowest of them, public only when it fills a gap that PHP, its extensions and PHPStan's typing leave, with its siblings decided together. A kernel type enters only when it would work unchanged in any PHP project, and on the owner's ruling. Result serves only an internal operation with two or more expected failure reasons a caller branches on, declared as `AbstractResult` under `#[\NoDiscard]`; WordPress and WooCommerce APIs, filters, REST and AJAX responses and v1 public functions use `X|WP_Error`. Carriers are `final readonly`; one whose identity is its values extends `AbstractValueObject`, while a snapshot of stored data does not. `Version` parses only versions the project controls, never on the boot path. Framework code throws the `Shared\Exception` mirrors. A runtime check stays where a violation fails silently, can lose or expose data, or breaks a standard the class implements. A violation the platform tolerates harmlessly gets no runtime check, at most a PHPStan type. 5. **Safeguards.** Everything a safeguard calls lives in `includes/safeguards.php` or files it requires. The entry loads that file before `load.php` and the floor gate, so a safeguard runs on any runtime that parses the plugin: it parses on PHP 7.4 and calls only WordPress APIs present at the oldest WordPress the bootstrap package runs on, which `phpstan.bootstrap.neon` sets, or guards them. Relaxation happens only through the safeguard's own filter, after `{prefix}/boot/succeeded`, on an explicit `false`. 6. **Shared logic.** `src/` holds a plugin's composition root and its components. Code that registers no hooks and is not part of the tree, such as option keys and readers, the footprint, migration steps and asset registrars, is prefixed functions and constants in `includes/`, which `functions.php` loads. Per-request state lives in a static holder or `WC()->session`. No service locator. 7. **Migration steps.** Append-only; a retired step keeps its code or throws. Converge from any partial state; verify writes and throw on failure, including a scheduler write that returns `WP_Error`. Role capabilities are granted by `Permission\CapabilityGrants`, never by a step. Create resources resumably (lookup marker, explicit `post_author`). Use WC CRUD for orders and products. A plain step finishes well under 10 minutes; bulk work is a `BatchedMigrationStepInterface` step, run only from the runner's scheduled hook, at about 1–2 s per batch. Steps run on `wp_loaded`, so every `init` registration exists. One linear list serves every build variant. 8. **Settings.** Full keys are the plugin's prefixed option constants; WC ids are flat. Boot-time defaults are constants; translated defaults come from the plugin's text reader after `init`. Validate on write, and guard every typed read: `filter_var( $raw, FILTER_VALIDATE_*, FILTER_NULL_ON_FAILURE ) ?? $default`, or a strict `in_array()` for a choice. Direct meta writes go through `wp_slash()`. The framework detects a failed query only where it runs the query itself; a WordPress or Action Scheduler API's failure is accepted as the API reports it. The uninstall engine is the exception, because it keeps consent until a pass runs clean. Framework components store data only under the consumer's `{prefix}_`, so the plugin's footprint covers it; a `TransientCache` prefix and any generation option it names start with it, so the footprint covers the generation option, and its entries, transients that all carry a timeout, expire on their own. An `ObjectCache` group starts with it too, which keeps it apart from core's and other plugins' groups. The bootstrap updater's release caches carry the text domain instead and expire on their own. WooCommerce multiselect option keys are truthy, because WooCommerce's save drops `'0'` and `''`. 9. **Object forms** render through `Forms::render_control()`, print their nonce bound to the object and verify it before they evaluate their definition; every framework saver goes through `Forms::sanitize_fields()` and leaves absent (unrendered or disabled) fields untouched, but for limitation 4's WooCommerce checkboxes and multiselects. -10. **Translation, by-path files, Plugin Check.** Never translate at include or registration time. Framework strings use only the `'wp-framework'` domain, owned by core, settings and woocommerce; bootstrap and shared ship none, so a boot notice builds its translated message in its render callback and prints it through `Requirements::print_admin_notice()`. The bootstrap package stays 7.4-parseable, loaded by path, outside every `autoload` key, and defines code only as final classes with public static entry points; consumers require only its `load.php`, whose path is public API. Code is Plugin-Check-clean, using inline `phpcs:ignore` with a reason; local PHPCS runs the full `WordPress` standard, which covers the WordPress sniffs Plugin Check runs, and Plugin Check's own `PluginCheck` sniffs over the packages, which Plugin Check skips in a plugin's `vendor-prefixed/` (its file checks run only at release). +10. **Translation, by-path files, Plugin Check.** Never translate at include or registration time. Framework strings use only the `'wp-framework'` domain, owned by core, settings and woocommerce; bootstrap and shared ship none, so a boot notice builds its translated message in its render callback and prints it through `Requirements::print_admin_notice()`. The bootstrap package stays 7.4-parseable, loaded by path, outside every `autoload` key, and defines code only as final classes with public static entry points; consumers require only its `load.php`, whose path is public API. Code is Plugin-Check-clean, using inline `phpcs:ignore` with a reason; local PHPCS runs the full `WordPress` standard, which covers the WordPress sniffs Plugin Check runs, and Plugin Check's own `PluginCheck` sniffs over the packages, which Plugin Check skips in a plugin's `vendor-prefixed/`. ## Contract @@ -96,7 +96,7 @@ Releases follow SemVer on this contract from the first tag: a break of it ships ## Style -- Every PHP file opens with ``, which the pull request commits. CI validates the fragments with `composer changelog:validate`. + ## Filing changes - Keep one concern per pull request. - Fill in the template's breaking-changes section. - Follow the code, documentation and commit conventions in [AGENTS.md](AGENTS.md). + +## Releases + +The five packages release together under one version. A release writes the fragments into a new version block of `CHANGELOG.md`, whose version the fragments' significance sets: + +```sh +composer changelog:write +``` + +Pushing the tag `v` on that commit splits it to every mirror, where consumers' `vcs` repositories find the release. + +## WooCommerce floor + +The woocommerce package tracks the newest stable WooCommerce release until a tag. Raising the floor moves, in one commit, the manifest's `extra.requires-wc`, the root's and the fixtures' `wp-plugin/woocommerce` and `php-stubs/woocommerce-stubs` constraints, the fixtures' `WC requires at least` and `WC tested up to` headers, and the locks. The same commit re-checks that WooCommerce still uses each cache group `MetaTypes::DEFINITIONS` names, because the uninstall engine flushes them. diff --git a/README.md b/README.md index 08356715..e025a982 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,7 @@ The packages form a stack: core builds on bootstrap and shared, settings on core ## Local development ```sh -composer packages-install # PHP deps (wraps composer install, ignoring only the PHP upper bound) -npm install # Node deps (wp-env) -npm run wp-env:start # developer environment at http://localhost:8800, as local, with WooCommerce +npm run setup # PHP and Node deps, then the developer environment at http://localhost:8800, as local, with WooCommerce composer quality-check # Validation, PHPCS, PHPStan, Unit and Integration tests (starts the tests environment) ``` diff --git a/SECURITY.md b/SECURITY.md index 761fac83..cdec1ee6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,9 +2,7 @@ ## Reporting a vulnerability -Please report security vulnerabilities privately via [GitHub's security advisory form](https://github.com/ahegyes/wordpress-framework/security/advisories/new). Do not open a public issue. - -You can expect an initial response within 7 days. Once the report is triaged, you'll receive updates as fixes land. Once a fix is released, credit is given in the advisory unless you request anonymity. +Report it privately through [GitHub's security advisory form](https://github.com/ahegyes/wordpress-framework/security/advisories/new), never in a public issue. The first response comes within 7 days, and the advisory credits the reporter once a fix is released, unless they ask to stay anonymous. ## Supported versions diff --git a/composer.lock b/composer.lock index 8f22bc70..8e4cbc5d 100644 --- a/composer.lock +++ b/composer.lock @@ -2566,12 +2566,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "a09945ae024a4cb2be97d080558455b979e3f743" + "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/a09945ae024a4cb2be97d080558455b979e3f743", - "reference": "a09945ae024a4cb2be97d080558455b979e3f743", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4", + "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4", "shasum": "" }, "conflict": { @@ -2872,7 +2872,7 @@ "filament/forms": ">=3,<=3.3.52", "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4", "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4", - "filegator/filegator": "<7.8", + "filegator/filegator": "<=7.14.4", "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", "firebase/php-jwt": "<7", @@ -2950,6 +2950,7 @@ "hillelcoren/invoice-ninja": "<5.3.35", "himiklab/yii2-jqgrid-widget": "<1.0.8", "hjue/justwriting": "<=1", + "hms5232/laravel-swagger": "<0.2.1", "hov/jobfair": "<1.0.13|>=2,<2.0.2", "httpsoft/http-message": "<1.0.12", "hybridauth/hybridauth": "<=3.12.2", @@ -3733,8 +3734,8 @@ "type": "tidelift" } ], - "time": "2026-11-07T01:38:18+00:00", - "published-time": "2026-11-07T01:38:22+00:00" + "time": "2026-11-10T03:11:40+00:00", + "published-time": "2026-11-10T03:11:44+00:00" }, { "name": "sebastian/cli-parser", diff --git a/package-lock.json b/package-lock.json index 220ebecd..1ff8ad7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,6 +95,16 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { "version": "7.29.12", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.12.tgz", @@ -142,6 +152,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-create-class-features-plugin": { "version": "7.29.12", "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.12.tgz", @@ -164,6 +184,16 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-create-regexp-features-plugin": { "version": "7.29.10", "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.10.tgz", @@ -182,6 +212,16 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-define-polyfill-provider": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", @@ -1426,6 +1466,16 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/plugin-transform-shorthand-properties": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", @@ -1680,6 +1730,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/preset-env/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/preset-modules": { "version": "0.1.6-no-external-plugins", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", @@ -5992,6 +6052,63 @@ "url": "https://github.com/sponsors/gregberge" } }, + "node_modules/@svgr/core/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/@svgr/core/node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@svgr/core/node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@svgr/hast-util-to-babel-ast": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", @@ -6055,6 +6172,63 @@ "@svgr/core": "*" } }, + "node_modules/@svgr/plugin-svgo/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/@svgr/plugin-svgo/node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@svgr/plugin-svgo/node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@svgr/webpack": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-8.1.0.tgz", @@ -6613,19 +6787,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.72.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz", @@ -7492,33 +7653,6 @@ } } }, - "node_modules/@wordpress/eslint-plugin/node_modules/cosmiconfig": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@wordpress/eslint-plugin/node_modules/yaml": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", - "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, "node_modules/@wordpress/hooks": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-5.0.0.tgz", @@ -8848,6 +8982,16 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/babel-plugin-polyfill-corejs3": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", @@ -8907,9 +9051,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.34", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz", - "integrity": "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==", + "version": "2.11.35", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.35.tgz", + "integrity": "sha512-GNk8KX52CHqeNKGk8+7tZnQab/2k1t/BgrUHPKNdijzpEhF1saPQZHVKb6m5loSAxB39gyrTYw34Qf9CQ3u1qQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -9516,6 +9660,16 @@ "node": ">=8" } }, + "node_modules/check-node-version/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -10049,60 +10203,20 @@ } }, "node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", "dev": true, "license": "MIT", "dependencies": { - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" }, "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/cosmiconfig/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/cosmiconfig/node_modules/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "node": ">=10" } }, "node_modules/crc-32": { @@ -10199,19 +10313,6 @@ } } }, - "node_modules/css-loader/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -10802,6 +10903,22 @@ "node": ">= 6.0.0" } }, + "node_modules/docker-compose/node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -10961,9 +11078,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.472", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.472.tgz", - "integrity": "sha512-s4+nfT29b0FKpVKoaIxl2pWsoNcn7A/nMPSrxayw4T3BsmPeV2lF9Y06BsAQeMGhCTQ4nYJ5XST7jkqe4ZEqFw==", + "version": "1.5.473", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.473.tgz", + "integrity": "sha512-9njcRQK+6FOXNAWw5g+h2RPiyazqo5RFMVJ39ZMHjDHUNnnFlhnOGV7O0WmJetODDx19ES5p+FLi+KCVFQGugw==", "dev": true, "license": "ISC" }, @@ -11022,9 +11139,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.26.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", - "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "version": "5.26.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.1.tgz", + "integrity": "sha512-PPnnSJq3azKOIewXR/uppBV/6h98dprBJ/WaQ9Qgj10xX1lJLNfg+XShpbsrSqcDSy2yYYyhfXM1wWhftqgyzw==", "dev": true, "license": "MIT", "dependencies": { @@ -11628,6 +11745,16 @@ "node": "*" } }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-plugin-jest": { "version": "29.17.0", "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.17.0.tgz", @@ -11738,19 +11865,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-plugin-jsdoc/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-plugin-jsx-a11y": { "version": "6.10.2", "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", @@ -11980,6 +12094,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -13853,19 +13977,6 @@ "semver": "^7.7.1" } }, - "node_modules/is-bun-module/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -14346,9 +14457,9 @@ } }, "node_modules/is-unsafe": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", - "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.3.tgz", + "integrity": "sha512-ZBllczCCO267vgdFb6Tq3pv+LhHaCiGMdr+ZsuL+OopruAg7S+3ZDs1vD0RPC0uSrkjzbraxaGjtX1YBxjPeFQ==", "dev": true, "funding": [ { @@ -14697,19 +14808,6 @@ "npm": ">=6" } }, - "node_modules/jsonwebtoken/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -16469,9 +16567,9 @@ } }, "node_modules/napi-postinstall": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.7.tgz", - "integrity": "sha512-jyU4cU4kVAmy/3qjP+DcoQTNXfINHc+dFw54gGqWC8nu0mpV0kOnk7kff+J+Q18SbX0k3Np8Rm7yFjd3phatNQ==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.8.tgz", + "integrity": "sha512-dHbDC5V1g/EI8OLEtL4Iuwbu0fgzmyykxenn2KnlsX9XdhTXL3gWnfbbP+ab+oUcgV6CFpb8QGyQt/ZXA0UXig==", "dev": true, "license": "MIT", "bin": { @@ -16547,6 +16645,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/node-releases": { "version": "2.0.63", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.63.tgz", @@ -16573,19 +16681,6 @@ "node": ">=10" } }, - "node_modules/normalize-package-data/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -16676,6 +16771,13 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/npm-package-json-lint/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/npm-package-json-lint/node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -16686,6 +16788,33 @@ "node": ">=8" } }, + "node_modules/npm-package-json-lint/node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/npm-package-json-lint/node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -16707,6 +16836,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/npm-package-json-lint/node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/npm-package-json-lint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -16714,19 +16866,6 @@ "dev": true, "license": "MIT" }, - "node_modules/npm-package-json-lint/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/npm-package-json-lint/node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -17977,46 +18116,6 @@ "webpack": "^5.0.0" } }, - "node_modules/postcss-loader/node_modules/cosmiconfig": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/postcss-loader/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/postcss-loader/node_modules/yaml": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", - "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, "node_modules/postcss-media-query-parser": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", @@ -19020,9 +19119,9 @@ } }, "node_modules/read-pkg/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.3.tgz", + "integrity": "sha512-kM18BPOqTi0+TB68703/GJRpAML5Me+IDQsR1J26YB3EFKKNEyHIVZuWDX7+WKQB3tHwDwgk24BfhdpWUbytvQ==", "dev": true, "license": "ISC", "bin": { @@ -19824,13 +19923,16 @@ } }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz", + "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==", "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/send": { @@ -23352,19 +23454,13 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", - "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "dev": true, "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" + "node": ">= 6" } }, "node_modules/yargs": { diff --git a/packages/bootstrap/README.md b/packages/bootstrap/README.md index d43eadc5..21b7ff8f 100644 --- a/packages/bootstrap/README.md +++ b/packages/bootstrap/README.md @@ -79,7 +79,15 @@ if ( ! Requirements::check_floors( __FILE__ ) ) { require_once __DIR__ . '/functions.php'; ``` -The updater offers the plugin's GitHub releases through WordPress's own update screens while the plugin's `Update URI` header points at its github.com repository. It installs the release asset named after the plugin's text domain, such as `acme-persona-bootstrap.zip`, and the `dws_github_updater/config/{text-domain}` filter changes the repository, the asset or the channel, or turns updates off. It offers only tags such as `v2.1.0` or `v2.1.0-rc.1`, whose optional suffix is `-dev`, `-alpha`, `-beta` or `-rc` in lowercase, optionally numbered, because PHP's `version_compare()` ranks other suffixes above their release. +The updater offers the plugin's GitHub releases through WordPress's own update screens while the plugin's `Update URI` header points at its github.com repository. It installs the release asset named after the plugin's text domain, such as `acme-persona-bootstrap.zip`. The `dws_github_updater/config/{text-domain}` filter receives and returns `enabled`, `repository` as `owner/name`, `asset` and `channel`: an installed version with a suffix follows the `prerelease` channel, the highest of the ten newest releases, and any other version the `stable` channel, the latest release. It offers only tags such as `v2.1.0` or `v2.1.0-rc.1`, whose optional suffix is `-dev`, `-alpha`, `-beta` or `-rc` in lowercase, optionally numbered, because PHP's `version_compare()` misorders other suffixes, such as `-pre`, `-patch` or `-Beta`. + +`Requirements::print_admin_notice()` prints a notice to users who can activate plugins, as `wp_admin_notice()` prints it, so a plugin can report a problem before it boots: + +```php +function acme_pb_render_notice(): void { + Requirements::print_admin_notice( esc_html__( 'Acme Persona Bootstrap is running.', 'acme-persona-bootstrap' ), array( 'type' => 'info' ) ); +} +``` ## Contract and versioning diff --git a/packages/bootstrap/load.php b/packages/bootstrap/load.php index 1aae4203..358e4976 100644 --- a/packages/bootstrap/load.php +++ b/packages/bootstrap/load.php @@ -8,9 +8,7 @@ * @package DeepWebSolutions\Framework\Bootstrap */ -namespace DeepWebSolutions\Framework\Bootstrap; - -\defined( 'ABSPATH' ) || exit; +defined( 'ABSPATH' ) || exit; require_once __DIR__ . '/src/Requirements.php'; require_once __DIR__ . '/src/Updater.php'; diff --git a/packages/bootstrap/src/Requirements.php b/packages/bootstrap/src/Requirements.php index 97b6223e..9b22377b 100644 --- a/packages/bootstrap/src/Requirements.php +++ b/packages/bootstrap/src/Requirements.php @@ -5,7 +5,7 @@ \defined( 'ABSPATH' ) || exit; /** - * Keeps a plugin from loading code its PHP or WordPress cannot run. + * Keeps a plugin from loading code its PHP or WordPress cannot run, and prints the notices that report it. * * @since 2.0.0 * @version 2.0.0 @@ -71,14 +71,15 @@ static function () use ( $plugin_file ): void { /** * Prints an admin notice about the plugin to users who can activate plugins, as wp_admin_notice() prints it. * - * The framework's boot checks report through it, because they run before the plugin's own admin notices exist or without them. An error prints its message without a paragraph wrap unless the arguments set one, since WordPress core's requirement messages are already paragraphs. Before WordPress 6.4, which has no wp_admin_notice(), it prints the same markup and honors only the type and paragraph_wrap arguments. + * It needs nothing beyond WordPress itself, so it reports problems found before a plugin has booted. An error prints its message without a paragraph wrap unless the arguments set one, since WordPress core's requirement messages are already paragraphs. Before WordPress 6.4, which has no wp_admin_notice(), it prints the same markup and honors only the type and paragraph_wrap arguments. * * @since 2.0.0 * @version 2.0.0 * + * @phpstan-param array{type?: string, dismissible?: bool, id?: string, additional_classes?: array, attributes?: array, paragraph_wrap?: bool} $args + * * @param string|\WP_Error $message The message, as HTML, or the error whose message to print. * @param array $args Optional. The notice's arguments, as wp_admin_notice() takes them. Default is an error notice. - * @phpstan-param array{type?: string, dismissible?: bool, id?: string, additional_classes?: array, attributes?: array, paragraph_wrap?: bool} $args * * @return void */ diff --git a/packages/bootstrap/src/Updater.php b/packages/bootstrap/src/Updater.php index 355f8558..7e557bbe 100644 --- a/packages/bootstrap/src/Updater.php +++ b/packages/bootstrap/src/Updater.php @@ -31,7 +31,7 @@ protected function __construct() {} /** * Offers the plugin's GitHub releases as updates, and answers its "View details" request, while its Update URI points at github.com. * - * The Update URI is read whenever WordPress asks, so the updater answers nothing once it points elsewhere. A release that needs a newer WordPress is not offered, because WordPress's automatic updates check only the PHP requirement. WordPress asks for Update URI offers from version 5.8, so below it no update is offered. + * The Update URI is read whenever WordPress asks, so the updater answers nothing once it points elsewhere. A release that needs a newer WordPress is not offered, because WordPress's automatic updates check only the PHP requirement. * * @since 2.0.0 * @version 2.0.0 @@ -49,9 +49,13 @@ static function ( $update, $plugin_data, $file ) use ( $plugin_file, $basename ) return $update; } - $config = self::get_updater_config( $plugin_file ); - $release = null === $config ? null : self::get_release( $config ); - if ( null === $config || null === $release || ! is_wp_version_compatible( $release['requires'] ) ) { + $config = self::get_updater_config( $plugin_file ); + if ( \is_null( $config ) ) { + return false; + } + + $release = self::get_release( $config ); + if ( \is_null( $release ) || ! is_wp_version_compatible( $release['requires'] ) ) { return false; } @@ -77,7 +81,7 @@ static function ( $result, $action, $args ) use ( $plugin_file ) { } $config = self::get_updater_config( $plugin_file ); - if ( null === $config || ( \get_object_vars( $args )['slug'] ?? null ) !== $config['slug'] ) { + if ( \is_null( $config ) || ( $args->slug ?? null ) !== $config['slug'] ) { return $result; } @@ -92,7 +96,7 @@ static function ( $result, $action, $args ) use ( $plugin_file ) { ); $release = self::get_release( $config ); - if ( null !== $release ) { + if ( ! \is_null( $release ) ) { $information = array( 'version' => $release['version'], 'download_link' => $release['package'], @@ -179,9 +183,9 @@ protected static function get_updater_config( string $plugin_file ): ?array { } /** - * Returns the newest usable release on the configured channel, from the plugin's cache while it holds one, and otherwise from GitHub with the requirements of its tagged entry file. + * Returns the channel's newest release when its asset and the requirements of its tagged entry file are usable, from the plugin's cache while it holds one and otherwise from GitHub, or null. * - * A usable release is cached for an hour, and a failed fetch or an unusable release as empty for five minutes, so update checks back off from a failing API. GitHub's release list is not ordered by version, so the prerelease channel takes the highest version among the ten it lists first, stable releases included. + * A failed fetch or an unusable release is cached as empty, so update checks back off from a failing API. GitHub's release list is not ordered by version, so the prerelease channel takes the highest version among the ten it lists first, stable releases included. * * @since 2.0.0 * @version 2.0.0 @@ -197,28 +201,31 @@ protected static function get_release( array $config ): ?array { return null; } - $release = self::read_release( $cached ); - if ( null !== $release ) { - return $release; + $cached_release = self::read_release( $cached ); + if ( ! \is_null( $cached_release ) ) { + return $cached_release; } $body = self::get_response_body( "https://api.github.com/repos/{$config['repository']}/" . ( 'prerelease' === $config['channel'] ? 'releases?per_page=10' : 'releases/latest' ), array( 'Accept' => 'application/vnd.github.html+json' ) ); - $releases = null === $body ? null : \json_decode( $body, true ); + $releases = \is_null( $body ) ? null : \json_decode( $body, true ); - $newest = array(); - $tag = ''; - $version = ''; - foreach ( ( \is_array( $releases ) && 'prerelease' === $config['channel'] ) ? $releases : array( $releases ) as $candidate ) { + $candidates = \is_array( $releases ) && 'prerelease' === $config['channel'] ? $releases : array( $releases ); + $newest = array(); + $tag = ''; + $version = ''; + foreach ( $candidates as $candidate ) { if ( ! \is_array( $candidate ) || true === ( $candidate['draft'] ?? false ) || ! \is_string( $candidate['tag_name'] ?? null ) ) { continue; } $candidate_version = \ltrim( $candidate['tag_name'], 'v' ); - if ( 1 !== \preg_match( '/^\d+(?:\.\d+)*(?:-(?:dev|alpha|beta|rc)(?:\.?\d+)?)?$/', $candidate_version ) ) { // PHP's version_compare() orders only these labels below their release as their names say, and misorders others, such as 'pre', 'patch' or 'Beta'. + // PHP's version_compare() orders only these labels below their release as their names say, and misorders others, such as 'pre', 'patch' or 'Beta'. + if ( 1 !== \preg_match( '/^\d+(?:\.\d+)*(?:-(?:dev|alpha|beta|rc)(?:\.?\d+)?)?$/', $candidate_version ) ) { continue; } - if ( 'stable' === $config['channel'] && false !== \strpos( $candidate_version, '-' ) ) { // GitHub's prerelease flag is set by hand, so a suffixed tag can be the latest release. + // GitHub's prerelease flag is set by hand, so a suffixed tag can be the latest release. + if ( 'stable' === $config['channel'] && false !== \strpos( $candidate_version, '-' ) ) { continue; } @@ -237,8 +244,9 @@ protected static function get_release( array $config ): ?array { } } - $entry_file = null === $package ? null : self::get_response_body( "https://raw.githubusercontent.com/{$config['repository']}/" . \rawurlencode( $tag ) . "/{$config['file']}" ); // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Reads the release's plugin headers, never served to visitors. - if ( null !== $entry_file ) { + $release = null; + $entry_file = \is_null( $package ) ? null : self::get_response_body( "https://raw.githubusercontent.com/{$config['repository']}/" . \rawurlencode( $tag ) . "/{$config['file']}" ); // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Reads the release's plugin headers, never served to visitors. + if ( ! \is_null( $entry_file ) ) { // WordPress's get_file_data() reads only paths, so the fetched entry file is parsed with the same pattern. $entry_file = \str_replace( "\r", "\n", \substr( $entry_file, 0, 8 * KB_IN_BYTES ) ); $headers = array( @@ -266,7 +274,7 @@ protected static function get_release( array $config ): ?array { } } - set_transient( $transient, $release ?? array(), null === $release ? 5 * MINUTE_IN_SECONDS : HOUR_IN_SECONDS ); + set_transient( $transient, $release ?? array(), \is_null( $release ) ? 5 * MINUTE_IN_SECONDS : HOUR_IN_SECONDS ); return $release; } @@ -303,7 +311,7 @@ protected static function read_release( $value ): ?array { * @version 2.0.0 * * @param string $url The URL. - * @param array $headers Optional. The request's headers. Default none. + * @param array $headers Optional. The request's headers. Default is none. * * @return string|null */ diff --git a/packages/core/README.md b/packages/core/README.md index efcc3920..ceb47b32 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -4,7 +4,7 @@ The plugin kernel. A plugin is composed of small components that load only where ## Requirements -It builds on wp-framework-bootstrap and wp-framework-shared, and on the PSR container, log, clock and simple-cache interfaces. Its `composer.json` holds its PHP floor under `require.php` and its WordPress floor under `extra.requires-wp`, which a plugin's `Requires PHP` and `Requires at least` headers meet. The kernel builds components through a PSR-11 container the plugin chooses. +It builds on wp-framework-bootstrap and wp-framework-shared, on the PSR container, log, clock and simple-cache interfaces, and on PHP's filter extension. Its `composer.json` holds its PHP floor under `require.php` and its WordPress floor under `extra.requires-wp`, which a plugin's `Requires PHP` and `Requires at least` headers meet. The kernel builds components through a PSR-11 container the plugin chooses. ## Installation @@ -112,7 +112,7 @@ function acme_pc_boot(): void { } ``` -One composition root builds the plugin. `PluginBoot` runs its composition and, when it throws, logs the failure and tells administrators instead of breaking the site, rethrowing only in local and development environments; the composition registers the scheduler and the migration runner, over the steps that the plugin's `includes/migrations.php` returns as prefixed functions, then boots the kernel, which builds each top-level component through the container and registers its hooks: +One composition root builds the plugin. `PluginBoot` runs its composition and, when it throws, logs the failure and tells administrators instead of breaking the site, rethrowing only in local and development environments. The composition registers the migration runner, over the steps that the plugin's `includes/migrations.php` returns as prefixed functions, then boots the kernel, which builds each top-level component through the container, the scheduler first, and registers its hooks: ```php use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; @@ -121,6 +121,7 @@ use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationR use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\PluginBoot; use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\PluginKernel; use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Scheduling\Scheduler; +use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Scheduling\SchedulerInterface; use Acme\PersonaCore\Scoped\DI\ContainerBuilder; use Acme\PersonaCore\Scoped\Psr\Log\LoggerInterface as PsrLoggerInterface; @@ -130,6 +131,7 @@ final class Plugin { // ... public const array COMPONENTS = array( + Scheduler::class, AdminNotices::class, Greeting::class, ); @@ -138,24 +140,23 @@ final class Plugin { public function __construct() { $this->logger = new ErrorLogLogger( self::SLUG ); - $this->boot = new PluginBoot( ACME_PC_FILE, self::PREFIX, $this->logger ); $this->scheduler = new Scheduler( self::PREFIX ); + $this->boundary = new PluginBoot( ACME_PC_FILE, self::PREFIX, $this->logger ); } // ... public function boot(): void { - $this->boot->run( $this->compose( ... ) ); + $this->boundary->run( $this->compose( ... ) ); } // ... protected function compose(): bool { - $this->scheduler->register_hooks(); $this->migrations = new MigrationRunner( ACME_PC_FILE, self::PREFIX . '_revision', acme_pc_get_migration_steps(), $this->logger, $this->scheduler ); $this->migrations->register_hooks(); - $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger ) )->build() ); + $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() ); $this->kernel->boot( self::COMPONENTS ); return true; @@ -163,9 +164,12 @@ final class Plugin { // ... - protected function get_definitions( PsrLoggerInterface $logger ): array { + protected function get_definitions( PsrLoggerInterface $logger, Scheduler $scheduler ): array { return array( - AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::PREFIX, $logger ), + PsrLoggerInterface::class => static fn (): PsrLoggerInterface => $logger, + Scheduler::class => static fn (): Scheduler => $scheduler, + SchedulerInterface::class => static fn (): Scheduler => $scheduler, + AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::PREFIX, $logger ), ); } @@ -188,6 +192,34 @@ final class Greeting implements ComponentInterface { } ``` +The plugin's `uninstall.php` hands its footprint to `Uninstaller::run()`, which unschedules the plugin's jobs on every site and deletes the rest of the footprint on each site that consents. The footprint names the plugin's data by kind: `options`, `option_prefixes`, `transients`, `network_options`, `post_types`, `comment_types`, `meta` and `meta_prefixes` by meta type, `user_meta_site`, `user_meta_global` and their `_prefixes`, `capabilities`, `tables`, `cron_hooks`, `as_hooks` and `as_groups`. A site consents through an option that holds `yes`, a key holding `yes` in an option's array, or a constant defined as `true`. The file loads the engine as the main file does, and on a site below the floors or with an incomplete build it keeps the data: + +```php +function acme_pc_get_footprint(): array { + return array( + 'option_prefixes' => array( Plugin::PREFIX . '_' ), + 'user_meta_site_prefixes' => array( Plugin::PREFIX . '_' ), + 'user_meta_global_prefixes' => array( Plugin::PREFIX . '_' ), + 'network_options' => array( Plugin::PREFIX . '_network_notices' ), + 'cron_hooks' => array( Plugin::PREFIX . '_revision/migration/run' ), + 'as_groups' => array( Plugin::PREFIX ), + ); +} +``` + +```php +Uninstaller::run( + $acme_pc_file, + $acme_pc_footprint, + array( + 'consent' => array( + 'option' => $acme_pc_consent, + 'key' => 'delete_data', + ), + ) +); +``` + ## Contract and versioning The five packages release together under one version, which the [changelog](https://github.com/ahegyes/wordpress-framework/blob/trunk/CHANGELOG.md) records. diff --git a/packages/core/src/AdminNotice/AdminNotices.php b/packages/core/src/AdminNotice/AdminNotices.php index 387d9593..fb1f7f83 100644 --- a/packages/core/src/AdminNotice/AdminNotices.php +++ b/packages/core/src/AdminNotice/AdminNotices.php @@ -69,7 +69,7 @@ final class AdminNotices implements ComponentInterface { * * @var bool */ - protected bool $needs_dismiss_script = false; + protected bool $needs_dismissal_script = false; // endregion @@ -81,8 +81,8 @@ final class AdminNotices implements ComponentInterface { * @since 2.0.0 * @version 2.0.0 * - * @param string $prefix The plugin's prefix, matching PREFIX_PATTERN without a reserved segment. - * @param PsrLoggerInterface $logger The logger that records storage failures. + * @param string $prefix The plugin's prefix: a lowercase letter followed by up to 31 lowercase letters, digits and underscores, with no network or hidden segment. + * @param PsrLoggerInterface $logger The logger that records failures to read, write or filter notices. * * @throws InvalidArgumentException Thrown when the prefix is invalid. */ @@ -99,6 +99,28 @@ public function __construct( // endregion + // region GETTERS + + /** + * Returns the scope's unexpired notices for the current site, network or user, in the order they were first added. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param NoticeScope $scope The scope. + * @param int $user_id Optional. The user of user notices, where 0 is the current user. Default is 0. + * + * @throws InvalidArgumentException Thrown when a user is given for another scope. + * @throws LogicException Thrown when user notices have no user and nobody is logged in. + * + * @return list + */ + public function get_notices( NoticeScope $scope, int $user_id = 0 ): array { + return \array_values( $this->read( $scope, $this->get_owner( $scope, $user_id ) ) ); + } + + // endregion + // region INHERITED METHODS /** @@ -110,9 +132,9 @@ public function __construct( #[\Override] public function register_hooks(): void { add_action( 'all_admin_notices', array( $this, 'render_notices' ) ); - add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_editor_script' ) ); + add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_delivery_script' ) ); add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) ); - add_action( 'admin_footer', array( $this, 'enqueue_dismiss_script' ) ); + add_action( 'admin_footer', array( $this, 'enqueue_dismissal_script' ) ); } // endregion @@ -128,7 +150,7 @@ public function register_hooks(): void { * @version 2.0.0 * * @param Notice $notice The notice. - * @param int $user_id Optional. The user of a user notice, where 0 is the current user. Default 0. + * @param int $user_id Optional. The user of a user notice, where 0 is the current user. Default is 0. * * @throws InvalidArgumentException Thrown when a user is given for a notice of another scope. * @throws LogicException Thrown when a user notice has no user and nobody is logged in. @@ -158,7 +180,7 @@ public function add( Notice $notice, int $user_id = 0 ): void { * * @param string $id The notice's id. * @param NoticeScope $scope The notice's scope. - * @param int $user_id Optional. The user of a user notice, where 0 is the current user. Default 0. + * @param int $user_id Optional. The user of a user notice, where 0 is the current user. Default is 0. * * @throws InvalidArgumentException Thrown when a user is given for another scope. * @throws LogicException Thrown when a user notice has no user and nobody is logged in. @@ -169,123 +191,6 @@ public function remove( string $id, NoticeScope $scope, int $user_id = 0 ): void $this->delete( $scope, $this->get_owner( $scope, $user_id ), $id ); } - /** - * Returns the scope's unexpired notices for the current site, network or user, in the order they were first added. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param NoticeScope $scope The scope. - * @param int $user_id Optional. The user of user notices, where 0 is the current user. Default 0. - * - * @throws InvalidArgumentException Thrown when a user is given for another scope. - * @throws LogicException Thrown when user notices have no user and nobody is logged in. - * - * @return list - */ - public function get_notices( NoticeScope $scope, int $user_id = 0 ): array { - return \array_values( $this->read( $scope, $this->get_owner( $scope, $user_id ) ) ); - } - - /** - * Returns the notices the current user sees here in scope order, or for the block editor the site, network and user one-shots that name no screens. - * - * Persistent notices wait for a classic screen, whose dismissal they need, and the block editor's delivery endpoint has no screen a notice could name. A scope whose storage throws is logged and left out. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param bool $block_editor Whether to select the block editor's notices. - * - * @return list - */ - protected function select( bool $block_editor ): array { - $user_id = get_current_user_id(); - if ( 0 === $user_id ) { - return array(); - } - - $selected = array(); - foreach ( self::ORDER as $scope ) { - $shows_here = match ( true ) { - $block_editor => NoticeScope::Request !== $scope, - is_network_admin() => NoticeScope::Site !== $scope, - is_user_admin() => NoticeScope::Request === $scope || NoticeScope::User === $scope, - default => is_blog_admin(), - }; - if ( ! $shows_here ) { - continue; - } - - try { - foreach ( $this->read( $scope, $this->get_owner( $scope, 0 ) ) as $notice ) { - if ( $block_editor && ( $notice->persistent || array() !== $notice->screens ) ) { - continue; - } - - if ( array() !== $notice->screens && ! \in_array( get_current_screen()?->id, $notice->screens, true ) ) { - continue; - } - - if ( null !== $notice->capability && ! current_user_can( $notice->capability ) ) { - continue; - } - - $state = $notice->is_shared() ? null : ( $this->get_hidden( $scope, $user_id )[ self::get_hidden_key( $scope, $notice->id ) ]['state'] ?? null ); - if ( 'dismissed' === $state || ( 'delivered' === $state && ! $notice->persistent ) ) { - continue; - } - - /** - * Filters whether a notice shows to the current user, once its screens, its capability and the user's dismissal allow it. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param bool $show Whether the notice shows. Only true shows it. - * @param string $id The notice's id. - * @param string $scope The notice's scope. - */ - if ( true === apply_filters( "{$this->prefix}/admin_notice/should_show", true, $notice->id, $scope->value ) ) { - $selected[] = $notice; - } - } - } catch ( \Throwable $exception ) { - $this->log( 'Could not select the {scope} notices: {message}', $scope, $exception ); - } - } - - return $selected; - } - - /** - * Records that the current user was shown a one-shot notice, deleting it unless other viewers still need to see it. - * - * A throw from storage is logged, so the notice may show again. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param Notice $notice The notice as it was selected. - * - * @return void - */ - protected function acknowledge( Notice $notice ): void { - if ( $notice->persistent ) { - return; - } - - try { - if ( NoticeScope::Request === $notice->scope || NoticeScope::User === $notice->scope || $notice->is_shared() ) { - $this->delete( $notice->scope, $this->get_owner( $notice->scope, 0 ), $notice->id ); - } else { - $this->hide( $notice->scope, $notice->id, get_current_user_id(), 'delivered' ); - } - } catch ( \Throwable $exception ) { - $this->log( 'Could not acknowledge a {scope} notice: {message}', $notice->scope, $exception ); - } - } - // endregion // region HOOKS @@ -313,7 +218,7 @@ public function render_notices(): void { ); if ( $notice->persistent && $notice->dismissible ) { $attributes['data-notice-nonce'] = wp_create_nonce( $this->get_nonce_action( $notice->scope, $notice->id ) ); - $this->needs_dismiss_script = true; + $this->needs_dismissal_script = true; } wp_admin_notice( @@ -374,23 +279,21 @@ public function register_rest_routes(): void { } /** - * Enqueues, on the post editor, the script that shows the current user's block-editor notices, fetched from the delivery endpoint as the editor loads with one-shots waiting and after each save that is not an autosave. - * - * The load only reports whether one-shots wait, because the meta boxes' save loads the edit screen in the background and discards it. The editor.savePost handler runs at priority 20, after the meta boxes save at 10, so it fetches the rejections that save reported, and it never throws, because a throw there reports the save as failed. Messages show as HTML, as on classic screens. + * Enqueues, on the post editor, the script that shows the current user's block-editor notices. * * @since 2.0.0 * @version 2.0.0 * * @return void */ - public function enqueue_editor_script(): void { + public function enqueue_delivery_script(): void { // The other block editors, such as the site editor while it browses, show no notices, so a one-shot delivered there would be lost. $screen = \function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( true !== $screen?->is_block_editor() || 'post' !== $screen->base ) { return; } - $handle = "{$this->prefix}-notice-delivery"; + $handle = "$this->prefix-notice-delivery"; $settings = wp_json_encode( array( 'prefix' => $this->prefix, @@ -399,7 +302,7 @@ public function enqueue_editor_script(): void { ) ); - wp_register_script( $handle, false, array( 'wp-api-fetch', 'wp-data', 'wp-hooks', 'wp-notices' ), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- A handle without a source loads no file to cache. + wp_register_script( $handle, false, array( 'wp-a11y', 'wp-api-fetch', 'wp-data', 'wp-hooks', 'wp-notices' ), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- A handle without a source loads no file to cache. wp_add_inline_script( $handle, <<<'JS' @@ -410,17 +313,25 @@ function deliver() { wp.data.dispatch( 'core/notices' ).createNotice( notice.status, notice.content, { id: notice.id, isDismissible: notice.isDismissible, + speak: false, + // Messages show as HTML, as on classic screens. __unstableHTML: true } ); + // The notices store would read the markup aloud with its entities spelled out. + wp.a11y.speak( notice.spoken, 'error' === notice.status ? 'assertive' : 'polite' ); } ); } ).catch( function ( error ) { + // A throw from an editor.savePost handler reports the save as failed. console.error( 'The notice delivery failed.', error ); } ); } + // The page only reports whether one-shots wait, because the meta boxes' save loads this screen in the background and discards it. if ( settings.waiting ) { deliver(); } + + // After the meta boxes save at priority 10, so the delivery brings the rejections that save reported. wp.hooks.addAction( 'editor.savePost', settings.prefix + '/notices', function ( post, options ) { if ( ! ( options && options.isAutosave ) ) { deliver(); @@ -442,12 +353,12 @@ function deliver() { * * @return void */ - public function enqueue_dismiss_script(): void { - if ( ! $this->needs_dismiss_script ) { + public function enqueue_dismissal_script(): void { + if ( ! $this->needs_dismissal_script ) { return; } - $handle = "{$this->prefix}-notice-dismissal"; + $handle = "$this->prefix-notice-dismissal"; $settings = wp_json_encode( array( 'prefix' => $this->prefix, @@ -502,7 +413,7 @@ public function rest_dismiss( \WP_REST_Request $request ): \WP_REST_Response|\WP $nonce = $params['nonce'] ?? null; $user_id = get_current_user_id(); - if ( null === $scope || ! \is_string( $id ) || 1 !== \preg_match( Notice::ID_PATTERN, $id ) || ! \is_string( $nonce ) ) { + if ( \is_null( $scope ) || ! \is_string( $id ) || 1 !== \preg_match( Notice::ID_PATTERN, $id ) || ! \is_string( $nonce ) ) { return new \WP_Error( 'rest_invalid_param', 'The notice scope, id or nonce is invalid.', array( 'status' => 400 ) ); } @@ -515,16 +426,16 @@ public function rest_dismiss( \WP_REST_Request $request ): \WP_REST_Response|\WP $notice = null; if ( NoticeScope::Request !== $scope ) { $notice = $this->read( $scope, $owner )[ $id ] ?? null; - if ( null === $notice ) { + if ( \is_null( $notice ) ) { return new \WP_Error( 'rest_notice_invalid_id', 'No stored notice has that id.', array( 'status' => 404 ) ); } - if ( ! $notice->persistent || ! $notice->dismissible || ( null !== $notice->capability && ! current_user_can( $notice->capability ) ) ) { + if ( ! $notice->persistent || ! $notice->dismissible || ( ! \is_null( $notice->capability ) && ! current_user_can( $notice->capability ) ) ) { return new \WP_Error( 'rest_forbidden', 'This user cannot dismiss that notice.', array( 'status' => 403 ) ); } } - $changed = null !== $notice && $notice->is_shared() ? $this->delete( $scope, $owner, $id ) : $this->hide( $scope, $id, $user_id, 'dismissed' ); + $changed = ! \is_null( $notice ) && $notice->is_shared() ? $this->delete( $scope, $owner, $id ) : $this->hide( $scope, $id, $user_id, 'dismissed' ); } catch ( \Throwable $exception ) { $this->log( 'Could not dismiss a {scope} notice: {message}', $scope, $exception ); return new \WP_Error( 'rest_notice_store_error', 'The notice store failed.', array( 'status' => 500 ) ); @@ -542,27 +453,32 @@ public function rest_dismiss( \WP_REST_Request $request ): \WP_REST_Response|\WP * @param int $user_id The user who dismissed it. * @param int $owner The notice's owner: 0 for a request notice, otherwise the user, blog or network id. */ - do_action( "{$this->prefix}/admin_notice/dismissed", $id, $scope->value, $user_id, $owner ); + do_action( "$this->prefix/admin_notice/dismissed", $id, $scope->value, $user_id, $owner ); } return new \WP_REST_Response( array( 'changed' => $changed ) ); } /** - * Returns the one-shots the block editor shows the current user, as its notices store takes them, and acknowledges them. + * Returns the one-shots the block editor shows the current user, as its notices store takes them with a plain-text copy to speak, and acknowledges them. * * @since 2.0.0 * @version 2.0.0 * - * @return list + * @return list */ public function rest_deliver(): array { $delivered = array(); foreach ( $this->select( true ) as $notice ) { + $content = wp_kses_post( $this->get_message( $notice ) ); + + // A space before each block element keeps a screen reader from running paragraphs and list items together. + $spaced = (string) \preg_replace( '#<(?=/?(?:p|br|div|li|ul|ol|h[1-6]|blockquote|pre|t[rdh]|d[ldt]|hr)\b)#i', ' <', $content ); $delivered[] = array( 'id' => $this->get_dom_id( $notice ), 'status' => $notice->type->value, - 'content' => wp_kses_post( $this->get_message( $notice ) ), + 'content' => $content, + 'spoken' => \html_entity_decode( wp_strip_all_tags( $spaced, true ), \ENT_QUOTES | \ENT_HTML5, 'UTF-8' ), 'isDismissible' => $notice->dismissible, ); $this->acknowledge( $notice ); @@ -575,6 +491,105 @@ public function rest_deliver(): array { // region HELPERS + /** + * Returns the notices the current user sees here in scope order, or for the block editor the site, network and user one-shots that name no screens. + * + * Persistent notices wait for a classic screen, whose dismissal they need, and the block editor's delivery endpoint has no screen a notice could name. A throw while a scope's notices are read or filtered is logged and ends that scope's selection. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param bool $block_editor Whether to select the block editor's notices. + * + * @return list + */ + protected function select( bool $block_editor ): array { + $user_id = get_current_user_id(); + if ( 0 === $user_id ) { + return array(); + } + + $selected = array(); + foreach ( self::ORDER as $scope ) { + $shows_here = match ( true ) { + $block_editor => NoticeScope::Request !== $scope, + is_network_admin() => NoticeScope::Site !== $scope, + is_user_admin() => NoticeScope::Request === $scope || NoticeScope::User === $scope, + default => is_blog_admin(), + }; + if ( ! $shows_here ) { + continue; + } + + try { + foreach ( $this->read( $scope, $this->get_owner( $scope, 0 ) ) as $notice ) { + if ( $block_editor && ( $notice->persistent || array() !== $notice->screens ) ) { + continue; + } + + if ( array() !== $notice->screens && ! \in_array( get_current_screen()?->id, $notice->screens, true ) ) { + continue; + } + + if ( ! \is_null( $notice->capability ) && ! current_user_can( $notice->capability ) ) { + continue; + } + + $state = $notice->is_shared() ? null : ( $this->get_hidden( $scope, $user_id )[ self::get_hidden_key( $scope, $notice->id ) ]['state'] ?? null ); + if ( 'dismissed' === $state || ( 'delivered' === $state && ! $notice->persistent ) ) { + continue; + } + + /** + * Filters whether a notice shows to the current user, once its screens, its capability and the user's dismissal allow it. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param bool $show Whether the notice shows. Only true shows it. + * @param string $id The notice's id. + * @param string $scope The notice's scope. + */ + if ( true === apply_filters( "$this->prefix/admin_notice/should_show", true, $notice->id, $scope->value ) ) { + $selected[] = $notice; + } + } + } catch ( \Throwable $exception ) { + $this->log( 'Could not select the {scope} notices: {message}', $scope, $exception ); + } + } + + return $selected; + } + + /** + * Records that the current user was shown a one-shot notice, deleting it unless other viewers still need to see it. + * + * A throw from storage is logged, so the notice may show again. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param Notice $notice The notice as it was selected. + * + * @return void + */ + protected function acknowledge( Notice $notice ): void { + if ( $notice->persistent ) { + return; + } + + try { + if ( NoticeScope::Request === $notice->scope || NoticeScope::User === $notice->scope || $notice->is_shared() ) { + $this->delete( $notice->scope, $this->get_owner( $notice->scope, 0 ), $notice->id ); + } else { + $this->hide( $notice->scope, $notice->id, get_current_user_id(), 'delivered' ); + } + } catch ( \Throwable $exception ) { + $this->log( 'Could not acknowledge a {scope} notice: {message}', $notice->scope, $exception ); + } + } + /** * Returns the owner of the scope's notices: 0 for the request, otherwise the user, or the current blog or network, so code inside switch_to_blog() addresses the switched site. * @@ -592,7 +607,7 @@ public function rest_deliver(): array { protected function get_owner( NoticeScope $scope, int $user_id ): int { if ( NoticeScope::User !== $scope ) { if ( 0 !== $user_id ) { - throw new InvalidArgumentException( "A $scope->value notice belongs to no user, but user $user_id was given." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + throw new InvalidArgumentException( "A $scope->value notice belongs to no user, but user '$user_id' was given." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. } return match ( $scope ) { @@ -604,7 +619,7 @@ protected function get_owner( NoticeScope $scope, int $user_id ): int { $user_id = $user_id ?: get_current_user_id(); if ( $user_id < 1 ) { - throw new LogicException( "A user notice needs a user, but it has user $user_id." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + throw new LogicException( "A user notice needs a user, but it has user '$user_id'." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. } return $user_id; @@ -634,7 +649,7 @@ protected function read( NoticeScope $scope, int $owner ): array { $notices = array(); foreach ( \is_array( $rows ) ? $rows : array() as $row ) { $notice = \is_array( $row ) ? Notice::from_array( $row ) : null; - if ( null !== $notice && $scope === $notice->scope && ( null === $notice->expires_at || $notice->expires_at > \time() ) ) { + if ( ! \is_null( $notice ) && $scope === $notice->scope && ( \is_null( $notice->expires_at ) || $notice->expires_at > \time() ) ) { $notices[ $notice->id ] = $notice; } } @@ -787,18 +802,18 @@ protected function get_message( Notice $notice ): string { * @param string $id The notice's id. * @param string $scope The notice's scope. */ - $message = apply_filters( "{$this->prefix}/admin_notice/message", $notice->message, $notice->id, $notice->scope->value ); + $message = apply_filters( "$this->prefix/admin_notice/message", $notice->message, $notice->id, $notice->scope->value ); return \is_string( $message ) ? $message : $notice->message; } /** - * Logs a storage failure as an error. + * Logs a failure to read, write or filter a scope's notices as an error. * * @since 2.0.0 * @version 2.0.0 * * @param string $message The message, with {scope} and {message} placeholders. - * @param NoticeScope $scope The scope whose storage failed. + * @param NoticeScope $scope The scope whose notices failed. * @param \Throwable $exception The failure. * * @return void @@ -825,7 +840,7 @@ protected function log( string $message, NoticeScope $scope, \Throwable $excepti * @return string */ protected function get_dom_id( Notice $notice ): string { - return "{$this->prefix}-{$notice->scope->value}-notice-{$notice->id}"; + return "$this->prefix-{$notice->scope->value}-notice-$notice->id"; } /** diff --git a/packages/core/src/AdminNotice/Notice.php b/packages/core/src/AdminNotice/Notice.php index 905456d9..37e23bda 100644 --- a/packages/core/src/AdminNotice/Notice.php +++ b/packages/core/src/AdminNotice/Notice.php @@ -10,7 +10,7 @@ /** * An admin notice: what it says, to whom, on which screens and for how long. * - * Stores keep the row that to_array() returns rather than the object, because each plugin scopes this class under its own namespace. + * AdminNotices keeps the row that to_array() returns rather than the object, because each plugin scopes this class under its own namespace. * * @since 2.0.0 * @version 2.0.0 @@ -40,16 +40,16 @@ * @since 2.0.0 * @version 2.0.0 * - * @param string $id The id, unique among its scope's notices and matching ID_PATTERN; a viewer's recorded dismissal or delivery outlives the notice, so a new occasion needs a new id. + * @param string $id The id, unique among its scope's notices and matching ID_PATTERN. A viewer's recorded dismissal or delivery outlives the notice, so a new occasion needs a new id. * @param string $message The message, as HTML in the locale of the request that creates the notice. * @param NoticeType $type Optional. The kind of notice. Default is an error. * @param NoticeScope $scope Optional. Where the notice belongs. Default is a user. - * @param bool $persistent Optional. Whether the notice shows until it is dismissed, removed or expired, rather than once. Default false. - * @param bool $dismissible Optional. Whether a viewer can dismiss the notice. Default true. + * @param bool $persistent Optional. Whether the notice shows until it is dismissed, removed or expired, rather than once. Default is false. + * @param bool $dismissible Optional. Whether a viewer can dismiss the notice. Default is true. * @param string|null $capability Optional. The capability a viewer needs, which site and network notices require. Default is none. * @param list $screens Optional. The ids of the screens the notice shows on. Default is every screen of its scope's surfaces. * @param int|null $expires_at Optional. The Unix time at which the notice expires. Default is never. - * @param bool $per_viewer Optional. Whether each viewer dismisses a site or network notice, and sees it as a one-shot, for themselves rather than for everyone, as user and request notices always do. Default false. + * @param bool $per_viewer Optional. Whether each viewer dismisses a site or network notice, and sees it as a one-shot, for themselves rather than for everyone, as user and request notices always do. Default is false. * * @throws InvalidArgumentException Thrown when the id, capability, screens or expiry is invalid. */ diff --git a/packages/core/src/Caching/ObjectCache.php b/packages/core/src/Caching/ObjectCache.php index fe01f048..e2dbaae2 100644 --- a/packages/core/src/Caching/ObjectCache.php +++ b/packages/core/src/Caching/ObjectCache.php @@ -27,7 +27,7 @@ final class ObjectCache implements PsrCacheInterface { * * @param string $group The object cache group, under the plugin's own prefix. * - * @throws InvalidArgumentException Thrown when the group is not a lowercase letter followed by up to 63 lowercase letters, digits, underscores and hyphens. + * @throws InvalidArgumentException Thrown when the group is invalid. */ public function __construct( protected string $group @@ -61,13 +61,13 @@ public function get( string $key, mixed $default = null ): mixed { // phpcs:igno */ #[\Override] public function set( string $key, mixed $value, null|int|\DateInterval $ttl = null ): bool { + $name = \md5( CacheEntries::check_key( $key ) ); $lifetime = CacheEntries::get_lifetime( $ttl ); if ( ! \is_null( $lifetime ) && $lifetime <= 0 ) { return $this->delete( $key ); } - $name = \md5( CacheEntries::check_key( $key ) ); - return wp_cache_set( $name, CacheEntries::wrap( $value, $this->get_salt() ), $this->group, $lifetime ?? 0 ); + return wp_cache_set( $name, CacheEntries::wrap( $value, $this->get_salt() ?? $this->start_salt() ), $this->group, $lifetime ?? 0 ); } /** @@ -114,13 +114,17 @@ public function getMultiple( iterable $keys, mixed $default = null ): iterable { return array(); } - $salt = $this->find_salt(); + $salt = $this->get_salt(); + if ( \is_null( $salt ) ) { + return \array_fill_keys( $keys, $default ); + } + $names = \array_map( 'md5', $keys ); - $entries = \is_null( $salt ) ? array() : wp_cache_get_multiple( $names, $this->group ); + $entries = wp_cache_get_multiple( $names, $this->group ); $values = array(); foreach ( $keys as $index => $key ) { - $entry = \is_null( $salt ) ? null : CacheEntries::unwrap( $entries[ $names[ $index ] ] ?? false, $salt ); + $entry = CacheEntries::unwrap( $entries[ $names[ $index ] ] ?? false, $salt ); $values[ $key ] = \is_null( $entry ) ? $default : $entry[0]; } @@ -156,13 +160,13 @@ public function setMultiple( iterable $values, null|int|\DateInterval $ttl = nul return true; } - $salt = $this->get_salt(); + $salt = $this->get_salt() ?? $this->start_salt(); foreach ( $entries as $name => $value ) { $entries[ $name ] = CacheEntries::wrap( $value, $salt ); } $stored = wp_cache_set_multiple( $entries, $this->group, $lifetime ?? 0 ); - return \array_all( \array_keys( $entries ), fn( $name ) => true === ( $stored[ $name ] ?? false ) ); + return \array_all( \array_keys( $entries ), static fn ( string $name ): bool => true === ( $stored[ $name ] ?? false ) ); } /** @@ -211,37 +215,37 @@ public function has( string $key ): bool { */ protected function read( string $key ): ?array { $name = \md5( CacheEntries::check_key( $key ) ); - $salt = $this->find_salt(); + $salt = $this->get_salt(); return \is_null( $salt ) ? null : CacheEntries::unwrap( wp_cache_get( $name, $this->group ), $salt ); } /** * Returns the group's salt, or null when it has none. * - * Read directly, because wp_cache_get_last_changed() stores a salt and fires an action for a group without one. + * The salt is read directly, because wp_cache_get_last_changed() stores a salt and fires an action for a group without one. * * @since 2.0.0 * @version 2.0.0 * * @return string|null */ - protected function find_salt(): ?string { + protected function get_salt(): ?string { $salt = wp_cache_get( 'last_changed', $this->group ); return \is_string( $salt ) && '' !== $salt ? $salt : null; } /** - * Returns the group's salt, after starting one when it has none. + * Starts a salt for the group and returns it. * - * Started through wp_cache_set_last_changed(), because wp_cache_get_last_changed() would return a stored salt that no read accepts. + * The salt is started through wp_cache_set_last_changed(), because wp_cache_get_last_changed() would return a stored salt that no read accepts. * * @since 2.0.0 * @version 2.0.0 * * @return string */ - protected function get_salt(): string { - return $this->find_salt() ?? wp_cache_set_last_changed( $this->group ); + protected function start_salt(): string { + return wp_cache_set_last_changed( $this->group ); } /** @@ -260,12 +264,7 @@ protected function delete_names( array $names ): bool { } $deleted = wp_cache_delete_multiple( $names, $this->group ); - $gone = true; - foreach ( $names as $name ) { - $gone = ( true === ( $deleted[ $name ] ?? false ) || $this->is_absent( $name ) ) && $gone; - } - - return $gone; + return \array_all( $names, fn ( string $name ): bool => true === ( $deleted[ $name ] ?? false ) || $this->is_absent( $name ) ); } /** diff --git a/packages/core/src/Caching/TransientCache.php b/packages/core/src/Caching/TransientCache.php index f13c176a..4e1ae3a1 100644 --- a/packages/core/src/Caching/TransientCache.php +++ b/packages/core/src/Caching/TransientCache.php @@ -12,7 +12,7 @@ /** * Caches values as transients of the current site under a prefix, and clears them all at once by starting a new generation. * - * Each entry is named by its key's hash, because the options table matches names regardless of letter case and accents and drops the timeout of a transient name past 172 characters, and holds the generation it was stored under, so that clear() leaves one row per key rather than one per generation. + * Each entry is named by its key's hash, because the options table matches names regardless of letter case and accents and drops the timeout of a transient name past 172 characters. Each also holds the generation it was stored under, so that clear() leaves one transient per key rather than one per generation. * * @since 2.0.0 * @version 2.0.0 @@ -63,7 +63,7 @@ final class TransientCache implements PsrCacheInterface { * @param string $prefix The prefix of every transient name, under the plugin's own prefix. * @param string|null $generation_option Optional. The option that keeps the generation, which caches that clear together share. Default is the prefix followed by cache_generation. * - * @throws InvalidArgumentException Thrown when the prefix is empty, surrounded by whitespace or longer than 140 characters, or the option name is empty, surrounded by whitespace or longer than 191 characters. + * @throws InvalidArgumentException Thrown when the prefix or the generation option name is invalid. */ public function __construct( protected string $prefix, diff --git a/packages/core/src/ConditionalComponentInterface.php b/packages/core/src/ConditionalComponentInterface.php index 88074279..a3aeff84 100644 --- a/packages/core/src/ConditionalComponentInterface.php +++ b/packages/core/src/ConditionalComponentInterface.php @@ -10,7 +10,7 @@ * @since 2.0.0 * @version 2.0.0 */ -interface ConditionalComponentInterface { +interface ConditionalComponentInterface extends ComponentInterface { /** * Returns whether the component and its children load. * diff --git a/packages/core/src/Logging/AbstractLogger.php b/packages/core/src/Logging/AbstractLogger.php index 415e2c27..27a78f0c 100644 --- a/packages/core/src/Logging/AbstractLogger.php +++ b/packages/core/src/Logging/AbstractLogger.php @@ -55,8 +55,12 @@ abstract class AbstractLogger extends PsrAbstractLogger { * @return int */ protected static function get_severity( mixed $level ): int { - $severity = \is_string( $level ) ? ( self::SEVERITIES[ $level ] ?? null ) : null; - return $severity ?? throw new PsrInvalidArgumentException( "Log level '" . ( \is_string( $level ) ? $level : \get_debug_type( $level ) ) . "' is not a PSR-3 level." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + if ( ! \is_string( $level ) || ! \array_key_exists( $level, self::SEVERITIES ) ) { + $shown = \is_string( $level ) ? $level : \get_debug_type( $level ); + throw new PsrInvalidArgumentException( "Log level '$shown' is not a PSR-3 level." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + } + + return self::SEVERITIES[ $level ]; } /** diff --git a/packages/core/src/Migration/MigrationRunner.php b/packages/core/src/Migration/MigrationRunner.php index b00daf60..e3d25631 100644 --- a/packages/core/src/Migration/MigrationRunner.php +++ b/packages/core/src/Migration/MigrationRunner.php @@ -166,7 +166,7 @@ public function register_hooks(): void { // endregion - // region METHODS + // region HOOKS /** * Runs the pending steps under the lock, pausing at a batched step outside the runner's hook or once the request's budget is spent. @@ -185,7 +185,7 @@ public function run(): void { return; } - // Outside the hook a pending batched step only schedules the hook, so no lock is taken; a stale revision only leaves plain steps to the hook, which runs them too. + // Outside the hook a pending batched step only schedules the hook and takes no lock, and a stale cached revision at worst leaves plain steps to the hook, which runs them too. if ( ! doing_action( $this->get_hook() ) && ( $this->steps[ $cached ] ?? null ) instanceof BatchedMigrationStepInterface ) { $this->schedule_continuation(); return; @@ -257,7 +257,7 @@ public function run(): void { } while ( $revision < $target ) { - if ( ! $this->holds_lock( $revision ) ) { // The previous checkpoint fired option hooks, so another run may have taken the lock over since the last check. + if ( ! $this->refresh_lock( $revision ) ) { // The previous checkpoint fired option hooks, so another run may have taken the lock over since the last check. return; } @@ -265,7 +265,7 @@ public function run(): void { $memory_limit = wp_convert_hr_to_bytes( (string) \ini_get( 'memory_limit' ) ); $spent = ! \is_null( $this->deadline ) && ( $this->get_time() >= $this->deadline || ( 0 < $memory_limit && \memory_get_usage( true ) > 0.9 * $memory_limit ) ); if ( $spent || ( $step instanceof BatchedMigrationStepInterface && ! doing_action( $this->get_hook() ) ) ) { - $this->unlock(); // The lock goes first, so that the continuation can take it. + $this->release_lock(); // The lock goes first, so that the continuation can take it. $this->schedule_continuation(); return; } @@ -282,7 +282,7 @@ public function run(): void { $step(); } - if ( ! $this->holds_lock( $revision ) ) { // A run whose lock was taken over must leave the revision and the error row to the run that holds it now. + if ( ! $this->refresh_lock( $revision ) ) { // A run whose lock was taken over must leave the revision and the error row to the run that holds it now. return; } @@ -296,7 +296,7 @@ public function run(): void { } } } catch ( \Throwable $exception ) { - if ( $this->holds_lock( $revision ?? 0 ) ) { + if ( $this->refresh_lock( $revision ?? 0 ) ) { $this->record_error( $exception->getMessage(), $revision ); } @@ -310,20 +310,16 @@ public function run(): void { // In production the lock stays until its hold expires, when a failing step runs again; elsewhere the fixed step runs on the next request. if ( PluginBoot::is_development_environment() ) { - $this->unlock(); + $this->release_lock(); throw $exception; } return; } - $this->unlock(); + $this->release_lock(); } - // endregion - - // region HOOKS - /** * Tells users who can activate plugins, while steps are pending, that the last run failed or that a batched step is running in the background, with a link that runs a pending batched step now. * @@ -417,7 +413,7 @@ public function run_now(): void { * * @return bool */ - protected function holds_lock( int $revision ): bool { + protected function refresh_lock( int $revision ): bool { try { if ( $this->lock->refresh( $this->owner ) ) { return true; @@ -446,7 +442,7 @@ protected function holds_lock( int $revision ): bool { * * @return void */ - protected function unlock(): void { + protected function release_lock(): void { try { $this->lock->release( $this->owner ); } catch ( RuntimeException $exception ) { @@ -498,7 +494,7 @@ protected function schedule_continuation(): void { */ protected function checkpoint( int $revision ): void { if ( ! update_option( $this->option, $revision, true ) ) { - throw new RuntimeException( "Could not record schema revision '$revision'." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + throw new RuntimeException( "Could not record data revision '$revision'." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. } if ( $revision > self::get_revision( "{$this->option}_high_water" ) ) { @@ -584,7 +580,7 @@ protected function get_time(): float { * @return non-empty-string */ protected function get_hook(): string { - return "{$this->option}/migration/run"; + return "$this->option/migration/run"; } // endregion diff --git a/packages/core/src/Permission/CapabilityGrants.php b/packages/core/src/Permission/CapabilityGrants.php index e619bbc8..3e5165ac 100644 --- a/packages/core/src/Permission/CapabilityGrants.php +++ b/packages/core/src/Permission/CapabilityGrants.php @@ -68,7 +68,7 @@ public function register_hooks(): void { // endregion - // region METHODS + // region HOOKS /** * Revokes each ledgered pair no longer declared and grants each declared pair that its role lacks and the ledger does not list, once the declaration or the declared roles have changed since the last complete run. @@ -82,7 +82,8 @@ public function register_hooks(): void { */ public function run(): void { $roles = wp_roles(); - $fingerprint = \md5( (string) wp_json_encode( array( $this->grants, \array_values( \array_filter( \array_keys( $this->grants ), $roles->is_role( ... ) ) ) ) ) ); + $present = \array_keys( \array_intersect_key( $this->grants, $roles->roles ) ); + $fingerprint = \md5( (string) wp_json_encode( array( $this->grants, $present ) ) ); $row = $this->get_row(); if ( $row['fingerprint'] === $fingerprint ) { return; @@ -178,10 +179,6 @@ public function run(): void { $this->logger->warning( 'The roles row did not keep the capability changes {changes}, so the next request makes them again.', array( 'changes' => \implode( ', ', $failed ) ) ); } - // endregion - - // region HOOKS - /** * Forgets the grants of each declared role that a write of the site's roles row deletes, so that the role is granted them again once it exists anew. * diff --git a/packages/core/src/PluginBoot.php b/packages/core/src/PluginBoot.php index 05260f1a..cd66d8a9 100644 --- a/packages/core/src/PluginBoot.php +++ b/packages/core/src/PluginBoot.php @@ -108,7 +108,7 @@ static function () use ( $plugin_file ): void { * * @param \Throwable $exception The failure that stopped the composition. */ - do_action( "{$this->prefix}/boot/failed", $exception ); + do_action( "$this->prefix/boot/failed", $exception ); if ( self::is_development_environment() ) { throw $exception; } @@ -125,7 +125,7 @@ static function () use ( $plugin_file ): void { * @since 2.0.0 * @version 2.0.0 */ - do_action( "{$this->prefix}/boot/blocked" ); + do_action( "$this->prefix/boot/blocked" ); return; } @@ -137,7 +137,7 @@ static function () use ( $plugin_file ): void { * @since 2.0.0 * @version 2.0.0 */ - do_action( "{$this->prefix}/boot/succeeded" ); + do_action( "$this->prefix/boot/succeeded" ); } /** diff --git a/packages/core/src/PluginKernel.php b/packages/core/src/PluginKernel.php index ced7a9d6..22f79226 100644 --- a/packages/core/src/PluginKernel.php +++ b/packages/core/src/PluginKernel.php @@ -118,7 +118,7 @@ protected function resolve( array $classes ): array { $components[] = $component; if ( \is_a( $class, CompositeComponentInterface::class, true ) ) { - \array_push( $components, ...$this->resolve( $class::get_child_component_classes() ) ); // The declared class names the children, so no runtime state can add an unvisited node. + \array_push( $components, ...$this->resolve( $class::get_child_component_classes() ) ); // Children come from a static method, so the visited set covers every node the walk can reach. } } diff --git a/packages/core/src/Scheduling/Backends/ActionSchedulerBackend.php b/packages/core/src/Scheduling/Backends/ActionSchedulerBackend.php index d1d6f8b5..39596378 100644 --- a/packages/core/src/Scheduling/Backends/ActionSchedulerBackend.php +++ b/packages/core/src/Scheduling/Backends/ActionSchedulerBackend.php @@ -142,7 +142,7 @@ public function clear_scheduled_hook( ScheduledHook $hook ): true|\WP_Error { */ #[\Override] public function unschedule_hook( string $hook ): true|\WP_Error { - // Best effort, since Action Scheduler reports no failure: at worst the hook's pending actions stay and run, or fail without a callback, until a later call succeeds. + // At worst a failing database write keeps Action Scheduler's bulk cancel looping until the request's time limit, and the hook's pending actions stay until a later call succeeds. as_unschedule_all_actions( $hook ); return true; } diff --git a/packages/core/src/Scheduling/Scheduler.php b/packages/core/src/Scheduling/Scheduler.php index 28187951..eab2dec1 100644 --- a/packages/core/src/Scheduling/Scheduler.php +++ b/packages/core/src/Scheduling/Scheduler.php @@ -219,10 +219,12 @@ public function clear_scheduled_hook( ScheduledHook $hook ): true|\WP_Error { * * @since 2.0.0 * @version 2.0.0 + * + * @throws InvalidArgumentException Thrown when the hook name is invalid. */ #[\Override] public function unschedule_hook( string $hook ): true|\WP_Error { - $hook = ( new ScheduledHook( $hook ) )->hook; // Given no hook name, Action Scheduler would cancel the pending actions of every hook. + new ScheduledHook( $hook ); // Given no hook name, Action Scheduler would cancel the pending actions of every hook. $ready = $this->check_ready( __METHOD__ ); if ( true !== $ready ) { return $ready; @@ -259,7 +261,7 @@ public function unschedule_hook( string $hook ): true|\WP_Error { * @return true|\WP_Error */ protected function check_ready( string $method ): true|\WP_Error { - $error = match ( true ) { + $ready = match ( true ) { 0 === did_action( 'init' ) || \array_any( $this->backends, static fn ( BackendInterface $backend ): bool => BackendState::Initializing === $backend->get_state() ) => new \WP_Error( 'scheduler_not_ready', 'Scheduling waits until init has fired and Action Scheduler has initialized.' ), // Readiness is measured for the site the scheduler was built on, while another site may run other plugins or lack Action Scheduler's tables. @@ -268,11 +270,11 @@ protected function check_ready( string $method ): true|\WP_Error { default => true, }; - if ( true !== $error ) { - _doing_it_wrong( esc_html( $method ), esc_html( $error->get_error_message() ), '2.0.0' ); + if ( true !== $ready ) { + _doing_it_wrong( esc_html( $method ), esc_html( $ready->get_error_message() ), '2.0.0' ); } - return $error; + return $ready; } /** @@ -306,8 +308,6 @@ protected static function get_failure( \Throwable $exception ): \WP_Error { /** * Cancels every pending invocation of the hook on every readable backend, then checks that none is left. * - * Action Scheduler reports no failed cancellation, so only the check catches one. - * * @since 2.0.0 * @version 2.0.0 * diff --git a/packages/core/src/Scheduling/SchedulerInterface.php b/packages/core/src/Scheduling/SchedulerInterface.php index eaca9e1d..16996962 100644 --- a/packages/core/src/Scheduling/SchedulerInterface.php +++ b/packages/core/src/Scheduling/SchedulerInterface.php @@ -49,9 +49,9 @@ public function enqueue( ScheduledHook $hook, int $priority = 10 ): true|\WP_Err public function schedule_single( ScheduledHook $hook, int $timestamp, int $priority = 10 ): true|\WP_Error; /** - * Ensures that the hook recurs at the given interval, creating its chain when none exists and replacing duplicate chains, or a chain at another interval, with one chain, which also cancels a pending one-off invocation of the identity. + * Ensures that the hook recurs at the given interval, as one chain. * - * On Action Scheduler a running occurrence defers any replacement to a later call, because it queues its successor only after it completes; WP-Cron shows no running state, so it is called outside the hook's own callback. + * A missing chain is created at the priority, which a chain that exists keeps, and duplicate chains or a chain at another interval are replaced with one, which also cancels a pending one-off invocation of the identity. On Action Scheduler a running occurrence defers any replacement to a later call. On WP-Cron, call it outside the hook's own callback, because WP-Cron shows no running state. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/core/src/Storage/Lock.php b/packages/core/src/Storage/Lock.php index c5313cb3..c9009741 100644 --- a/packages/core/src/Storage/Lock.php +++ b/packages/core/src/Storage/Lock.php @@ -12,7 +12,7 @@ /** * Leases a named lock to one owner at a time, which refreshes its hold while it works, and lets another owner take the lock over from a hold left past its TTL. * - * The lock is one row of the current site's options table, written only by compare-and-swap, because add_option() overwrites a row inserted after its cached check, while update_option() and delete_option() write whatever the row holds. + * The lock is one row of the current site's options table, written only through OptionRows' compare-and-swap. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/core/src/Storage/OptionRows.php b/packages/core/src/Storage/OptionRows.php index 99978020..2eb54877 100644 --- a/packages/core/src/Storage/OptionRows.php +++ b/packages/core/src/Storage/OptionRows.php @@ -10,7 +10,7 @@ /** * Reads and writes options as rows of the current site's options table, with writes that are atomic or conditional on the exact row read. * - * The options API decides each write from a possibly cached read, so it cannot write conditionally. Conditional writes compare bytes, not the column's case-insensitive collation, and every write that changes a row clears the option's cache entries, so get_option() reads it. Rows are written without autoload, so a replace also stops an autoloaded option from autoloading. + * The options API decides each write from a possibly cached read, so add_option() overwrites a row inserted after its cached check, while update_option() and delete_option() write whatever the row holds. Conditional writes compare bytes, not the column's case-insensitive collation, and every write that changes a row clears the option's cache entries, so get_option() reads it. Rows are written without autoload, so a replace also stops an autoloaded option from autoloading. * * @since 2.0.0 * @version 2.0.0 @@ -64,7 +64,7 @@ public static function get( string $name ): ?OptionRow { self::check_name( $name ); - // A row rather than a single value, because wpdb::get_var() returns null for an empty string as it does for a missing row. + // The query reads a row rather than a single value, because wpdb::get_var() returns null for an empty string as it does for a missing row. $wpdb->flush(); $row = $wpdb->get_row( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name = %s', $wpdb->options, $name ) ?? '', ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- A cached value may predate another request's write. if ( '' !== $wpdb->last_error ) { // wpdb::get_row() returns null for a failed query as for a missing row, so the error left after the flush tells them apart. diff --git a/packages/core/src/Uninstall/Uninstaller.php b/packages/core/src/Uninstall/Uninstaller.php index ffd6bd80..b2c83419 100644 --- a/packages/core/src/Uninstall/Uninstaller.php +++ b/packages/core/src/Uninstall/Uninstaller.php @@ -48,9 +48,9 @@ final class Uninstaller { * @since 2.0.0 * @version 2.0.0 * - * @phpstan-param array> $footprint - * @phpstan-param array{meta: array>, meta_prefixes: array>} $meta_footprint - * @phpstan-param array{consent: array{option: string, key: string, constant: string}, meta_types: array, cache_groups: list}>, cleanup: (callable(array{scope: 'site'|'network', site_id: int, plugin_file: string}): void)|null} $args + * @phpstan-param array> $footprint + * @phpstan-param array{meta: array>, meta_prefixes: array>} $meta_footprint + * @phpstan-param array{consent: array{option: string, key: string, constant: string}, meta_types: array, cache_groups: list}>, cleanup: (callable(array{scope: 'site'|'network', site_id: int, plugin_file: string}): void)|null} $args * * @param string $plugin_file The path to the plugin's entry file. * @param array $footprint The names and prefixes of every kind but the meta kinds, by kind. @@ -71,7 +71,7 @@ protected function __construct( /** * Removes the plugin's footprint from every site that consents, and unschedules its jobs on every site. * - * On each site the cleanup runs first, and a failed cleanup keeps that site's footprint. A failed delete leaves the others to run, while a throw from another plugin's hook ends that site's pass. Either keeps the site's consent row, and the failures are thrown once every site was visited, so WordPress keeps the plugin's files and the next uninstall retries. A job that cannot be unscheduled is logged instead, since it only ever fires into a missing callback. + * Failures are thrown once every site was visited, and a site that failed keeps its consent row, so WordPress keeps the plugin's files and the next uninstall retries the pass. * * @since 2.0.0 * @version 2.0.0 @@ -81,9 +81,9 @@ protected function __construct( * * @param string $plugin_file The path to the plugin's entry file. * @param array $footprint The plugin's stored data by kind. - * @param array $args Optional. The consent (an option, its key in a grouped row, or a constant), the meta types the meta kinds may name beside post, term and comment, and the cleanup. Default none, which keeps the footprint. + * @param array $args Optional. The consent (an option, its key in a grouped row, or a constant), the meta types the meta kinds may name beside post, term and comment, and the cleanup. Default is none, which keeps the footprint. * - * @throws RuntimeException Thrown when a web request runs on a large network, before any change, or when a delete, a site's pass or the cleanup failed, once every site was visited. + * @throws RuntimeException Thrown when a web request runs on a large network, before any change, or when a read or delete, a site's pass or the cleanup failed, once every site was visited. * * @return void */ @@ -137,21 +137,22 @@ public static function run( string $plugin_file, array $footprint, array $args = $args ); if ( ! is_multisite() ) { - $uninstaller->uninstall_site( true ); + $uninstaller->uninstall_site(); } else { if ( wp_is_large_network() && ! self::is_constant_true( 'WP_CLI' ) ) { // A web request could not finish sweeping a large network within the server's time limit, which WP-CLI does not have. throw new RuntimeException( 'Uninstall does not sweep a large network in a web request; run `wp plugin uninstall` instead.' ); } - foreach ( get_sites( + $site_ids = get_sites( array( 'fields' => 'ids', 'number' => 0, ) - ) as $site_id ) { + ); + foreach ( $site_ids as $site_id ) { switch_to_blog( $site_id ); try { - $uninstaller->uninstall_site( false ); + $uninstaller->uninstall_site(); } finally { restore_current_blog(); if ( wp_cache_supports( 'flush_runtime' ) ) { // WordPress keeps every visited site's options in its runtime cache, so the sweep's memory would grow with each site. @@ -172,7 +173,7 @@ public static function run( string $plugin_file, array $footprint, array $args = } if ( array() !== $uninstaller->failures ) { - throw new RuntimeException( 'Uninstall could not remove everything: ' . \implode( '; ', $uninstaller->failures ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + throw new RuntimeException( 'Uninstall could not remove everything: ' . \rtrim( \implode( '; ', $uninstaller->failures ), '.' ) . '.' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. } } @@ -188,11 +189,9 @@ public static function run( string $plugin_file, array $footprint, array $args = * * @global \wpdb $wpdb The WordPress database connection. * - * @param bool $network_footprint Whether the pass also deletes the network options and network-wide user meta, which a single site keeps as its own. - * * @return void */ - protected function uninstall_site( bool $network_footprint ): void { + protected function uninstall_site(): void { global $wpdb; $failed = \count( $this->failures ); @@ -219,7 +218,7 @@ protected function uninstall_site( bool $network_footprint ): void { foreach ( $jobs as $subject => $arguments ) { $wpdb->flush(); try { - as_unschedule_all_actions( ...$arguments ); // Action Scheduler throws when its store fails to cancel an action. + as_unschedule_all_actions( ...$arguments ); // Action Scheduler's hybrid store, which it runs while it migrates its data, throws when it fails to cancel an action. $result = null; } catch ( \Throwable $throwable ) { $result = $throwable; @@ -293,9 +292,10 @@ protected function uninstall_site( bool $network_footprint ): void { } } - $keys = $meta_keys[ $name ] ?? array(); + $keys = $meta_keys[ $name ] ?? array(); + $prefix_tables = '' === $meta_type['meta_type'] ? $tables : array( (string) _get_meta_table( $meta_type['meta_type'] ), ...$tables ); foreach ( $meta_prefixes[ $name ] ?? array() as $prefix ) { - foreach ( '' === $meta_type['meta_type'] ? $tables : array( (string) _get_meta_table( $meta_type['meta_type'] ), ...$tables ) as $table ) { + foreach ( $prefix_tables as $table ) { $keys = \array_merge( $keys, $this->get_names_by_prefix( $table, 'meta_key', $prefix ) ); } } @@ -312,7 +312,7 @@ protected function uninstall_site( bool $network_footprint ): void { } } - if ( wp_cache_supports( 'flush_group' ) ) { // Where the object cache cannot flush a single group, the entries expire on their own. + if ( wp_cache_supports( 'flush_group' ) ) { // Where the object cache cannot flush a single group, cached entries stay until they are invalidated or evicted. foreach ( $meta_type['cache_groups'] as $group ) { wp_cache_flush_group( $group ); } @@ -338,7 +338,7 @@ protected function uninstall_site( bool $network_footprint ): void { $this->record_failure( "table $table", $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . $table ) ?? '' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- A plugin's own table, which no WordPress API drops. } - if ( $network_footprint ) { + if ( ! is_multisite() ) { $this->delete_network_footprint(); } @@ -428,7 +428,7 @@ protected function delete_meta_keys( string $meta_type, array $keys ): void { protected function run_cleanup( string $scope ): bool { global $wpdb; - if ( null === $this->args['cleanup'] ) { + if ( \is_null( $this->args['cleanup'] ) ) { return true; } @@ -560,7 +560,7 @@ protected function get_matching_ids( string $table, string $id_column, string $c * @return \WP_Post|true|\WP_Error */ protected static function confirm_deleted( mixed $deleted ): mixed { - return null === $deleted || false === $deleted ? new \WP_Error( 'not_deleted', 'It was not deleted.' ) : $deleted; + return \is_null( $deleted ) || false === $deleted ? new \WP_Error( 'not_deleted', 'It was not deleted.' ) : $deleted; } /** @@ -587,7 +587,7 @@ protected static function is_constant_true( string $name ): bool { * * @param mixed $value The value. * - * @return list + * @return list */ protected static function get_strings( mixed $value ): array { return \array_values( \array_filter( \is_array( $value ) ? $value : array(), static fn ( mixed $item ): bool => \is_string( $item ) && '' !== $item && '0' !== $item ) ); @@ -602,7 +602,7 @@ protected static function get_strings( mixed $value ): array { * @param mixed $value The meta kind's entries, by meta type name. * @param array $meta_types The run's meta types, by name. * - * @return array> + * @return array> */ protected static function get_meta_strings( mixed $value, array $meta_types ): array { return \array_map( self::get_strings( ... ), \array_intersect_key( \is_array( $value ) ? $value : array(), $meta_types ) ); diff --git a/packages/settings/README.md b/packages/settings/README.md index bdd5e927..bc92082e 100644 --- a/packages/settings/README.md +++ b/packages/settings/README.md @@ -86,20 +86,29 @@ require_once __DIR__ . '/vendor/autoload.php'; require_once __DIR__ . '/functions.php'; ``` -The helpers need no kernel. On `plugins_loaded` at priority 15 the plugin registers its settings page: +The helpers need no kernel. On `plugins_loaded` at priority 15 the plugin registers its settings page, and a form on the post edit screen whose rejections core's `AdminNotices` reports: ```php +use Acme\PersonaSettings\Details; use Acme\PersonaSettings\Settings; +use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; +use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger; use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Settings\AdminPage\OptionsPage; +use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Settings\EditScreen\Hosts\PostMetaBox; +use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Settings\EditScreen\ObjectForm; defined( 'ABSPATH' ) || exit; -add_action( 'plugins_loaded', 'acme_ps_register_settings_page', 15 ); +add_action( 'plugins_loaded', 'acme_ps_register_hooks', 15 ); // ... -function acme_ps_register_settings_page(): void { +function acme_ps_register_hooks(): void { + $notices = new AdminNotices( Settings::PREFIX, new ErrorLogLogger( 'acme-persona-settings' ) ); + $notices->register_hooks(); + new OptionsPage( Settings::PREFIX, new Settings() )->register_hooks(); + new ObjectForm( Settings::PREFIX . '_details', new Details(), new PostMetaBox( array( 'post' ) ), $notices )->register_hooks(); } ``` @@ -123,10 +132,15 @@ final class Settings implements OptionsPageDefinitionInterface { 'option' => self::PREFIX . '_settings', 'fields' => array( array( - 'type' => 'number', - 'id' => 'limit', - 'title' => __( 'Limit', 'acme-persona-settings' ), - 'validate' => array( $this, 'validate_limit' ), + 'type' => 'number', + 'id' => 'limit', + 'title' => __( 'Limit', 'acme-persona-settings' ), + 'default' => '10', + 'custom_attributes' => array( + 'min' => '1', + 'step' => '1', + ), + 'validate' => array( $this, 'validate_limit' ), ), ), ), @@ -146,6 +160,31 @@ final class Settings implements OptionsPageDefinitionInterface { } ``` +A form's definition object declares its fields the same way. `PostMetaBox` shows the form on the edit screens of the post types it names, `UserProfile` on the profile screens and `TermForm` on the term screens of the taxonomies it names. Each host stores a field under its id as the object's meta through `MetadataStore`, unless the form takes another store: + +```php +final class Details implements ObjectFormDefinitionInterface { + // ... + + #[\Override] + public function get_object_form( string $form_id, int $object_id ): array { + return array( + 'title' => __( 'Details', 'acme-persona-settings' ), + 'capability' => 'edit_posts', + 'fields' => array( + array( + 'type' => 'text', + 'id' => '_' . Settings::PREFIX . '_subtitle', + 'title' => __( 'Subtitle', 'acme-persona-settings' ), + ), + ), + ); + } + + // ... +} +``` + ## Contract and versioning The five packages release together under one version, which the [changelog](https://github.com/ahegyes/wordpress-framework/blob/trunk/CHANGELOG.md) records. diff --git a/packages/settings/src/AdminPage/OptionsPage.php b/packages/settings/src/AdminPage/OptionsPage.php index 12ac8035..3a55504d 100644 --- a/packages/settings/src/AdminPage/OptionsPage.php +++ b/packages/settings/src/AdminPage/OptionsPage.php @@ -17,8 +17,8 @@ * * @phpstan-import-type Field from Forms * @phpstan-import-type FieldRow from Forms - * @phpstan-type Tab array{slug: string, title: string, option: string, fields: list} - * @phpstan-type Page array{parent: string, capability: string, page_title: string, menu_title: string, tabs: list} + * @phpstan-type Tab array{slug: string, title: string, option: string, fields: list} + * @phpstan-type Page array{parent: string, capability: string, page_title: string, menu_title: string, tabs: list} */ final class OptionsPage { // region FIELDS AND CONSTANTS @@ -44,7 +44,7 @@ final class OptionsPage { * @version 2.0.0 * * @param string $id The page's menu slug, which also prefixes its tabs filter. - * @param OptionsPageDefinitionInterface $definition The definition, which the page asks for once, on first use after init. + * @param OptionsPageDefinitionInterface $definition The page's definition. * * @throws InvalidArgumentException Thrown when the id is not a valid key. */ @@ -190,6 +190,10 @@ public function render_page(): void { $option = $tab['option']; $stored = get_option( $option ); foreach ( Forms::get_rows( $tab['fields'], \is_array( $stored ) ? $stored : array(), $option ) as $row ) { + if ( isset( $row['label_for'] ) ) { // Core prints the label through esc_attr(), which keeps an entity the control's id encodes. + $row['label_for'] = Forms::escape_attribute( $row['label_for'] ); + } + add_settings_field( $row['field']['id'], esc_html( $row['field']['title'] ), array( $this, 'render_field' ), $this->id, 'default', $row ); } @@ -240,7 +244,7 @@ protected function get_page(): array { * * @param list $tabs The page's tabs. */ - $page['tabs'] = apply_filters( "{$this->id}/options_page/tabs", $page['tabs'] ); + $page['tabs'] = apply_filters( "$this->id/options_page/tabs", $page['tabs'] ); $this->page = $page; } diff --git a/packages/settings/src/EditScreen/FormTable.php b/packages/settings/src/EditScreen/FormTable.php index 342a34b9..44e27b23 100644 --- a/packages/settings/src/EditScreen/FormTable.php +++ b/packages/settings/src/EditScreen/FormTable.php @@ -2,6 +2,8 @@ namespace DeepWebSolutions\Framework\Settings\EditScreen; +use DeepWebSolutions\Framework\Settings\Forms; + \defined( 'ABSPATH' ) || exit; /** @@ -43,7 +45,7 @@ protected function __construct() {} public static function render_rows( string $title, array $rows ): void { echo '

' . $title . '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The form escapes its title. foreach ( $rows as $row ) { - $heading = \is_null( $row['label_for'] ) ? $row['title'] : ''; + $heading = \is_null( $row['label_for'] ) ? $row['title'] : ''; echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The form escapes titles and controls. } echo ''; diff --git a/packages/settings/src/EditScreen/Hosts/PostMetaBox.php b/packages/settings/src/EditScreen/Hosts/PostMetaBox.php index 7faa1786..a7a90f52 100644 --- a/packages/settings/src/EditScreen/Hosts/PostMetaBox.php +++ b/packages/settings/src/EditScreen/Hosts/PostMetaBox.php @@ -6,6 +6,7 @@ use DeepWebSolutions\Framework\Settings\EditScreen\ObjectFormHostInterface; use DeepWebSolutions\Framework\Settings\EditScreen\ObjectStoreInterface; use DeepWebSolutions\Framework\Settings\EditScreen\Stores\MetadataStore; +use DeepWebSolutions\Framework\Settings\Forms; \defined( 'ABSPATH' ) || exit; @@ -72,7 +73,8 @@ public function register_hooks( ObjectForm $form ): void { if ( \in_array( 'attachment', $this->post_types, true ) ) { add_action( 'edit_attachment', array( $this, 'save_attachment' ) ); // WordPress saves an attachment without firing save_post. } - add_filter( 'wp_refresh_nonces', array( $this, 'refresh_nonces' ), 11, 2 ); + + add_filter( 'wp_refresh_nonces', array( $this, 'refresh_nonces' ), 11, 2 ); // After core's own refreshes at 10, which create the replace maps this adds to. } /** @@ -111,7 +113,7 @@ public function get_default_store(): ObjectStoreInterface { #[\Override] public function render_rows( string $title, array $rows ): void { foreach ( $rows as $row ) { - $heading = \is_null( $row['label_for'] ) ? '' . $row['title'] . '' : ''; + $heading = \is_null( $row['label_for'] ) ? '' . $row['title'] . '' : ''; echo '

' . $heading . '

' . $row['control'] . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The form escapes titles and controls. } } @@ -155,9 +157,10 @@ public function render_meta_box( \WP_Post $post ): void { // The block editor's heartbeat script sets only core's nonces, by name, while the classic editor's sets every one the heartbeat returns. if ( true === get_current_screen()?->is_block_editor() ) { - foreach ( \array_keys( $this->form->get_refreshed_nonces( $post->ID ) ) as $field ) { - $field = esc_js( $field ); - wp_add_inline_script( 'heartbeat', "jQuery( document ).on( 'heartbeat-tick', function ( event, data ) { var nonces = data['wp-refresh-metabox-loader-nonces']; if ( nonces && nonces.replace && nonces.replace['$field'] ) { jQuery( '#$field' ).val( nonces.replace['$field'] ); } } );" ); + $nonce_field = \array_key_first( $this->form->get_refreshed_nonces( $post->ID ) ); + if ( ! \is_null( $nonce_field ) ) { + $nonce_field = esc_js( $nonce_field ); + wp_add_inline_script( 'heartbeat', "jQuery( document ).on( 'heartbeat-tick', function ( event, data ) { var nonces = data['wp-refresh-metabox-loader-nonces']; if ( nonces && nonces.replace && nonces.replace['$nonce_field'] ) { jQuery( '#$nonce_field' ).val( nonces.replace['$nonce_field'] ); } } );" ); } } } diff --git a/packages/settings/src/EditScreen/ObjectForm.php b/packages/settings/src/EditScreen/ObjectForm.php index f4c81617..00558c45 100644 --- a/packages/settings/src/EditScreen/ObjectForm.php +++ b/packages/settings/src/EditScreen/ObjectForm.php @@ -13,13 +13,13 @@ /** * Adds a form of field arrays to the screen that edits an object, and stores the accepted fields as the object's data. * - * Its host decides where the form shows and when it saves, and its store keeps the values, by default the host's own. The form shows only to a user who may edit the object and holds the definition's capability, carries a nonce bound to the object, and reports each rejection of a save as a one-shot notice for the user, which replaces the notice of the field's earlier rejection. Controls post under the form's id, so they never meet the screen's own inputs, and each field stores under its id. + * Controls post under the form's id, so they never meet the screen's own inputs. * * @since 2.0.0 * @version 2.0.0 * * @phpstan-import-type Field from Forms - * @phpstan-type Form array{title: string, capability: string, fields: list} + * @phpstan-type Form array{title: string, capability: string, fields: list} */ final class ObjectForm { // region FIELDS AND CONSTANTS @@ -65,7 +65,7 @@ final class ObjectForm { * @version 2.0.0 * * @param string $id The form's id, which starts with the plugin's prefix and names the form's controls, nonce and fields filter. - * @param ObjectFormDefinitionInterface $definition The definition, which the form asks for once per object, on first use after init. + * @param ObjectFormDefinitionInterface $definition The form's definition. * @param ObjectFormHostInterface $host The host that shows and saves the form. * @param AdminNotices $notices The plugin's admin notices, which report the rejections. * @param ObjectStoreInterface|null $store Optional. The store of the form's values. Default is null, for the host's store. @@ -188,7 +188,7 @@ public function render( int $object_id ): void { /** * Stores the accepted fields of the request's submission for the object, and reports each rejection as a one-shot error notice for the current user. * - * Nothing changes unless the request carries the form's nonce for the object, which binds it to the object as WordPress core binds its edit forms, so a save that fires for a revision or another object in the same request is refused, and the current user may edit the object and holds the definition's capability. A field the form did not post, because it was not rendered or is disabled, keeps its value, as a rejected one does. The form saves once per object and request, because a save that updates the object again fires the host's save hook again with the same submission. + * The form's nonce binds the save to the object, so a save that fires for a revision or another object in the same request changes nothing. The form saves once per object and request, because a save that updates the object again fires the host's save hook again. * * @since 2.0.0 * @version 2.0.0 @@ -262,7 +262,7 @@ protected function get_form( int $object_id ): array { * @param list $fields The form's fields for the object. * @param int $object_id The id of the object being edited or saved. */ - $form['fields'] = apply_filters( "{$this->id}/object_form/fields", $form['fields'], $object_id ); + $form['fields'] = apply_filters( "$this->id/object_form/fields", $form['fields'], $object_id ); $this->forms[ $object_id ] = $form; } diff --git a/packages/settings/src/Forms.php b/packages/settings/src/Forms.php index 08ad0c9d..49dd4af4 100644 --- a/packages/settings/src/Forms.php +++ b/packages/settings/src/Forms.php @@ -69,7 +69,7 @@ protected function __construct() {} /** * Returns the escaped markup of the field's control, without its row label. * - * A radio, a checkboxes group and a checkbox with a desc render in a fieldset named by the title, so their host prints the row label as plain text, while every other control carries the id the row label points at. An enabled checkbox or multi-value control posts a hidden input first, so a field absent from a submission was not rendered or is disabled. A field with a render callable renders through it, and the callable escapes, names and describes its control, a fieldset named by the title for a group, and, unless the field is disabled, prints a hidden empty input before it. + * A group, as is_group() tells, renders in a fieldset its title names, or in its host's fieldset when it has no title. A field with a render callable renders through it, and the callable escapes, names and describes its control, prints its id through escape_attribute() as the row label does, and wraps a group the same way. Unless the field is disabled, the callable also prints a hidden empty input before its control, so that a field absent from a submission reads as unrendered or disabled. * * @since 2.0.0 * @version 2.0.0 @@ -99,7 +99,7 @@ public static function render_control( array $field, mixed $value, string $name, /** * Returns the accepted values and the rejections of a submission, keyed by field id, for the defined, present and enabled fields only. * - * Callers merge the values onto the stored row with array_replace( $stored, $result['values'] ) and report the errors, so a submission writes no id outside the definition, and every stored id it leaves out, including those of rejected fields and of fields absent from the definition, keeps its value. A field with validate hands its submission, alone, to the callable, which returns the value to store or a WP_Error and owns the field's allowed values, so no type sanitizer runs before or after it. Without validate, the field's type sanitizes the submission and checks its choices, and a rejection carries a code and a message that tells the user what to enter, which a host may replace by the code. Every WP_Error message is plain text, which the host escapes. + * Absent, rejected and undefined ids stay out of the values. A field with validate hands its submission, alone, to the callable, which returns the value to store or a WP_Error, and no type sanitizer runs before or after it. Without validate, the field's type sanitizes the submission and checks its choices. Every WP_Error message is plain text, which the host escapes. * * @since 2.0.0 * @version 2.0.0 @@ -231,7 +231,7 @@ public static function get_rejection_message( string $title, \WP_Error $error ): * * @param string $id The id. * - * @throws InvalidArgumentException Thrown when the id is not a lowercase letter, after an optional underscore, followed by lowercase letters, digits, underscores and hyphens. + * @throws InvalidArgumentException Thrown when the id is not a valid helper id. * * @return void */ @@ -279,7 +279,7 @@ public static function escape_attribute( string $value ): string { } /** - * Returns a row for each field with an id: the field named by its title, its stored value or else its default, the name it posts under, its HTML id, and the id its row label points at unless it is a group. + * Returns the row each field with an id renders as. * * @since 2.0.0 * @version 2.0.0 @@ -478,24 +478,30 @@ protected static function render_choices( array $field, mixed $value, string $na $disabled = self::is_disabled( $field ); $chosen = self::get_chosen( $value, $multiple ); $custom = self::get_custom_attributes( $field ); + $untitled = '' === ( $field['title'] ?? '' ); $choices = $multiple && ! $disabled ? self::render_hidden_input( $name, '' ) : ''; $index = 0; foreach ( $field['options'] ?? array() as $key => $label ) { $attributes = array( - 'type' => $multiple ? 'checkbox' : 'radio', - 'id' => "$id.$index", - 'name' => $multiple ? $name . '[]' : $name, - 'value' => (string) $key, - 'class' => $field['class'] ?? null, - 'style' => $field['css'] ?? null, - 'checked' => \in_array( (string) $key, $chosen, true ), - 'disabled' => $disabled || self::is_disabled_choice( $field, $key ), + 'type' => $multiple ? 'checkbox' : 'radio', + 'id' => "$id.$index", + 'name' => $multiple ? $name . '[]' : $name, + 'value' => (string) $key, + 'class' => $field['class'] ?? null, + 'style' => $field['css'] ?? null, + 'checked' => \in_array( (string) $key, $chosen, true ), + 'disabled' => $disabled || self::is_disabled_choice( $field, $key ), + 'aria-describedby' => $untitled ? self::get_described_by( $help ) : null, ); $choices .= ' "$id.$index" ) ) . '> ' . esc_html( $label ) . '
'; ++$index; } + if ( $untitled ) { // A host that names the group in a fieldset of its own passes no title. + return $choices . self::render_help( $help ); + } + return ' self::get_described_by( $help ) ) ) . '>' . self::render_legend( $field ) . $choices . self::render_help( $help ) . ''; } @@ -514,7 +520,9 @@ protected static function render_legend( array $field ): string { } /** - * Returns the markup of the help paragraphs. + * Returns the markup of the help texts. + * + * A text sits in a paragraph, which WordPress's admin styles as a description, unless it holds an element that ends a paragraph, which would end it before that element and leave the element the control names empty, so such a text sits in a div. * * @since 2.0.0 * @version 2.0.0 @@ -526,12 +534,14 @@ protected static function render_legend( array $field ): string { protected static function render_help( array $help ): string { $html = ''; foreach ( $help as $help_id => $text ) { - $html .= ' 'description', 'id' => $help_id, ) - ) . '>' . wp_kses_post( $text ) . '

'; + ) . ">$text"; } return $html; @@ -550,7 +560,11 @@ protected static function render_help( array $help ): string { */ protected static function render_attributes( array $own, array $custom = array() ): string { $html = ''; - foreach ( \array_filter( $own + $custom, static fn ( string|bool|null $value ): bool => ! \is_null( $value ) && false !== $value ) as $name => $value ) { + foreach ( $own + $custom as $name => $value ) { + if ( \is_null( $value ) || false === $value ) { + continue; + } + $html .= true === $value ? " $name" : " $name=\"" . self::escape_attribute( $value ) . '"'; } diff --git a/packages/shared/README.md b/packages/shared/README.md index 5f778fb5..4de1040b 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -61,32 +61,65 @@ return ( require __DIR__ . '/vendor/ahegyes/wordpress-configs/php/php-scoper/sco ## Usage -A `Version` parses and compares versions, a result is a `Success` or a `Failure` whose `match()` takes one callback per branch, and a value object equals any other of its class with the same values. A script loads the project's autoloader and imports the package under the project's prefix: +A `Version` parses and compares versions, a result is a `Success` or a `Failure` whose `match()` takes one callback per branch, and a value object equals any other of its class with the same values. The package throws its mirrors of PHP's `InvalidArgumentException`, `LogicException` and `RuntimeException`, which extend them and implement `ExceptionInterface`, so a caller catches the framework's failures by that interface. A script loads the project's autoloader and imports the package under the project's prefix: ```php +use Acme\PersonaShared\Catalog; use Acme\PersonaShared\Point; use Acme\PersonaShared\UnknownKey; +use Acme\PersonaShared\Scoped\DeepWebSolutions\Framework\Shared\Exception\ExceptionInterface; use Acme\PersonaShared\Scoped\DeepWebSolutions\Framework\Shared\Result\Failure; use Acme\PersonaShared\Scoped\DeepWebSolutions\Framework\Shared\Result\Success; use Acme\PersonaShared\Scoped\DeepWebSolutions\Framework\Shared\Versioning\Version; require __DIR__ . '/vendor/autoload.php'; +try { + $acme_rejected = (string) Version::from_string( 'next' ); +} catch ( ExceptionInterface $acme_exception ) { + $acme_rejected = $acme_exception::class; +} + +$acme_catalog = new Catalog( array( 'acme' => 'box' ) ); + echo \json_encode( array( 'wordpress' => \defined( 'ABSPATH' ), 'version' => array( (string) Version::from_string( '2.1.0-rc.1' ), Version::from_string( '2.1.0' )->compare( Version::from_string( '2.1.0-rc.1' ) ) ), + 'rejected' => $acme_rejected, 'results' => array( Success::from( 'acme' )->match( static fn ( string $value ): string => "found $value", static fn (): string => 'failed' ), Failure::from( new UnknownKey( 'acme' ) )->match( static fn (): string => 'found', static fn ( UnknownKey $error ): string => "unknown $error->key" ), ), + 'narrowed' => array( $acme_catalog->describe( 'acme' ), $acme_catalog->describe( 'other' ) ), 'equality' => array( new Point( 1, 2 )->equals( new Point( 1, 2 ) ), new Point( 1, 2 )->equals( new Point( 2, 1 ) ) ), ), \JSON_THROW_ON_ERROR ); ``` -A failure carries an error object, and a value object declares its values as promoted properties: +An operation with failures its caller branches on returns `AbstractResult` under `#[\NoDiscard]`, and `is_success()` or `is_failure()` narrows the result to its `value` or its `error`. A failure carries an error object, and a value object declares its values as promoted properties: + +```php +final readonly class Catalog { + // ... + + #[\NoDiscard] + public function find( string $key ): AbstractResult { + return isset( $this->values[ $key ] ) ? Success::from( $this->values[ $key ] ) : Failure::from( new UnknownKey( $key ) ); + } + + // ... + + public function describe( string $key ): string { + $found = $this->find( $key ); + + return $found->is_success() ? "found $found->value" : "unknown {$found->error->key}"; + } + + // ... +} +``` ```php final readonly class UnknownKey implements ErrorInterface { diff --git a/packages/shared/src/Result/AbstractResult.php b/packages/shared/src/Result/AbstractResult.php index 2df40e7a..33aeb9b0 100644 --- a/packages/shared/src/Result/AbstractResult.php +++ b/packages/shared/src/Result/AbstractResult.php @@ -12,7 +12,7 @@ * * @template-covariant TValue * @template-covariant TError of ErrorInterface - * @phpstan-sealed Success|Failure + * @phpstan-sealed Success|Failure */ abstract readonly class AbstractResult { // region METHODS diff --git a/packages/shared/src/Result/Failure.php b/packages/shared/src/Result/Failure.php index f3a883a5..c9af4454 100644 --- a/packages/shared/src/Result/Failure.php +++ b/packages/shared/src/Result/Failure.php @@ -11,7 +11,7 @@ * @version 2.0.0 * * @template-covariant TError of ErrorInterface - * @extends AbstractResult + * @extends AbstractResult */ final readonly class Failure extends AbstractResult { // region MAGIC METHODS diff --git a/packages/shared/src/Result/Success.php b/packages/shared/src/Result/Success.php index 1ae19194..37195696 100644 --- a/packages/shared/src/Result/Success.php +++ b/packages/shared/src/Result/Success.php @@ -9,7 +9,7 @@ * @version 2.0.0 * * @template-covariant TValue - * @extends AbstractResult + * @extends AbstractResult */ final readonly class Success extends AbstractResult { // region MAGIC METHODS diff --git a/packages/shared/src/Versioning/Version.php b/packages/shared/src/Versioning/Version.php index d02c50ba..ec7270cb 100644 --- a/packages/shared/src/Versioning/Version.php +++ b/packages/shared/src/Versioning/Version.php @@ -6,7 +6,7 @@ use DeepWebSolutions\Framework\Shared\ValueObject\AbstractValueObject; /** - * A version the plugin controls, ordered by precedence. + * A version the project controls, ordered by precedence. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/woocommerce/README.md b/packages/woocommerce/README.md index fc5b3dd7..34883bc5 100644 --- a/packages/woocommerce/README.md +++ b/packages/woocommerce/README.md @@ -4,7 +4,7 @@ The framework's settings, edit-screen and logging helpers carried onto WooCommer ## Requirements -It builds on wp-framework-settings, which brings wp-framework-core, wp-framework-bootstrap and wp-framework-shared. Its `composer.json` holds its PHP floor under `require.php`, its WordPress floor under `extra.requires-wp` and WooCommerce under `extra.requires-plugins`, which a plugin's `Requires PHP`, `Requires at least` and `Requires Plugins` headers meet. The plugin's own `WC requires at least` header is the WooCommerce floor that `WooCommerceRequirement` checks. +It builds on wp-framework-settings, which brings wp-framework-core, wp-framework-bootstrap and wp-framework-shared. Its `composer.json` holds its PHP floor under `require.php`, its WordPress floor under `extra.requires-wp`, its WooCommerce floor under `extra.requires-wc` and WooCommerce under `extra.requires-plugins`, which a plugin's `Requires PHP`, `Requires at least`, `WC requires at least` and `Requires Plugins` headers meet. `WooCommerceRequirement` checks the plugin's own `WC requires at least` header at runtime. ## Installation @@ -67,6 +67,8 @@ return ( require __DIR__ . '/vendor/ahegyes/wordpress-configs/php/php-scoper/sco ## Usage +`Logging/` extends core's logging and `EditScreen/` settings' object forms, while `ProductData/` and `Setting/` are WooCommerce screens built on settings' `Forms`. A `SettingsTab` adds a WooCommerce settings tab, and a `SettingsSection`, constructed in a `woocommerce_settings_sections_registration` callback, adds a section to an existing one. An `OrderMetaBox` hosts an object form on the order edit screens and stores through `OrderMetaStore`, and `ProductMeta::get_value()` reads a product field's stored value, or a fallback only where the meta is absent. + The plugin's main file requires `load.php` from bootstrap's prefixed copy, then its autoloader once the floor check passes: ```php @@ -101,12 +103,14 @@ use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\WooCommerce\WooCom defined( 'ABSPATH' ) || exit; +require_once __DIR__ . '/includes/options.php'; + add_action( 'plugins_loaded', 'acme_pw_register_hooks', 15 ); // ... function acme_pw_register_hooks(): void { - if ( ! new WooCommerceRequirement( ACME_PW_FILE )->check() ) { + if ( ! WooCommerceRequirement::check( ACME_PW_FILE ) ) { return; } @@ -151,6 +155,12 @@ final class Packing implements ProductDataTabDefinitionInterface, SectionFieldsD 'id' => self::PREFIX . '_default_box', 'title' => __( 'Default box', 'acme-persona-woocommerce' ), ), + array( + 'type' => 'checkbox', + 'id' => ACME_PW_OPTION_DELETE_DATA, + 'title' => __( 'Data on uninstall', 'acme-persona-woocommerce' ), + 'desc' => __( 'Delete the packing data when the plugin is uninstalled', 'acme-persona-woocommerce' ), + ), ); } @@ -165,7 +175,7 @@ final class Packing implements ProductDataTabDefinitionInterface, SectionFieldsD 'fields' => array( array( 'type' => 'text', - 'id' => '_' . self::PREFIX . '_box', + 'id' => ACME_PW_META_BOX, 'label' => __( 'Box', 'acme-persona-woocommerce' ), ), ), @@ -178,6 +188,19 @@ final class Packing implements ProductDataTabDefinitionInterface, SectionFieldsD } ``` +The plugin's `uninstall.php` runs core's `Uninstaller` as core's README shows, and passes `MetaTypes::DEFINITIONS`, so that a footprint can name the `order` and `order_item` meta kinds, whose WooCommerce tables the definitions name, so the uninstall finds them while WooCommerce is inactive: + +```php +Uninstaller::run( + $acme_pw_file, + $acme_pw_footprint, + array( + 'consent' => array( 'option' => $acme_pw_consent ), + 'meta_types' => $acme_pw_meta_types, + ) +); +``` + ## Contract and versioning The five packages release together under one version, which the [changelog](https://github.com/ahegyes/wordpress-framework/blob/trunk/CHANGELOG.md) records. diff --git a/packages/woocommerce/composer.json b/packages/woocommerce/composer.json index 52112da6..5b40f0a8 100644 --- a/packages/woocommerce/composer.json +++ b/packages/woocommerce/composer.json @@ -18,6 +18,7 @@ "dev-trunk": "2.x-dev" }, "requires-wp": "7.1", + "requires-wc": "11.2", "requires-plugins": [ "woocommerce" ], diff --git a/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php b/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php index a80db01f..bd2fb330 100644 --- a/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php +++ b/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php @@ -101,7 +101,7 @@ public function get_default_store(): ObjectStoreInterface { */ #[\Override] public function render_rows( string $title, array $rows ): void { - ( new PostMetaBox( array() ) )->render_rows( $title, $rows ); + new PostMetaBox( array() )->render_rows( $title, $rows ); } // endregion @@ -147,7 +147,7 @@ public function render_meta_box( mixed $order ): void { /** * Saves the form when WooCommerce saves an order from its edit screen, before WooCommerce saves the order's data, so that a status change and its emails see the form's values. * - * On the order tables WooCommerce fires the hook for every order type, and in the posts table only for the types wc_get_order_types( 'order-meta-boxes' ) lists; only the box's own screens print the form's nonce. + * On the order tables WooCommerce fires the hook for every order type, and in the posts table only for the types wc_get_order_types( 'order-meta-boxes' ) lists. Only the box's own screens print the form's nonce. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/woocommerce/src/Logging/WooCommerceLogger.php b/packages/woocommerce/src/Logging/WooCommerceLogger.php index 62488808..06378438 100644 --- a/packages/woocommerce/src/Logging/WooCommerceLogger.php +++ b/packages/woocommerce/src/Logging/WooCommerceLogger.php @@ -19,14 +19,14 @@ final class WooCommerceLogger extends AbstractLogger { // region FIELDS AND CONSTANTS /** - * The severity of the lowest level the threshold logs, or null for the WP_DEBUG default. + * The severity of the lowest level logged, or null for the WP_DEBUG default. * * @since 2.0.0 * @version 2.0.0 * * @var int|null */ - protected ?int $floor = null; + protected ?int $threshold = null; // endregion @@ -50,7 +50,7 @@ public function __construct( protected ?\WC_Logger_Interface $logger = null ) { if ( ! \is_null( $threshold ) ) { - $this->floor = self::get_severity( $threshold ); + $this->threshold = self::get_severity( $threshold ); } } @@ -77,7 +77,7 @@ public function __construct( #[\Override] public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { $severity = self::get_severity( $level ); - if ( $severity < ( $this->floor ?? self::get_default_threshold() ) ) { + if ( $severity < ( $this->threshold ?? self::get_default_threshold() ) ) { return; } @@ -103,7 +103,12 @@ public function log( mixed $level, string|\Stringable $message, array $context = $this->logger ??= \function_exists( 'wc_get_logger' ) ? wc_get_logger() : null; $this->logger?->log( $level, $message, array( 'source' => $this->source ) + $encoded ); - if ( $severity >= self::SEVERITIES[ PsrLogLevel::ERROR ] && ( ! \class_exists( LoggingUtil::class ) || ! LoggingUtil::logging_is_enabled() || \WC_Log_Levels::get_level_severity( LoggingUtil::get_level_threshold() ) > $severity ) ) { + if ( $severity < self::SEVERITIES[ PsrLogLevel::ERROR ] ) { + return; + } + + $dropped = ! \class_exists( LoggingUtil::class ) || ! LoggingUtil::logging_is_enabled() || \WC_Log_Levels::get_level_severity( LoggingUtil::get_level_threshold() ) > $severity; + if ( $dropped ) { \error_log( self::format_record( $this->source, $level, $message, $context ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Errors must stay visible when WooCommerce drops them. } } diff --git a/packages/woocommerce/src/ProductData/ProductDataTab.php b/packages/woocommerce/src/ProductData/ProductDataTab.php index b3329031..147c8162 100644 --- a/packages/woocommerce/src/ProductData/ProductDataTab.php +++ b/packages/woocommerce/src/ProductData/ProductDataTab.php @@ -218,11 +218,11 @@ protected function get_groups( array $tab, \WC_Product $product ): array { * @param list $groups The tab's groups. * @param \WC_Product $product The product being edited or saved. */ - return apply_filters( "{$this->id}/product_data_tab/groups", $tab['groups'], $product ); + return apply_filters( "$this->id/product_data_tab/groups", $tab['groups'], $product ); } /** - * Prints the field's row with its stored value, through WooCommerce's function for its type, or through Forms::render_control() for a render callable, radios or checkboxes. + * Prints the field's row in WooCommerce's product data markup, with its stored value. * * @since 2.0.0 * @version 2.0.0 @@ -252,7 +252,7 @@ protected static function render_field( array $field, \WC_Product $product ): vo 'desc' => $field['description'] ?? '', ) + $field; $group = Forms::is_group( $field ); - echo $group ? '
' . wp_kses_post( $field['label'] ) . '' : '

'; + echo $group ? '

' . wp_kses_post( $field['label'] ) . '' : '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Forms::escape_attribute() escapes the id as the control's id is. echo Forms::render_control( $control, $field['value'], $key, $id ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- render_control() escapes the control, and a render callable its own. echo $group ? '

' : '

'; return; diff --git a/packages/woocommerce/src/Setting/Fields.php b/packages/woocommerce/src/Setting/Fields.php index 5b0bebc4..4380498c 100644 --- a/packages/woocommerce/src/Setting/Fields.php +++ b/packages/woocommerce/src/Setting/Fields.php @@ -121,7 +121,7 @@ public function claim( array $settings, string $section_id ): array { /** * Returns the value to store for an owned field's submission, or null, under which WooCommerce keeps the stored value, and any other option's value unchanged. * - * A disabled field and a field the form did not post are never saved. The submission goes through Forms::sanitize_fields(), where a field without validate, a render callable or the checkboxes type keeps WooCommerce's value when it holds only scalars, null and arrays, and a rejection becomes one of WooCommerce's settings errors. + * A disabled field is never saved, and a field the form did not post keeps its value, but for a checkbox or multiselect, which WooCommerce's form posts nothing for when cleared, so it is stored cleared. * * @since 2.0.0 * @version 2.0.0 @@ -181,7 +181,7 @@ public function render_row( array $resolved ): void { echo ''; // A group's legend names it, so its row heading is plain text. - echo Forms::is_group( $field ) ? esc_html( $field['title'] ) : ''; + echo Forms::is_group( $field ) ? esc_html( $field['title'] ) : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Forms::escape_attribute() escapes the id as the control's id is. echo ''; echo Forms::render_control( $field, $resolved['value'] ?? '', $name, $id ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- render_control() escapes the control. echo ''; @@ -211,10 +211,10 @@ protected function get_owned( mixed $setting ): ?array { * @since 2.0.0 * @version 2.0.0 * - * @param mixed $setting The setting. - * * @phpstan-assert-if-true Field $setting * + * @param mixed $setting The setting. + * * @return bool */ protected static function is_field( mixed $setting ): bool { diff --git a/packages/woocommerce/src/Setting/SettingsSection.php b/packages/woocommerce/src/Setting/SettingsSection.php index 7e997a50..e07a435a 100644 --- a/packages/woocommerce/src/Setting/SettingsSection.php +++ b/packages/woocommerce/src/Setting/SettingsSection.php @@ -11,13 +11,13 @@ /** * Adds a section of framework field arrays under an existing WooCommerce settings tab, saved through WooCommerce's settings API. * - * The section is constructed in a woocommerce_settings_sections_registration callback and registered with the registry it receives, which refuses an id the tab already holds. It owns its fields after WooCommerce's settings filter, including fields other plugins add there, once WooCommerce first asks it for them. A field's `render` callable takes effect only on a type WooCommerce does not draw, because WooCommerce draws its own types without firing the hook that prints it. + * The section is constructed in a woocommerce_settings_sections_registration callback and registered with the registry it receives, which refuses an id another registered section holds. A section whose id is one of the tab's own sections registers but never shows. It owns its fields after WooCommerce's settings filter, including fields other plugins add there, once WooCommerce first asks it for them. A field's render callable takes effect only on a type WooCommerce does not draw, because WooCommerce draws its own types without firing the hook that prints it. * * @since 2.0.0 * @version 2.0.0 * * @phpstan-import-type Field from Forms - * @phpstan-type Section array{tab: string, label: string} + * @phpstan-type Section array{tab: string, label: string} */ final class SettingsSection extends WooCommerceSettingsSection { // region FIELDS AND CONSTANTS diff --git a/packages/woocommerce/src/Setting/SettingsTab.php b/packages/woocommerce/src/Setting/SettingsTab.php index a5c2d20c..8e691bf4 100644 --- a/packages/woocommerce/src/Setting/SettingsTab.php +++ b/packages/woocommerce/src/Setting/SettingsTab.php @@ -10,13 +10,13 @@ /** * Adds a WooCommerce settings tab whose sections hold framework field arrays, saved through WooCommerce's settings API. * - * WooCommerce loads its settings page class only for the woocommerce_get_settings_pages filter, so the tab is constructed in that filter's callback, which registers it. The tab owns every field of its sections after WooCommerce's settings filter, including fields other plugins add there. A field's `render` callable takes effect only on a type WooCommerce does not draw, because WooCommerce draws its own types without firing the hook that prints it. + * WooCommerce loads its settings page class only for the woocommerce_get_settings_pages filter, so the tab is constructed in that filter's callback, which registers it. The tab owns every field of its sections after WooCommerce's settings filter, including fields other plugins add there. A field's render callable takes effect only on a type WooCommerce does not draw, because WooCommerce draws its own types without firing the hook that prints it. * * @since 2.0.0 * @version 2.0.0 * * @phpstan-import-type Field from Forms - * @phpstan-type Tab array{label: string, sections: array} + * @phpstan-type Tab array{label: string, sections: array} */ final class SettingsTab extends \WC_Settings_Page { // region FIELDS AND CONSTANTS diff --git a/packages/woocommerce/src/Uninstall/MetaTypes.php b/packages/woocommerce/src/Uninstall/MetaTypes.php index 41b66c0f..1cdfa520 100644 --- a/packages/woocommerce/src/Uninstall/MetaTypes.php +++ b/packages/woocommerce/src/Uninstall/MetaTypes.php @@ -16,7 +16,7 @@ final class MetaTypes { /** * The meta types of WooCommerce's orders and order items, keyed by the names a footprint's meta kinds use. * - * Order meta lives in post meta and, where WooCommerce created it, in the order meta table. Order item meta lives only in WooCommerce's own table, whose WordPress meta type exists only while WooCommerce is loaded, and plugins are often deleted after WooCommerce is deactivated. + * Order item meta names no WordPress meta type, which exists only while WooCommerce is loaded. * * @since 2.0.0 * @version 2.0.0 diff --git a/packages/woocommerce/src/WooCommerceRequirement.php b/packages/woocommerce/src/WooCommerceRequirement.php index 2f5cecb8..2480070c 100644 --- a/packages/woocommerce/src/WooCommerceRequirement.php +++ b/packages/woocommerce/src/WooCommerceRequirement.php @@ -20,12 +20,8 @@ final class WooCommerceRequirement { * * @since 2.0.0 * @version 2.0.0 - * - * @param string $plugin_file The path to the plugin's entry file. */ - public function __construct( - protected string $plugin_file - ) {} + protected function __construct() {} // endregion @@ -39,15 +35,16 @@ public function __construct( * @since 2.0.0 * @version 2.0.0 * + * @param string $plugin_file The path to the plugin's entry file. + * * @return bool */ - public function check(): bool { - $floor = get_file_data( $this->plugin_file, array( 'floor' => 'WC requires at least' ) )['floor']; + public static function check( string $plugin_file ): bool { + $floor = get_file_data( $plugin_file, array( 'floor' => 'WC requires at least' ) )['floor']; if ( \defined( 'WC_VERSION' ) && ( '' === $floor || \version_compare( WC_VERSION, $floor, '>=' ) ) ) { return true; } - $plugin_file = $this->plugin_file; add_action( 'all_admin_notices', static function () use ( $plugin_file, $floor ): void { diff --git a/tests/EndToEnd/inline-scripts.spec.js b/tests/EndToEnd/inline-scripts.spec.js index 5659153b..acfb3681 100644 --- a/tests/EndToEnd/inline-scripts.spec.js +++ b/tests/EndToEnd/inline-scripts.spec.js @@ -47,7 +47,7 @@ test.describe( 'Inline scripts', () => { ).toBeVisible(); } ); - test( 'a post saved in the block editor keeps its object form and shows its delivered notice as markup', async ( { + test( 'a post saved in the block editor keeps its object form and shows its delivered notice as markup, read aloud as text', async ( { admin, editor, page, @@ -77,12 +77,38 @@ test.describe( 'Inline scripts', () => { } ); await expect( nonce ).not.toHaveValue( 'stale' ); + // The live regions keep only their latest message, so every message they receive is recorded. + await page.evaluate( () => { + window.acmeSpoken = []; + const observer = new window.MutationObserver( () => + document + .querySelectorAll( '.a11y-speak-region' ) + .forEach( ( region ) => { + if ( region.textContent ) { + window.acmeSpoken.push( region.textContent ); + } + } ) + ); + document + .querySelectorAll( '.a11y-speak-region' ) + .forEach( ( region ) => + observer.observe( region, { + childList: true, + characterData: true, + subtree: true, + } ) + ); + } ); + await editor.saveDraft(); await expect( page .locator( '.components-notice' ) .getByRole( 'link', { name: 'the guide' } ) ).toBeVisible(); + await expect + .poll( () => page.evaluate( () => window.acmeSpoken ) ) + .toContain( 'Saved & filed. Read the guide.' ); await page.reload(); await page diff --git a/tests/Fixtures/package-manifests/relative-name.php b/tests/Fixtures/PackageManifests/relative-name.php similarity index 100% rename from tests/Fixtures/package-manifests/relative-name.php rename to tests/Fixtures/PackageManifests/relative-name.php diff --git a/tests/Fixtures/Settings/acme-object-forms.php b/tests/Fixtures/Settings/acme-object-forms.php index 49360af4..781a4283 100644 --- a/tests/Fixtures/Settings/acme-object-forms.php +++ b/tests/Fixtures/Settings/acme-object-forms.php @@ -1,8 +1,8 @@ '; - }; - } - - $behavior = $field['validate'] ?? null; - if ( is_string( $behavior ) && str_starts_with( $behavior, 'acme:' ) ) { - $field['validate'] = static function ( mixed $raw ) use ( $behavior, $field_id ): mixed { - $GLOBALS['acme_outcome']['records'][] = array( 'validate', $field_id, $raw ); - - return match ( $behavior ) { - 'acme:append' => $raw . '!', - 'acme:reject' => new WP_Error( 'acme_rejected', 'Use at most four characters.' ), - 'acme:reject-silently' => new WP_Error( 'acme_rejected' ), - 'acme:record' => $raw, - }; - }; - } - } - - return $fields; -}; - add_action( 'plugins_loaded', - static function () use ( $spec, $acme_with_behaviors ): void { - $fields = $acme_with_behaviors( $spec['form']['fields'] ); + static function () use ( $spec ): void { + $fields = RecordedDefinition::with_behaviors( $spec['form']['fields'] ); $definition = new RecordedDefinition( static function ( string $form_id, int $object_id ) use ( $spec, $fields ): array { $GLOBALS['acme_outcome']['records'][] = array( 'definition' ); @@ -77,12 +48,14 @@ public function __construct( protected string $option ) {} + #[\Override] public function get_values( int $object_id, array $keys ): array { $GLOBALS['acme_outcome']['records'][] = array( 'get_values', $object_id, $keys ); return array_intersect_key( get_option( $this->option, array() )[ $object_id ] ?? array(), array_flip( $keys ) ); } + #[\Override] public function update_values( int $object_id, array $values ): void { $GLOBALS['acme_outcome']['records'][] = array( 'update_values', $object_id, $values ); @@ -95,17 +68,17 @@ public function update_values( int $object_id, array $values ): void { $notices = new AdminNotices( $spec['id'], new ErrorLogLogger( 'acme' ) ); $notices->register_hooks(); - ( new ObjectForm( $spec['id'], $definition, $host, $notices, $store ) )->register_hooks(); + new ObjectForm( $spec['id'], $definition, $host, $notices, $store )->register_hooks(); } ); if ( isset( $spec['contributed'] ) ) { add_filter( "{$spec['id']}/object_form/fields", - static function ( array $fields, int $object_id ) use ( $spec, $acme_with_behaviors ): array { + static function ( array $fields, int $object_id ) use ( $spec ): array { $GLOBALS['acme_outcome']['records'][] = array( 'contributed', $object_id ); - return array_merge( $fields, $acme_with_behaviors( $spec['contributed'] ) ); + return array_merge( $fields, RecordedDefinition::with_behaviors( $spec['contributed'] ) ); }, 10, 2 diff --git a/tests/Fixtures/Settings/acme-options-page.php b/tests/Fixtures/Settings/acme-options-page.php index 44baff66..1e0c6034 100644 --- a/tests/Fixtures/Settings/acme-options-page.php +++ b/tests/Fixtures/Settings/acme-options-page.php @@ -1,8 +1,8 @@ $raw . '!', - 'acme:reject' => new WP_Error( 'acme_rejected', 'Use at most four characters.' ), - 'acme:reject-silently' => new WP_Error( 'acme_rejected' ), - 'acme:record' => $raw, - }; - }; - } + $tab['fields'] = RecordedDefinition::with_behaviors( $tab['fields'] ); } return $tabs; diff --git a/tests/Fixtures/WooCommerce/acme-product-data.php b/tests/Fixtures/WooCommerce/acme-product-data.php index 65d11797..1cfbdab6 100644 --- a/tests/Fixtures/WooCommerce/acme-product-data.php +++ b/tests/Fixtures/WooCommerce/acme-product-data.php @@ -1,8 +1,8 @@ '; - }; - } - - $behavior = $field['validate'] ?? null; - if ( is_string( $behavior ) && str_starts_with( $behavior, 'acme:' ) ) { - $field['validate'] = static function ( mixed $raw ) use ( $behavior, $field_id ): mixed { - $GLOBALS['acme_outcome']['records'][] = array( 'validate', $field_id, $raw ); - - return match ( $behavior ) { - 'acme:append' => $raw . '!', - 'acme:reject' => new WP_Error( 'acme_rejected', 'Use at most four characters.' ), - 'acme:reject-silently' => new WP_Error( 'acme_rejected' ), - 'acme:record' => $raw, - }; - }; - } - } - - return $fields; -}; - add_action( 'plugins_loaded', - static function () use ( $spec, $acme_with_behaviors ): void { + static function () use ( $spec ): void { $definition = new RecordedDefinition( - static function ( string $tab_id, WC_Product $product ) use ( $spec, $acme_with_behaviors ): ?array { + static function ( string $tab_id, WC_Product $product ) use ( $spec ): ?array { if ( 'acme:simple' === ( $spec['supports'] ?? null ) && ! $product->is_type( 'simple' ) ) { return null; } @@ -55,7 +26,8 @@ static function ( string $tab_id, WC_Product $product ) use ( $spec, $acme_with_ if ( $group['per_product'] ?? false ) { $GLOBALS['acme_outcome']['records'][] = array( 'fields', $product->get_id() ); } - $groups[] = array( 'fields' => $acme_with_behaviors( $group['fields'] ) ) + array_intersect_key( $group, array( 'class' => true ) ); + + $groups[] = array( 'fields' => RecordedDefinition::with_behaviors( $group['fields'] ) ) + array_intersect_key( $group, array( 'class' => true ) ); } return array( @@ -71,16 +43,16 @@ static function ( string $tab_id, WC_Product $product ) use ( $spec, $acme_with_ } ); - ( new ProductDataTab( $spec['id'], $definition ) )->register_hooks(); + new ProductDataTab( $spec['id'], $definition )->register_hooks(); } ); if ( isset( $spec['contributed'] ) ) { add_filter( "{$spec['id']}/product_data_tab/groups", - static function ( array $groups, WC_Product $product ) use ( $spec, $acme_with_behaviors ): array { + static function ( array $groups, WC_Product $product ) use ( $spec ): array { $GLOBALS['acme_outcome']['records'][] = array( 'groups', $product->get_id() ); - $groups[] = array( 'fields' => $acme_with_behaviors( $spec['contributed'] ) ); + $groups[] = array( 'fields' => RecordedDefinition::with_behaviors( $spec['contributed'] ) ); return $groups; }, diff --git a/tests/Fixtures/WooCommerce/acme-settings.php b/tests/Fixtures/WooCommerce/acme-settings.php index 2be30486..992ee707 100644 --- a/tests/Fixtures/WooCommerce/acme-settings.php +++ b/tests/Fixtures/WooCommerce/acme-settings.php @@ -1,8 +1,8 @@ '; - }; - } - - $behavior = $field['validate'] ?? null; - if ( is_string( $behavior ) && str_starts_with( $behavior, 'acme:' ) ) { - $field['validate'] = static function ( mixed $raw ) use ( $behavior, $field_id ): mixed { - $GLOBALS['acme_outcome']['records'][] = array( 'validate', $field_id, $raw ); - - return match ( $behavior ) { - 'acme:append' => $raw . '!', - 'acme:uppercase' => strtoupper( $raw ), - 'acme:reject' => new WP_Error( 'acme_rejected', 'Use at most four characters.' ), - 'acme:reject-silently' => new WP_Error( 'acme_rejected' ), - 'acme:record' => $raw, - }; - }; - } - } - - return $fields; -}; - if ( isset( $spec['tab'] ) ) { add_filter( 'woocommerce_get_settings_pages', - static function ( array $pages ) use ( $spec, $acme_with_behaviors ): array { + static function ( array $pages ) use ( $spec ): array { $pages[] = new SettingsTab( $spec['tab']['id'], array( 'label' => $spec['tab']['label'], 'sections' => array_map( static fn ( array $section ): string => $section['label'], $spec['tab']['sections'] ), ), - new RecordedDefinition( static fn ( string $tab_id, string $section_id ): array => $acme_with_behaviors( $spec['tab']['sections'][ $section_id ]['fields'] ) ) + new RecordedDefinition( static fn ( string $tab_id, string $section_id ): array => RecordedDefinition::with_behaviors( $spec['tab']['sections'][ $section_id ]['fields'] ) ) ); return $pages; @@ -64,7 +34,7 @@ static function ( array $pages ) use ( $spec, $acme_with_behaviors ): array { if ( isset( $spec['section'] ) ) { add_action( 'woocommerce_settings_sections_registration', - static function ( $registry ) use ( $spec, $acme_with_behaviors ): void { + static function ( $registry ) use ( $spec ): void { $registry->register( new SettingsSection( $spec['section']['id'], @@ -72,7 +42,7 @@ static function ( $registry ) use ( $spec, $acme_with_behaviors ): void { 'tab' => $spec['section']['tab'], 'label' => $spec['section']['label'], ), - new RecordedDefinition( static fn (): array => $acme_with_behaviors( $spec['section']['fields'] ) ) + new RecordedDefinition( static fn (): array => RecordedDefinition::with_behaviors( $spec['section']['fields'] ) ) ) ); } @@ -82,7 +52,7 @@ static function ( $registry ) use ( $spec, $acme_with_behaviors ): void { if ( isset( $spec['contributed'] ) ) { add_filter( "woocommerce_get_settings_{$spec['contributed']['tab']}", - static fn ( array $settings, mixed $section_id ): array => $spec['contributed']['section'] === $section_id ? array_merge( $settings, $acme_with_behaviors( $spec['contributed']['fields'] ) ) : $settings, + static fn ( array $settings, mixed $section_id ): array => $spec['contributed']['section'] === $section_id ? array_merge( $settings, RecordedDefinition::with_behaviors( $spec['contributed']['fields'] ) ) : $settings, 10, 2 ); diff --git a/tests/Fixtures/WooCommerce/acme-uninstall.php b/tests/Fixtures/WooCommerce/acme-uninstall.php index e9382190..2bc7f7dc 100644 --- a/tests/Fixtures/WooCommerce/acme-uninstall.php +++ b/tests/Fixtures/WooCommerce/acme-uninstall.php @@ -15,7 +15,7 @@ add_action( 'admin_init', static function () use ( $spec ): void { - $GLOBALS['acme_outcome']['records']['woocommerce loaded'] = class_exists( 'WooCommerce', false ); + $GLOBALS['acme_outcome']['records']['woocommerce_loaded'] = class_exists( 'WooCommerce', false ); Uninstaller::run( $spec['plugin_file'], $spec['footprint'], diff --git a/tests/Fixtures/consumer-a/acme-consumer-a.php b/tests/Fixtures/consumer-a/acme-consumer-a.php index 0db9c20a..7e56273a 100644 --- a/tests/Fixtures/consumer-a/acme-consumer-a.php +++ b/tests/Fixtures/consumer-a/acme-consumer-a.php @@ -1,5 +1,7 @@ =3,<=3.3.52", "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4", "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4", - "filegator/filegator": "<7.8", + "filegator/filegator": "<=7.14.4", "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", "firebase/php-jwt": "<7", @@ -2553,6 +2553,7 @@ "hillelcoren/invoice-ninja": "<5.3.35", "himiklab/yii2-jqgrid-widget": "<1.0.8", "hjue/justwriting": "<=1", + "hms5232/laravel-swagger": "<0.2.1", "hov/jobfair": "<1.0.13|>=2,<2.0.2", "httpsoft/http-message": "<1.0.12", "hybridauth/hybridauth": "<=3.12.2", @@ -3336,8 +3337,8 @@ "type": "tidelift" } ], - "time": "2026-11-07T01:38:18+00:00", - "published-time": "2026-11-07T01:38:22+00:00" + "time": "2026-11-10T03:11:40+00:00", + "published-time": "2026-11-10T03:11:44+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/consumer-a/scoper.inc.php b/tests/Fixtures/consumer-a/scoper.inc.php index c32d7a8c..4b7048dc 100644 --- a/tests/Fixtures/consumer-a/scoper.inc.php +++ b/tests/Fixtures/consumer-a/scoper.inc.php @@ -2,6 +2,9 @@ /** * Returns the php-scoper config that prefixes the framework and PHP-DI with the plugin's namespace. * + * @since 1.0.0 + * @version 1.0.0 + * * @package Acme\ConsumerA */ diff --git a/tests/Fixtures/consumer-a/src/FaultInjector.php b/tests/Fixtures/consumer-a/src/FaultInjector.php index 92b17fc0..6eb58e1e 100644 --- a/tests/Fixtures/consumer-a/src/FaultInjector.php +++ b/tests/Fixtures/consumer-a/src/FaultInjector.php @@ -2,7 +2,6 @@ namespace Acme\ConsumerA; -use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\ComponentInterface; use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\ConditionalComponentInterface; \defined( 'ABSPATH' ) || exit; @@ -13,7 +12,7 @@ * @since 1.0.0 * @version 1.0.0 */ -final class FaultInjector implements ComponentInterface, ConditionalComponentInterface { +final class FaultInjector implements ConditionalComponentInterface { // region MAGIC METHODS /** diff --git a/tests/Fixtures/consumer-a/src/Plugin.php b/tests/Fixtures/consumer-a/src/Plugin.php index b116a5ba..3cce1e7a 100644 --- a/tests/Fixtures/consumer-a/src/Plugin.php +++ b/tests/Fixtures/consumer-a/src/Plugin.php @@ -69,7 +69,7 @@ final class Plugin { * * @var PluginBoot */ - public readonly PluginBoot $boot; + public readonly PluginBoot $boundary; /** * The scheduler, which registers its WP-Cron schedules even while the plugin is blocked. @@ -124,7 +124,7 @@ final class Plugin { public function __construct() { $this->logger = new WooCommerceLogger( self::SLUG, \defined( 'ACME_CA_DEBUG' ) && true === \constant( 'ACME_CA_DEBUG' ) ? 'debug' : null ); $this->scheduler = new Scheduler( self::PREFIX ); - $this->boot = new PluginBoot( ACME_CA_FILE, self::PREFIX, $this->logger ); + $this->boundary = new PluginBoot( ACME_CA_FILE, self::PREFIX, $this->logger ); } // endregion @@ -140,7 +140,7 @@ public function __construct() { * @return void */ public function boot(): void { - $this->boot->run( $this->compose( ... ) ); + $this->boundary->run( $this->compose( ... ) ); } // endregion @@ -157,7 +157,7 @@ public function boot(): void { */ protected function compose(): bool { $this->scheduler->register_hooks(); // Before the WooCommerce check, so the plugin's recurring WP-Cron events keep their schedules while it is blocked. - if ( ! ( new WooCommerceRequirement( ACME_CA_FILE ) )->check() ) { + if ( ! WooCommerceRequirement::check( ACME_CA_FILE ) ) { return false; } diff --git a/tests/Fixtures/consumer-a/uninstall.php b/tests/Fixtures/consumer-a/uninstall.php index 6721108b..58a0d6f6 100644 --- a/tests/Fixtures/consumer-a/uninstall.php +++ b/tests/Fixtures/consumer-a/uninstall.php @@ -30,7 +30,11 @@ require_once __DIR__ . '/vendor/autoload.php'; require_once __DIR__ . '/includes/footprint.php'; $acme_ca_footprint = acme_ca_get_footprint(); + $acme_ca_consent = Plugin::PREFIX . '_settings'; $acme_ca_meta_types = MetaTypes::DEFINITIONS; + if ( ! class_exists( Uninstaller::class ) ) { + throw new RuntimeException( 'its uninstall engine is missing.' ); + } } catch ( Throwable $error ) { // WP-CLI then fails and keeps the files for a rerun on a complete build, while a web request deletes them and leaves the data. if ( defined( 'WP_CLI' ) && true === WP_CLI ) { @@ -47,7 +51,7 @@ $acme_ca_footprint, array( 'consent' => array( - 'option' => Plugin::PREFIX . '_settings', + 'option' => $acme_ca_consent, 'key' => 'delete_data', ), 'meta_types' => $acme_ca_meta_types, diff --git a/tests/Fixtures/consumer-b/acme-consumer-b.php b/tests/Fixtures/consumer-b/acme-consumer-b.php index ad6e75a6..844329a7 100644 --- a/tests/Fixtures/consumer-b/acme-consumer-b.php +++ b/tests/Fixtures/consumer-b/acme-consumer-b.php @@ -1,5 +1,7 @@ =3,<=3.3.52", "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4", "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4", - "filegator/filegator": "<7.8", + "filegator/filegator": "<=7.14.4", "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", "firebase/php-jwt": "<7", @@ -2467,6 +2467,7 @@ "hillelcoren/invoice-ninja": "<5.3.35", "himiklab/yii2-jqgrid-widget": "<1.0.8", "hjue/justwriting": "<=1", + "hms5232/laravel-swagger": "<0.2.1", "hov/jobfair": "<1.0.13|>=2,<2.0.2", "httpsoft/http-message": "<1.0.12", "hybridauth/hybridauth": "<=3.12.2", @@ -3250,8 +3251,8 @@ "type": "tidelift" } ], - "time": "2026-11-07T01:38:18+00:00", - "published-time": "2026-11-07T01:38:22+00:00" + "time": "2026-11-10T03:11:40+00:00", + "published-time": "2026-11-10T03:11:44+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/consumer-b/scoper.inc.php b/tests/Fixtures/consumer-b/scoper.inc.php index 6bcaf35b..7f2a971e 100644 --- a/tests/Fixtures/consumer-b/scoper.inc.php +++ b/tests/Fixtures/consumer-b/scoper.inc.php @@ -2,6 +2,9 @@ /** * Returns the php-scoper config that prefixes the framework and PHP-DI with the plugin's namespace. * + * @since 1.0.0 + * @version 1.0.0 + * * @package Acme\ConsumerB */ diff --git a/tests/Fixtures/consumer-b/src/Plugin.php b/tests/Fixtures/consumer-b/src/Plugin.php index 2e19aaff..13471085 100644 --- a/tests/Fixtures/consumer-b/src/Plugin.php +++ b/tests/Fixtures/consumer-b/src/Plugin.php @@ -61,7 +61,7 @@ final class Plugin { * * @var PluginBoot */ - public readonly PluginBoot $boot; + public readonly PluginBoot $boundary; /** * The kernel, once the composition built it, which tells which components loaded. @@ -94,8 +94,8 @@ final class Plugin { * @version 1.0.0 */ public function __construct() { - $this->logger = new ErrorLogLogger( self::SLUG ); - $this->boot = new PluginBoot( ACME_CB_FILE, self::PREFIX, $this->logger ); + $this->logger = new ErrorLogLogger( self::SLUG ); + $this->boundary = new PluginBoot( ACME_CB_FILE, self::PREFIX, $this->logger ); } // endregion @@ -111,7 +111,7 @@ public function __construct() { * @return void */ public function boot(): void { - $this->boot->run( $this->compose( ... ) ); + $this->boundary->run( $this->compose( ... ) ); } // endregion diff --git a/tests/Fixtures/end-to-end/acme-end-to-end.php b/tests/Fixtures/end-to-end/acme-end-to-end.php index 367e3c2d..ffab61bd 100644 --- a/tests/Fixtures/end-to-end/acme-end-to-end.php +++ b/tests/Fixtures/end-to-end/acme-end-to-end.php @@ -65,7 +65,7 @@ public function get_object_form( string $form_id, int $object_id ): array { static function ( int $post_id, WP_Post $post ) use ( $acme_notices ): void { // Opening a new post saves an auto-draft, whose notice the editor would deliver as it loads. if ( 'auto-draft' !== $post->post_status ) { - $acme_notices->add( new Notice( 'saved', 'Saved. Read the guide.', NoticeType::Success ) ); + $acme_notices->add( new Notice( 'saved', 'Saved & filed. Read the guide.', NoticeType::Success ) ); } }, 10, diff --git a/tests/Fixtures/personas/bootstrap/acme-persona-bootstrap.php b/tests/Fixtures/personas/bootstrap/acme-persona-bootstrap.php index 55bdf4a6..75b8595e 100644 --- a/tests/Fixtures/personas/bootstrap/acme-persona-bootstrap.php +++ b/tests/Fixtures/personas/bootstrap/acme-persona-bootstrap.php @@ -1,5 +1,7 @@ =3,<=3.3.52", "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4", "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4", - "filegator/filegator": "<7.8", + "filegator/filegator": "<=7.14.4", "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", "firebase/php-jwt": "<7", @@ -2066,6 +2066,7 @@ "hillelcoren/invoice-ninja": "<5.3.35", "himiklab/yii2-jqgrid-widget": "<1.0.8", "hjue/justwriting": "<=1", + "hms5232/laravel-swagger": "<0.2.1", "hov/jobfair": "<1.0.13|>=2,<2.0.2", "httpsoft/http-message": "<1.0.12", "hybridauth/hybridauth": "<=3.12.2", @@ -2849,8 +2850,8 @@ "type": "tidelift" } ], - "time": "2026-11-07T01:38:18+00:00", - "published-time": "2026-11-07T01:38:22+00:00" + "time": "2026-11-10T03:11:40+00:00", + "published-time": "2026-11-10T03:11:44+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/personas/bootstrap/scoper.inc.php b/tests/Fixtures/personas/bootstrap/scoper.inc.php index b39364ed..79659550 100644 --- a/tests/Fixtures/personas/bootstrap/scoper.inc.php +++ b/tests/Fixtures/personas/bootstrap/scoper.inc.php @@ -2,6 +2,9 @@ /** * Returns the php-scoper config that prefixes the bootstrap package with the plugin's namespace. * + * @since 1.0.0 + * @version 1.0.0 + * * @package Acme\PersonaBootstrap */ diff --git a/tests/Fixtures/personas/core/acme-persona-core.php b/tests/Fixtures/personas/core/acme-persona-core.php index d038511c..2c570a4f 100644 --- a/tests/Fixtures/personas/core/acme-persona-core.php +++ b/tests/Fixtures/personas/core/acme-persona-core.php @@ -1,5 +1,7 @@ =3,<=3.3.52", "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4", "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4", - "filegator/filegator": "<7.8", + "filegator/filegator": "<=7.14.4", "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", "firebase/php-jwt": "<7", @@ -2428,6 +2432,7 @@ "hillelcoren/invoice-ninja": "<5.3.35", "himiklab/yii2-jqgrid-widget": "<1.0.8", "hjue/justwriting": "<=1", + "hms5232/laravel-swagger": "<0.2.1", "hov/jobfair": "<1.0.13|>=2,<2.0.2", "httpsoft/http-message": "<1.0.12", "hybridauth/hybridauth": "<=3.12.2", @@ -3211,8 +3216,8 @@ "type": "tidelift" } ], - "time": "2026-11-07T01:38:18+00:00", - "published-time": "2026-11-07T01:38:22+00:00" + "time": "2026-11-10T03:11:40+00:00", + "published-time": "2026-11-10T03:11:44+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/personas/core/functions.php b/tests/Fixtures/personas/core/functions.php index 4fdad8e4..a9bb42f1 100644 --- a/tests/Fixtures/personas/core/functions.php +++ b/tests/Fixtures/personas/core/functions.php @@ -1,6 +1,6 @@ , user_meta_site_prefixes: list, user_meta_global_prefixes: list, network_options: list, cron_hooks: list, as_groups: list} + */ +function acme_pc_get_footprint(): array { + return array( + 'option_prefixes' => array( Plugin::PREFIX . '_' ), + 'user_meta_site_prefixes' => array( Plugin::PREFIX . '_' ), + 'user_meta_global_prefixes' => array( Plugin::PREFIX . '_' ), + 'network_options' => array( Plugin::PREFIX . '_network_notices' ), + 'cron_hooks' => array( Plugin::PREFIX . '_revision/migration/run' ), + 'as_groups' => array( Plugin::PREFIX ), + ); +} diff --git a/tests/Fixtures/personas/core/includes/migrations.php b/tests/Fixtures/personas/core/includes/migrations.php index eb7aa7ce..f70b891b 100644 --- a/tests/Fixtures/personas/core/includes/migrations.php +++ b/tests/Fixtures/personas/core/includes/migrations.php @@ -8,8 +8,6 @@ * @package Acme\PersonaCore */ -use Acme\PersonaCore\Plugin; - defined( 'ABSPATH' ) || exit; /** @@ -37,7 +35,7 @@ function acme_pc_get_migration_steps(): array { * @return void */ function acme_pc_add_settings(): void { - if ( false === get_option( Plugin::PREFIX . '_settings' ) && ! add_option( Plugin::PREFIX . '_settings', array( 'greeting' => 'Hello' ) ) ) { + if ( false === get_option( ACME_PC_OPTION_SETTINGS ) && ! add_option( ACME_PC_OPTION_SETTINGS, array( 'greeting' => 'Hello' ) ) ) { throw new RuntimeException( 'The settings could not be stored.' ); } } diff --git a/tests/Fixtures/personas/core/includes/options.php b/tests/Fixtures/personas/core/includes/options.php new file mode 100644 index 00000000..3bb77a31 --- /dev/null +++ b/tests/Fixtures/personas/core/includes/options.php @@ -0,0 +1,23 @@ + */ public const array COMPONENTS = array( + Scheduler::class, AdminNotices::class, Greeting::class, ); @@ -63,17 +65,17 @@ final class Plugin { * * @var PluginBoot */ - public readonly PluginBoot $boot; + public readonly PluginBoot $boundary; /** - * The kernel, once the composition built it, which tells which components loaded. + * The scheduler of the plugin's background work, which the migration runner continues through. * * @since 1.0.0 * @version 1.0.0 * - * @var PluginKernel|null + * @var Scheduler */ - protected(set) ?PluginKernel $kernel = null; + public readonly Scheduler $scheduler; /** * The migration runner, once the composition built it. @@ -86,24 +88,24 @@ final class Plugin { protected(set) ?MigrationRunner $migrations = null; /** - * The logger every part of the plugin writes to. + * The kernel, once the composition built it, which tells which components loaded. * * @since 1.0.0 * @version 1.0.0 * - * @var PsrLoggerInterface + * @var PluginKernel|null */ - protected readonly PsrLoggerInterface $logger; + protected(set) ?PluginKernel $kernel = null; /** - * The scheduler of the plugin's background work, which the migration runner continues through. + * The logger every part of the plugin writes to. * * @since 1.0.0 * @version 1.0.0 * - * @var Scheduler + * @var PsrLoggerInterface */ - protected readonly Scheduler $scheduler; + protected readonly PsrLoggerInterface $logger; // endregion @@ -117,8 +119,8 @@ final class Plugin { */ public function __construct() { $this->logger = new ErrorLogLogger( self::SLUG ); - $this->boot = new PluginBoot( ACME_PC_FILE, self::PREFIX, $this->logger ); $this->scheduler = new Scheduler( self::PREFIX ); + $this->boundary = new PluginBoot( ACME_PC_FILE, self::PREFIX, $this->logger ); } // endregion @@ -134,7 +136,7 @@ public function __construct() { * @return void */ public function boot(): void { - $this->boot->run( $this->compose( ... ) ); + $this->boundary->run( $this->compose( ... ) ); } // endregion @@ -142,7 +144,7 @@ public function boot(): void { // region HELPERS /** - * Registers the scheduler and the migrations, then builds the kernel and boots the plugin's components. + * Registers the migrations, then builds the kernel and boots the plugin's components, the scheduler first. * * @since 1.0.0 * @version 1.0.0 @@ -150,29 +152,32 @@ public function boot(): void { * @return bool */ protected function compose(): bool { - $this->scheduler->register_hooks(); $this->migrations = new MigrationRunner( ACME_PC_FILE, self::PREFIX . '_revision', acme_pc_get_migration_steps(), $this->logger, $this->scheduler ); $this->migrations->register_hooks(); - $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger ) )->build() ); + $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() ); $this->kernel->boot( self::COMPONENTS ); return true; } /** - * Returns the container's definitions of the services the root builds. + * Returns the container's definitions of the services the root builds, which the kernel's components share. * * @since 1.0.0 * @version 1.0.0 * - * @param PsrLoggerInterface $logger The plugin's logger. + * @param PsrLoggerInterface $logger The plugin's logger. + * @param Scheduler $scheduler The plugin's scheduler. * * @return array */ - protected function get_definitions( PsrLoggerInterface $logger ): array { + protected function get_definitions( PsrLoggerInterface $logger, Scheduler $scheduler ): array { return array( - AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::PREFIX, $logger ), + PsrLoggerInterface::class => static fn (): PsrLoggerInterface => $logger, + Scheduler::class => static fn (): Scheduler => $scheduler, + SchedulerInterface::class => static fn (): Scheduler => $scheduler, + AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::PREFIX, $logger ), ); } diff --git a/tests/Fixtures/personas/core/uninstall.php b/tests/Fixtures/personas/core/uninstall.php new file mode 100644 index 00000000..6dd4e7ef --- /dev/null +++ b/tests/Fixtures/personas/core/uninstall.php @@ -0,0 +1,55 @@ +getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- The uninstall request has no other channel. + return; +} + +Uninstaller::run( + $acme_pc_file, + $acme_pc_footprint, + array( + 'consent' => array( + 'option' => $acme_pc_consent, + 'key' => 'delete_data', + ), + ) +); diff --git a/tests/Fixtures/personas/settings/acme-persona-settings.php b/tests/Fixtures/personas/settings/acme-persona-settings.php index dc2697dd..6c013e29 100644 --- a/tests/Fixtures/personas/settings/acme-persona-settings.php +++ b/tests/Fixtures/personas/settings/acme-persona-settings.php @@ -1,5 +1,7 @@ =3,<=3.3.52", "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4", "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4", - "filegator/filegator": "<7.8", + "filegator/filegator": "<=7.14.4", "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", "firebase/php-jwt": "<7", @@ -2271,6 +2275,7 @@ "hillelcoren/invoice-ninja": "<5.3.35", "himiklab/yii2-jqgrid-widget": "<1.0.8", "hjue/justwriting": "<=1", + "hms5232/laravel-swagger": "<0.2.1", "hov/jobfair": "<1.0.13|>=2,<2.0.2", "httpsoft/http-message": "<1.0.12", "hybridauth/hybridauth": "<=3.12.2", @@ -3054,8 +3059,8 @@ "type": "tidelift" } ], - "time": "2026-11-07T01:38:18+00:00", - "published-time": "2026-11-07T01:38:22+00:00" + "time": "2026-11-10T03:11:40+00:00", + "published-time": "2026-11-10T03:11:44+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/personas/settings/functions.php b/tests/Fixtures/personas/settings/functions.php index 57f8a04d..ee7ff9e9 100644 --- a/tests/Fixtures/personas/settings/functions.php +++ b/tests/Fixtures/personas/settings/functions.php @@ -1,6 +1,6 @@ register_hooks(); + new OptionsPage( Settings::PREFIX, new Settings() )->register_hooks(); + new ObjectForm( Settings::PREFIX . '_details', new Details(), new PostMetaBox( array( 'post' ) ), $notices )->register_hooks(); } diff --git a/tests/Fixtures/personas/settings/scoper.inc.php b/tests/Fixtures/personas/settings/scoper.inc.php index 014c8517..7d9c478d 100644 --- a/tests/Fixtures/personas/settings/scoper.inc.php +++ b/tests/Fixtures/personas/settings/scoper.inc.php @@ -2,6 +2,9 @@ /** * Returns the php-scoper config that prefixes the framework with the plugin's namespace. * + * @since 1.0.0 + * @version 1.0.0 + * * @package Acme\PersonaSettings */ diff --git a/tests/Fixtures/personas/settings/src/Details.php b/tests/Fixtures/personas/settings/src/Details.php new file mode 100644 index 00000000..32112e91 --- /dev/null +++ b/tests/Fixtures/personas/settings/src/Details.php @@ -0,0 +1,40 @@ + __( 'Details', 'acme-persona-settings' ), + 'capability' => 'edit_posts', + 'fields' => array( + array( + 'type' => 'text', + 'id' => '_' . Settings::PREFIX . '_subtitle', + 'title' => __( 'Subtitle', 'acme-persona-settings' ), + ), + ), + ); + } + + // endregion +} diff --git a/tests/Fixtures/personas/settings/src/Settings.php b/tests/Fixtures/personas/settings/src/Settings.php index b1ede0cd..0c47af58 100644 --- a/tests/Fixtures/personas/settings/src/Settings.php +++ b/tests/Fixtures/personas/settings/src/Settings.php @@ -49,10 +49,15 @@ public function get_options_page( string $page_id ): array { 'option' => self::PREFIX . '_settings', 'fields' => array( array( - 'type' => 'number', - 'id' => 'limit', - 'title' => __( 'Limit', 'acme-persona-settings' ), - 'validate' => array( $this, 'validate_limit' ), + 'type' => 'number', + 'id' => 'limit', + 'title' => __( 'Limit', 'acme-persona-settings' ), + 'default' => '10', + 'custom_attributes' => array( + 'min' => '1', + 'step' => '1', + ), + 'validate' => array( $this, 'validate_limit' ), ), ), ), diff --git a/tests/Fixtures/personas/shared/composer.lock b/tests/Fixtures/personas/shared/composer.lock index 61b049f7..c4cf4c5a 100644 --- a/tests/Fixtures/personas/shared/composer.lock +++ b/tests/Fixtures/personas/shared/composer.lock @@ -1686,12 +1686,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "a09945ae024a4cb2be97d080558455b979e3f743" + "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/a09945ae024a4cb2be97d080558455b979e3f743", - "reference": "a09945ae024a4cb2be97d080558455b979e3f743", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4", + "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4", "shasum": "" }, "conflict": { @@ -1992,7 +1992,7 @@ "filament/forms": ">=3,<=3.3.52", "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4", "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4", - "filegator/filegator": "<7.8", + "filegator/filegator": "<=7.14.4", "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", "firebase/php-jwt": "<7", @@ -2070,6 +2070,7 @@ "hillelcoren/invoice-ninja": "<5.3.35", "himiklab/yii2-jqgrid-widget": "<1.0.8", "hjue/justwriting": "<=1", + "hms5232/laravel-swagger": "<0.2.1", "hov/jobfair": "<1.0.13|>=2,<2.0.2", "httpsoft/http-message": "<1.0.12", "hybridauth/hybridauth": "<=3.12.2", @@ -2853,8 +2854,8 @@ "type": "tidelift" } ], - "time": "2026-11-07T01:38:18+00:00", - "published-time": "2026-11-07T01:38:22+00:00" + "time": "2026-11-10T03:11:40+00:00", + "published-time": "2026-11-10T03:11:44+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/personas/shared/run.php b/tests/Fixtures/personas/shared/run.php index f36ebf48..beb6b4e6 100644 --- a/tests/Fixtures/personas/shared/run.php +++ b/tests/Fixtures/personas/shared/run.php @@ -1,26 +1,41 @@ 'box' ) ); + echo \json_encode( array( 'wordpress' => \defined( 'ABSPATH' ), 'version' => array( (string) Version::from_string( '2.1.0-rc.1' ), Version::from_string( '2.1.0' )->compare( Version::from_string( '2.1.0-rc.1' ) ) ), + 'rejected' => $acme_rejected, 'results' => array( Success::from( 'acme' )->match( static fn ( string $value ): string => "found $value", static fn (): string => 'failed' ), Failure::from( new UnknownKey( 'acme' ) )->match( static fn (): string => 'found', static fn ( UnknownKey $error ): string => "unknown $error->key" ), ), + 'narrowed' => array( $acme_catalog->describe( 'acme' ), $acme_catalog->describe( 'other' ) ), 'equality' => array( new Point( 1, 2 )->equals( new Point( 1, 2 ) ), new Point( 1, 2 )->equals( new Point( 2, 1 ) ) ), ), \JSON_THROW_ON_ERROR diff --git a/tests/Fixtures/personas/shared/scoper.inc.php b/tests/Fixtures/personas/shared/scoper.inc.php index d6de9fe9..da20d790 100644 --- a/tests/Fixtures/personas/shared/scoper.inc.php +++ b/tests/Fixtures/personas/shared/scoper.inc.php @@ -2,6 +2,9 @@ /** * Returns the php-scoper config that prefixes the shared package with the project's namespace. * + * @since 1.0.0 + * @version 1.0.0 + * * @package Acme\PersonaShared */ diff --git a/tests/Fixtures/personas/shared/src/Catalog.php b/tests/Fixtures/personas/shared/src/Catalog.php new file mode 100644 index 00000000..6bcf3d5d --- /dev/null +++ b/tests/Fixtures/personas/shared/src/Catalog.php @@ -0,0 +1,66 @@ + $values The values, keyed by their keys. + */ + public function __construct( + public array $values + ) {} + + // endregion + + // region METHODS + + /** + * Returns the value the key holds, or the failure that names the key. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @param string $key The key. + * + * @return AbstractResult + */ + #[\NoDiscard] + public function find( string $key ): AbstractResult { + return isset( $this->values[ $key ] ) ? Success::from( $this->values[ $key ] ) : Failure::from( new UnknownKey( $key ) ); + } + + /** + * Returns what a lookup of the key found, in words. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @param string $key The key. + * + * @return string + */ + public function describe( string $key ): string { + $found = $this->find( $key ); + + return $found->is_success() ? "found $found->value" : "unknown {$found->error->key}"; + } + + // endregion +} diff --git a/tests/Fixtures/personas/woocommerce/acme-persona-woocommerce.php b/tests/Fixtures/personas/woocommerce/acme-persona-woocommerce.php index 106ebb6d..d0278ec8 100644 --- a/tests/Fixtures/personas/woocommerce/acme-persona-woocommerce.php +++ b/tests/Fixtures/personas/woocommerce/acme-persona-woocommerce.php @@ -1,5 +1,7 @@ =3,<=3.3.52", "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4", "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4", - "filegator/filegator": "<7.8", + "filegator/filegator": "<=7.14.4", "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", "firebase/php-jwt": "<7", @@ -2357,6 +2361,7 @@ "hillelcoren/invoice-ninja": "<5.3.35", "himiklab/yii2-jqgrid-widget": "<1.0.8", "hjue/justwriting": "<=1", + "hms5232/laravel-swagger": "<0.2.1", "hov/jobfair": "<1.0.13|>=2,<2.0.2", "httpsoft/http-message": "<1.0.12", "hybridauth/hybridauth": "<=3.12.2", @@ -3140,8 +3145,8 @@ "type": "tidelift" } ], - "time": "2026-11-07T01:38:18+00:00", - "published-time": "2026-11-07T01:38:22+00:00" + "time": "2026-11-10T03:11:40+00:00", + "published-time": "2026-11-10T03:11:44+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/personas/woocommerce/functions.php b/tests/Fixtures/personas/woocommerce/functions.php index 5d8960c9..bc7c8aa7 100644 --- a/tests/Fixtures/personas/woocommerce/functions.php +++ b/tests/Fixtures/personas/woocommerce/functions.php @@ -1,6 +1,6 @@ check() ) { + if ( ! WooCommerceRequirement::check( ACME_PW_FILE ) ) { return; } diff --git a/tests/Fixtures/personas/woocommerce/includes/footprint.php b/tests/Fixtures/personas/woocommerce/includes/footprint.php new file mode 100644 index 00000000..c0f985fd --- /dev/null +++ b/tests/Fixtures/personas/woocommerce/includes/footprint.php @@ -0,0 +1,28 @@ +, meta: array{post: list}} + */ +function acme_pw_get_footprint(): array { + return array( + 'option_prefixes' => array( Packing::PREFIX . '_' ), + 'meta' => array( 'post' => array( ACME_PW_META_BOX ) ), + ); +} diff --git a/tests/Fixtures/personas/woocommerce/includes/index.php b/tests/Fixtures/personas/woocommerce/includes/index.php new file mode 100644 index 00000000..f7673463 --- /dev/null +++ b/tests/Fixtures/personas/woocommerce/includes/index.php @@ -0,0 +1 @@ + self::PREFIX . '_default_box', 'title' => __( 'Default box', 'acme-persona-woocommerce' ), ), + array( + 'type' => 'checkbox', + 'id' => ACME_PW_OPTION_DELETE_DATA, + 'title' => __( 'Data on uninstall', 'acme-persona-woocommerce' ), + 'desc' => __( 'Delete the packing data when the plugin is uninstalled', 'acme-persona-woocommerce' ), + ), ); } @@ -62,7 +68,7 @@ public function get_product_data_tab( string $tab_id, \WC_Product $product ): ar 'fields' => array( array( 'type' => 'text', - 'id' => '_' . self::PREFIX . '_box', + 'id' => ACME_PW_META_BOX, 'label' => __( 'Box', 'acme-persona-woocommerce' ), ), ), diff --git a/tests/Fixtures/personas/woocommerce/uninstall.php b/tests/Fixtures/personas/woocommerce/uninstall.php new file mode 100644 index 00000000..f6f0abff --- /dev/null +++ b/tests/Fixtures/personas/woocommerce/uninstall.php @@ -0,0 +1,55 @@ +getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- The uninstall request has no other channel. + return; +} + +Uninstaller::run( + $acme_pw_file, + $acme_pw_footprint, + array( + 'consent' => array( 'option' => $acme_pw_consent ), + 'meta_types' => $acme_pw_meta_types, + ) +); diff --git a/tests/Integration/Bootstrap/RequirementsTest.php b/tests/Integration/Bootstrap/RequirementsTest.php index dd9ea7c7..93596dcd 100644 --- a/tests/Integration/Bootstrap/RequirementsTest.php +++ b/tests/Integration/Bootstrap/RequirementsTest.php @@ -5,6 +5,8 @@ use DeepWebSolutions\Framework\Bootstrap\Requirements; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\RendersAdminScreensTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\WritesPluginFilesTrait; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -13,11 +15,11 @@ final class RequirementsTest extends TestCase { use CreatesUsersTrait; use IsolatesHooksTrait; + use RendersAdminScreensTrait; + use WritesPluginFilesTrait; protected string $slug; - protected array $directories = array(); - #[\Override] public static function setUpBeforeClass(): void { require_once \dirname( __DIR__, 3 ) . '/packages/bootstrap/load.php'; @@ -29,15 +31,6 @@ protected function setUp(): void { remove_all_actions( 'all_admin_notices' ); } - #[\Override] - protected function tearDown(): void { - wp_set_current_user( 0 ); - foreach ( $this->directories as $directory ) { - \array_map( 'unlink', \glob( "$directory/*" ) ); - \rmdir( $directory ); - } - } - public static function unmet_headers(): array { return array( 'php-header' => array( array( 'Requires PHP' => '99' ) ), @@ -167,28 +160,18 @@ public function test_a_message_that_is_neither_a_string_nor_an_error_prints_noth } protected function write_plugin( string $root, array $headers ): string { - $directory = "$root/$this->slug"; - \mkdir( $directory ); - $this->directories[] = $directory; - - $lines = array( 'Plugin Name: Acme Requirements', "Text Domain: $this->slug" ); - foreach ( $headers as $name => $value ) { - $lines[] = "$name: $value"; - } - - $plugin_file = "$directory/$this->slug.php"; - \file_put_contents( $plugin_file, "write_plugin_file( + $root, + $this->slug, + array( + 'Plugin Name' => 'Acme Requirements', + 'Text Domain' => $this->slug, + ) + $headers + ); } protected function render_notices_as( string $role ): string { - wp_set_current_user( $this->make_user( "acme_requirements_$role", $role ) ); - - \ob_start(); - do_action( 'all_admin_notices' ); - - return (string) \ob_get_clean(); + return $this->render_all_admin_notices( $this->make_user( "acme_requirements_$role", $role ) ); } protected function print_notice_as( string $role, mixed ...$arguments ): string { diff --git a/tests/Integration/Bootstrap/UpdaterTest.php b/tests/Integration/Bootstrap/UpdaterTest.php index 2c40d4a3..b1128faa 100644 --- a/tests/Integration/Bootstrap/UpdaterTest.php +++ b/tests/Integration/Bootstrap/UpdaterTest.php @@ -4,6 +4,7 @@ use DeepWebSolutions\Framework\Bootstrap\Updater; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\WritesPluginFilesTrait; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -11,11 +12,10 @@ #[CoversClass( Updater::class )] final class UpdaterTest extends TestCase { use IsolatesHooksTrait; + use WritesPluginFilesTrait; protected string $slug; - protected array $plugin_files = array(); - protected array $responses = array(); protected array $requests = array(); @@ -40,34 +40,22 @@ protected function tearDown(): void { $slug = \basename( $plugin_file, '.php' ); delete_transient( "{$slug}_github_release_stable" ); delete_transient( "{$slug}_github_release_prerelease" ); - wp_delete_file( $plugin_file ); - \rmdir( \dirname( $plugin_file ) ); } delete_site_transient( 'update_plugins' ); wp_clean_plugins_cache( false ); } + // A latest-release status of 0 leaves the request unanswered, and a null asset count keeps every asset. public static function unusable_releases(): array { return array( - 'failed-fetch' => array( static fn ( self $test ) => $test->stage( $test->api_url( 'releases/latest' ), 500, '' ) ), - 'unreachable-api' => array( static fn ( self $test ) => null ), - 'release-without-assets' => array( static fn ( self $test ) => $test->stage_latest( array( 'assets' => array() ) + $test->release( 'v2.0.0' ) ) ), - 'release-without-the-zip' => array( - static function ( self $test ): void { - $release = $test->release( 'v2.0.0' ); - $release['assets'] = \array_slice( $release['assets'], 0, 1 ); - $test->stage_latest( $release ); - }, - ), - 'unreadable-requirements' => array( - static function ( self $test ): void { - $test->stage_latest( $test->release( 'v2.0.0' ) ); - $test->stage( $test->raw_url( 'v2.0.0' ), 404, '404: Not Found' ); - }, - ), - 'tag-not-a-version' => array( static fn ( self $test ) => $test->stage_latest( $test->release( 'preview' ) ) ), - 'unflagged-prerelease' => array( static fn ( self $test ) => $test->stage_latest( $test->release( 'v2.1.0-beta.1' ) ) ), - 'requirement-not-version' => array( static fn ( self $test ) => $test->stage_latest( $test->release( 'v2.0.0' ), '', array( 'Requires PHP' => '8.1' ) ) ), + 'failed-fetch' => array( 500 ), + 'unreachable-api' => array( 0 ), + 'release-without-assets' => array( 200, 'v2.0.0', 0 ), + 'release-without-the-zip' => array( 200, 'v2.0.0', 1 ), + 'unreadable-requirements' => array( 200, 'v2.0.0', null, 404 ), + 'tag-not-a-version' => array( 200, 'preview' ), + 'unflagged-prerelease' => array( 200, 'v2.1.0-beta.1' ), + 'requirement-not-version' => array( 200, 'v2.0.0', null, 200, array( 'Requires PHP' => '8.1' ) ), ); } @@ -174,9 +162,19 @@ public function test_an_up_to_date_install_is_listed_without_an_update_and_its_r } #[DataProvider( 'unusable_releases' )] - public function test_an_unusable_release_offers_nothing_and_is_fetched_again_after_five_minutes( \Closure $stage ): void { + public function test_an_unusable_release_offers_nothing_and_is_fetched_again_after_five_minutes( int $latest_status, string $tag = '', ?int $assets = null, int $entry_status = 200, array $requirements = array() ): void { Updater::register( $this->write_plugin( $this->slug ) ); - $stage( $this ); + if ( 200 === $latest_status ) { + $release = $this->release( $tag ); + $release['assets'] = \array_slice( $release['assets'], 0, $assets ); + $this->stage_latest( $release, '', $requirements ); + } elseif ( 0 !== $latest_status ) { + $this->stage( $this->api_url( 'releases/latest' ), $latest_status, '' ); + } + + if ( 200 !== $entry_status ) { + $this->stage( $this->raw_url( $tag ), $entry_status, "$entry_status: Not Found" ); + } $updates = $this->check_for_updates(); $requests = $this->requests; @@ -527,7 +525,7 @@ public function answer_request( mixed $preempt, array $args, string $url ): mixe return $this->responses[ $url ] ?? new \WP_Error( 'http_request_failed', "Nothing answers $url." ); } - public function release( string $tag, string $slug = '' ): array { + protected function release( string $tag, string $slug = '' ): array { $slug = '' === $slug ? $this->slug : $slug; return array( @@ -548,20 +546,20 @@ public function release( string $tag, string $slug = '' ): array { ); } - public function stage( string $url, int $code, string $body ): void { + protected function stage( string $url, int $code, string $body ): void { $this->responses[ $url ] = self::response( $code, $body ); } - public function stage_latest( array $release, string $slug = '', array $requirements = array() ): void { + protected function stage_latest( array $release, string $slug = '', array $requirements = array() ): void { $this->stage( $this->api_url( 'releases/latest', $slug ), 200, (string) wp_json_encode( $release ) ); $this->stage_entry_file( $release['tag_name'], $slug, $requirements ); } - public function api_url( string $path, string $slug = '' ): string { + protected function api_url( string $path, string $slug = '' ): string { return 'https://api.github.com/repos/acme/' . ( '' === $slug ? $this->slug : $slug ) . "/$path"; } - public function raw_url( string $tag, string $slug = '' ): string { + protected function raw_url( string $tag, string $slug = '' ): string { $slug = '' === $slug ? $this->slug : $slug; return "https://raw.githubusercontent.com/acme/$slug/$tag/$slug.php"; @@ -607,17 +605,7 @@ protected function write_plugin( string $slug, array $headers = array() ): strin 'Update URI' => "https://github.com/acme/$slug", ); - $lines = array(); - foreach ( \array_filter( $headers ) as $name => $value ) { - $lines[] = "$name: $value"; - } - - $plugin_file = WP_PLUGIN_DIR . "/$slug/$slug.php"; - wp_mkdir_p( \dirname( $plugin_file ) ); - \file_put_contents( $plugin_file, "plugin_files, true ) ) { - $this->plugin_files[] = $plugin_file; - } + $plugin_file = $this->write_plugin_file( WP_PLUGIN_DIR, $slug, $headers ); wp_clean_plugins_cache( false ); return $plugin_file; diff --git a/tests/Integration/ConsumerA/ConsumerABootTest.php b/tests/Integration/ConsumerA/ConsumerABootTest.php index fcc3d4a0..81ba4e50 100644 --- a/tests/Integration/ConsumerA/ConsumerABootTest.php +++ b/tests/Integration/ConsumerA/ConsumerABootTest.php @@ -4,6 +4,7 @@ use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\InstallsConsumersTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\ProductFixturesTrait; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; @@ -12,6 +13,7 @@ final class ConsumerABootTest extends TestCase { use CreatesUsersTrait; use InstallsConsumersTrait; + use ProductFixturesTrait; protected const string SCOPED = 'Acme\\ConsumerA\\Scoped\\DeepWebSolutions\\Framework\\'; @@ -35,26 +37,10 @@ public function test_a_gated_subtree_is_skipped_and_its_children_never_load(): v self::assertFalse( $request['records']['reports_page_class'] ); } - public function test_the_build_is_a_production_install_that_keeps_the_bootstrap_loader_at_its_contract_path(): void { - $installed = \json_decode( (string) \file_get_contents( self::FIXTURES . '/consumer-a/vendor/composer/installed.json' ), true ); - - self::assertFileExists( self::FIXTURES . '/consumer-a/vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php' ); - self::assertIsArray( $installed ); - self::assertFalse( $installed['dev'] ); - self::assertSame( array(), $installed['packages'] ); - } - public function test_the_product_edit_screen_shows_the_plugins_tab(): void { - $product = wc_get_product_object( 'simple' ); - $product->set_name( 'Acme product' ); - $product->save(); - $plugin = $this->install_consumer( 'consumer-a', 'acme-consumer-a' ); - - try { - $request = $this->consumer_request( 'GET', "post.php?post={$product->get_id()}&action=edit", $this->make_user( 'acme_ca_admin', 'administrator' ), array( 'activate' => $plugin ) ); - } finally { - wp_delete_post( $product->get_id(), true ); - } + $product = $this->make_product(); + $plugin = $this->install_consumer( 'consumer-a', 'acme-consumer-a' ); + $request = $this->consumer_request( 'GET', "post.php?post={$product->get_id()}&action=edit", $this->make_user( 'acme_ca_admin', 'administrator' ), array( 'activate' => $plugin ) ); self::assertNull( $request['died'] ); self::assertStringContainsString( 'id="acme_ca_packing_product_data"', $request['body'] ); diff --git a/tests/Integration/ConsumerA/ConsumerAUninstallTest.php b/tests/Integration/ConsumerA/ConsumerAUninstallTest.php index 50f89cd5..9e79fa44 100644 --- a/tests/Integration/ConsumerA/ConsumerAUninstallTest.php +++ b/tests/Integration/ConsumerA/ConsumerAUninstallTest.php @@ -4,6 +4,7 @@ use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\InstallsConsumersTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\ProductFixturesTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; @@ -13,6 +14,7 @@ final class ConsumerAUninstallTest extends TestCase { use CreatesUsersTrait; use InstallsConsumersTrait; + use ProductFixturesTrait; protected const string DIR = 'acme-consumer-a-uninstall'; @@ -28,6 +30,7 @@ public static function engines_that_cannot_run(): array { 'autoloader' => array( array( 'vendor/autoload.php' ), array() ), 'scoped-autoloader' => array( array( 'vendor-prefixed/scoper-autoload.php' ), array() ), 'composition-root' => array( array( 'src/Plugin.php' ), array() ), + 'scoped-engine' => array( array( 'vendor-prefixed/ahegyes/wp-framework-core/src/Uninstall/Uninstaller.php' ), array() ), 'footprint' => array( array( 'includes/footprint.php' ), array() ), 'below-php-floor' => array( array(), array( 'Requires PHP' => '99' ) ), ); @@ -59,6 +62,7 @@ public function test_a_web_uninstall_whose_engine_fails_shows_its_message_and_ke self::assertSame( 500, $request['died']['status'] ?? null ); self::assertStringStartsWith( 'Uninstall could not remove everything: option acme_ca_revision: ', $request['died']['message'] ?? '' ); + self::assertStringEndsWith( ' exist.', $request['died']['message'] ?? '' ); self::assertFileExists( WP_PLUGIN_DIR . '/' . self::DIR . '/acme-consumer-a.php' ); self::assertSame( array( 'delete_data' => 'yes' ), get_option( 'acme_ca_settings' ) ); self::assertSame( '1', get_option( 'acme_ca_revision' ) ); @@ -82,29 +86,17 @@ public function test_a_wp_cli_uninstall_that_cannot_run_the_engine_fails_and_kee $this->seed_footprint(); $this->install_consumer( 'consumer-a', self::DIR, $remove, $headers ); - $descriptors = array( - 1 => array( 'pipe', 'w' ), - 2 => array( 'pipe', 'w' ), - ); - $process = \proc_open( array( 'wp', 'plugin', 'uninstall', self::DIR ), $descriptors, $pipes, ABSPATH ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_proc_open -- Runs WP-CLI's own command. - self::assertIsResource( $process ); - $output = \stream_get_contents( $pipes[1] ) . \stream_get_contents( $pipes[2] ); - \fclose( $pipes[1] ); - \fclose( $pipes[2] ); - $exit = \proc_close( $process ); + list( $exit_code, $output, $errors ) = self::run_process( array( 'wp', 'plugin', 'uninstall', self::DIR ), ABSPATH ); wp_cache_flush(); - self::assertNotSame( 0, $exit, (string) $output ); + self::assertNotSame( 0, $exit_code, $output . $errors ); self::assertFileExists( WP_PLUGIN_DIR . '/' . self::DIR . '/acme-consumer-a.php' ); self::assertSame( array( 'delete_data' => 'yes' ), get_option( 'acme_ca_settings' ) ); self::assertSame( 'kept', get_post_meta( $this->product_id, '_acme_ca_packing_note', true ) ); } #[After] - protected function delete_seeded_rows(): void { - if ( 0 !== $this->product_id ) { - wp_delete_post( $this->product_id, true ); - } + protected function delete_request_log(): void { if ( '' !== $this->log && \is_file( $this->log ) ) { \unlink( $this->log ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes the request's log. } @@ -120,10 +112,7 @@ protected function seed_footprint(): void { update_user_option( $this->user_id, 'acme_ca_hidden_notices', array( 'seeded' ) ); update_user_meta( $this->user_id, 'acme_ca_network_hidden_notices', array( 'seeded' ) ); - $product = wc_get_product_object( 'simple' ); - $product->set_name( 'Acme product' ); - $product->update_meta_data( '_acme_ca_packing_note', 'kept' ); - $this->product_id = $product->save(); + $this->product_id = $this->make_product( array( '_acme_ca_packing_note' => 'kept' ) )->get_id(); } protected function uninstall_on_the_web( string $plugin, array $missing, string $failing_query = '' ): array { diff --git a/tests/Integration/ConsumerB/ConsumerBBootTest.php b/tests/Integration/ConsumerB/ConsumerBBootTest.php index 7705c617..27291892 100644 --- a/tests/Integration/ConsumerB/ConsumerBBootTest.php +++ b/tests/Integration/ConsumerB/ConsumerBBootTest.php @@ -23,14 +23,4 @@ public function test_the_plugin_boots_without_woocommerce_and_its_settings_tab_r self::assertSame( 'booted', $request['records']['consumer_b']['status'] ); self::assertStringContainsString( 'name="acme_cb_settings[greeting]"', $request['body'] ); } - - public function test_the_build_is_a_production_install_that_keeps_the_bootstrap_loader_at_its_contract_path(): void { - self::assertFileExists( self::FIXTURES . '/consumer-b/vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php' ); - self::assertFileExists( self::FIXTURES . '/consumer-b/vendor/composer/installed.json' ); - - $installed = \json_decode( (string) \file_get_contents( self::FIXTURES . '/consumer-b/vendor/composer/installed.json' ), true ); - self::assertIsArray( $installed ); - self::assertFalse( $installed['dev'] ); - self::assertSame( array(), $installed['packages'] ); - } } diff --git a/tests/Integration/Consumers/ConsumersScopingTest.php b/tests/Integration/Consumers/ConsumersScopingTest.php index 8624ad36..17f7a750 100644 --- a/tests/Integration/Consumers/ConsumersScopingTest.php +++ b/tests/Integration/Consumers/ConsumersScopingTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Consumers; +use DeepWebSolutions\Framework\Tests\Support\CopiesFixturesTrait; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -9,21 +10,39 @@ #[CoversNothing] final class ConsumersScopingTest extends TestCase { - protected const string FIXTURES = __DIR__ . '/../../Fixtures'; + use CopiesFixturesTrait; + + public static function builds(): array { + $psr = array( 'clock', 'container', 'log', 'simple-cache' ); - public static function closures(): array { return array( - 'consumer-a' => array( 'consumer-a', array( 'wp-framework-bootstrap', 'wp-framework-core', 'wp-framework-settings', 'wp-framework-shared', 'wp-framework-woocommerce' ) ), - 'consumer-b' => array( 'consumer-b', array( 'wp-framework-bootstrap', 'wp-framework-core', 'wp-framework-settings', 'wp-framework-shared' ) ), + 'consumer-a' => array( 'consumer-a', null, array( 'wp-framework-bootstrap', 'wp-framework-core', 'wp-framework-settings', 'wp-framework-shared', 'wp-framework-woocommerce' ), $psr ), + 'consumer-b' => array( 'consumer-b', null, array( 'wp-framework-bootstrap', 'wp-framework-core', 'wp-framework-settings', 'wp-framework-shared' ), $psr ), + 'persona-bootstrap' => array( 'personas/bootstrap', array( 'ahegyes', 'scoper-autoload.php' ), array( 'wp-framework-bootstrap' ), array() ), + 'persona-shared' => array( 'personas/shared', array( 'ahegyes', 'scoper-autoload.php' ), array( 'wp-framework-shared' ), array() ), + 'persona-core' => array( 'personas/core', null, array( 'wp-framework-bootstrap', 'wp-framework-core', 'wp-framework-shared' ), $psr ), + 'persona-settings' => array( 'personas/settings', array( 'ahegyes', 'psr', 'scoper-autoload.php' ), array( 'wp-framework-bootstrap', 'wp-framework-core', 'wp-framework-settings', 'wp-framework-shared' ), $psr ), + 'persona-woocommerce' => array( 'personas/woocommerce', array( 'ahegyes', 'psr', 'scoper-autoload.php' ), array( 'wp-framework-bootstrap', 'wp-framework-core', 'wp-framework-settings', 'wp-framework-shared', 'wp-framework-woocommerce' ), $psr ), ); } - #[DataProvider( 'closures' )] - public function test_the_scoped_build_holds_exactly_its_closure_s_framework_packages_and_psr_interfaces( string $fixture, array $packages ): void { - $scoped = self::FIXTURES . "/$fixture/vendor-prefixed"; + // PHP-DI's own dependencies change between releases, so the top level of a build that ships it stays unlisted. + #[DataProvider( 'builds' )] + public function test_the_production_build_ships_its_closure_scoped_and_nothing_unscoped( string $fixture, ?array $top_level, array $packages, array $psr ): void { + $build = self::FIXTURES . "/$fixture"; + self::assertFileExists( "$build/vendor/composer/installed.json", "Build $fixture first with `composer fixtures:build`." ); + + $installed = \json_decode( (string) \file_get_contents( "$build/vendor/composer/installed.json" ), true ); + self::assertIsArray( $installed ); + self::assertFalse( $installed['dev'] ); + self::assertSame( array(), $installed['packages'] ); + if ( ! \is_null( $top_level ) ) { + self::assertSame( $top_level, self::list_entries( "$build/vendor-prefixed" ) ); + } - self::assertSame( $packages, self::list_directories( "$scoped/ahegyes" ) ); - self::assertSame( array( 'clock', 'container', 'log', 'simple-cache' ), self::list_directories( "$scoped/psr" ) ); + self::assertSame( $packages, self::list_entries( "$build/vendor-prefixed/ahegyes" ) ); + self::assertSame( $psr, self::list_entries( "$build/vendor-prefixed/psr" ) ); + self::assertSame( \in_array( 'wp-framework-bootstrap', $packages, true ), \is_file( "$build/vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php" ) ); } #[RunInSeparateProcess] @@ -59,11 +78,10 @@ public function test_the_scoped_woocommerce_settings_classes_extend_woocommerce_ self::assertSame( 'WC_Settings_Page', \get_parent_class( $scoped . 'SettingsTab' ) ); } - protected static function list_directories( string $path ): array { - self::assertDirectoryExists( $path ); - $directories = \array_map( 'basename', (array) \glob( "$path/*", \GLOB_ONLYDIR ) ); - \sort( $directories ); + protected static function list_entries( string $path ): array { + $entries = \is_dir( $path ) ? \array_values( \array_diff( (array) \scandir( $path ), array( '.', '..' ) ) ) : array(); + \sort( $entries ); - return $directories; + return $entries; } } diff --git a/tests/Integration/Core/AdminNotice/AdminNoticeFixturesTrait.php b/tests/Integration/Core/AdminNotice/AdminNoticeFixturesTrait.php new file mode 100644 index 00000000..bc94f945 --- /dev/null +++ b/tests/Integration/Core/AdminNotice/AdminNoticeFixturesTrait.php @@ -0,0 +1,23 @@ +prefix-$scope-notice-$id"; + } + + protected static function ids( array $notices ): array { + return \array_map( static fn ( Notice $notice ): string => $notice->id, $notices ); + } +} diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php index 778b6664..821c4df4 100644 --- a/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php +++ b/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php @@ -3,7 +3,6 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Core\AdminNotice; use DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; -use DeepWebSolutions\Framework\Core\AdminNotice\Notice; use DeepWebSolutions\Framework\Core\AdminNotice\NoticeScope; use DeepWebSolutions\Framework\Core\AdminNotice\NoticeType; use DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger; @@ -20,6 +19,7 @@ #[CoversClass( AdminNotices::class )] final class AdminNoticesBlockEditorTest extends TestCase { + use AdminNoticeFixturesTrait; use CreatesSitesTrait; use CreatesUsersTrait; use IsolatesHooksTrait; @@ -44,9 +44,6 @@ protected function setUp(): void { #[\Override] protected function tearDown(): void { $GLOBALS['wp_rest_server'] = null; - while ( is_multisite() && ms_is_switched() ) { - restore_current_blog(); - } } public function test_the_post_editor_loads_knowing_whether_one_shots_wait_and_acknowledges_none(): void { @@ -79,13 +76,27 @@ public function test_the_delivery_endpoint_answers_only_a_logged_in_user(): void } public function test_a_delivered_message_is_filtered_and_keeps_only_post_markup(): void { - add_filter( "{$this->prefix}/admin_notice/message", static fn ( string $message ): string => "$message ", 10, 3 ); + add_filter( "$this->prefix/admin_notice/message", static fn ( string $message ): string => "$message ", 10, 3 ); $this->notices()->add( $this->notice( 'saved', message: 'Read the guide.' ), $this->admin ); wp_set_current_user( $this->admin ); self::assertSame( wp_kses_post( 'Read the guide. ' ), $this->deliver()->get_data()[0]['content'] ); } + public function test_a_delivered_message_carries_its_plain_text_for_screen_readers(): void { + $this->notices()->add( $this->notice( 'saved', message: 'Saved & filed. Read the “guide”.' ), $this->admin ); + wp_set_current_user( $this->admin ); + + self::assertSame( 'Saved & filed. Read the “guide”.', $this->deliver()->get_data()[0]['spoken'] ); + } + + public function test_a_delivered_message_s_paragraphs_and_list_items_are_spoken_apart(): void { + $this->notices()->add( $this->notice( 'saved', message: '

Saved.

Check:

  • Box
  • Weight
' ), $this->admin ); + wp_set_current_user( $this->admin ); + + self::assertSame( 'Saved. Check: Box Weight', $this->deliver()->get_data()[0]['spoken'] ); + } + public static function other_screens(): array { return array( 'site-editor' => array( 'site-editor', true ), @@ -101,7 +112,7 @@ public function test_no_other_screen_loads_the_script_or_acknowledges_a_notice( $notices->add( $this->notice( 'saved' ), $this->admin ); $this->view_as( $this->admin, $screen, $block_editor ); - $notices->enqueue_editor_script(); + $notices->enqueue_delivery_script(); self::assertFalse( wp_script_is( $this->script_handle(), 'registered' ) ); self::assertCount( 1, $notices->get_notices( NoticeScope::User, $this->admin ) ); @@ -111,7 +122,7 @@ public function test_the_script_loads_after_its_dependencies_with_the_delivery_p $settings = $this->load_editor( $this->admin ); self::assertTrue( wp_script_is( $this->script_handle() ) ); - self::assertSame( array( 'wp-api-fetch', 'wp-data', 'wp-hooks', 'wp-notices' ), wp_scripts()->registered[ $this->script_handle() ]->deps ); + self::assertSame( array( 'wp-a11y', 'wp-api-fetch', 'wp-data', 'wp-hooks', 'wp-notices' ), wp_scripts()->registered[ $this->script_handle() ]->deps ); self::assertSame( array( 'prefix' => $this->prefix, @@ -126,7 +137,7 @@ public function test_registered_hooks_load_the_script_on_the_editors_assets_and_ $notices = $this->notices(); $notices->register_hooks(); - self::assertSame( 10, has_action( 'enqueue_block_editor_assets', array( $notices, 'enqueue_editor_script' ) ) ); + self::assertSame( 10, has_action( 'enqueue_block_editor_assets', array( $notices, 'enqueue_delivery_script' ) ) ); self::assertArrayHasKey( '/' . \str_replace( '_', '-', $this->prefix ) . '/v1/notices/deliveries', rest_get_server()->get_routes() ); } @@ -138,7 +149,7 @@ public function test_a_network_one_shot_reaches_the_post_editor_of_any_site(): v switch_to_blog( $site_id ); wp_set_current_user( $this->admin ); - self::assertSame( array( "{$this->prefix}-network-notice-x" ), \array_column( $this->deliver()->get_data(), 'id' ) ); + self::assertSame( array( "$this->prefix-network-notice-x" ), \array_column( $this->deliver()->get_data(), 'id' ) ); self::assertSame( array(), $this->notices()->get_notices( NoticeScope::Network ) ); } @@ -157,7 +168,7 @@ protected function every_one_shot(): array { return array( $this->delivered( 'site', 'migrated', 'success', 'Notice migrated.' ), $this->delivered( 'site', 'news', 'error', 'Notice news.' ), - $this->delivered( 'user', 'saved', 'error', 'Saved it.' ), + $this->delivered( 'user', 'saved', 'error', 'Saved it.', spoken: 'Saved it.' ), $this->delivered( 'user', 'pinned', 'warning', 'Notice pinned.', false ), ); } @@ -170,11 +181,12 @@ protected function assert_only_the_others_wait(): void { self::assertSame( 'delivered', get_user_option( "{$this->prefix}_hidden_notices", $this->admin )['site:news']['state'] ?? null ); } - protected function delivered( string $scope, string $id, string $status, string $content, bool $dismissible = true ): array { + protected function delivered( string $scope, string $id, string $status, string $content, bool $dismissible = true, ?string $spoken = null ): array { return array( - 'id' => "{$this->prefix}-$scope-notice-$id", + 'id' => "$this->prefix-$scope-notice-$id", 'status' => $status, 'content' => $content, + 'spoken' => $spoken ?? $content, 'isDismissible' => $dismissible, ); } @@ -183,7 +195,7 @@ protected function delivered( string $scope, string $id, string $status, string protected function load_editor( int $user_id ): array { $this->remove_the_script(); $this->view_as( $user_id, 'post', true ); - $this->notices()->enqueue_editor_script(); + $this->notices()->enqueue_delivery_script(); self::assertSame( 1, \preg_match( '/\}\s*\)\(\s*(\{.*\})\s*\);/s', \implode( "\n", wp_scripts()->get_data( $this->script_handle(), 'after' ) ), $matches ) ); return \json_decode( $matches[1], true ); @@ -197,15 +209,8 @@ protected function notices(): AdminNotices { return new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ); } - protected function notice( string $id, NoticeScope $scope = NoticeScope::User, string $message = '', mixed ...$arguments ): Notice { - $arguments['scope'] = $scope; - $arguments['capability'] ??= NoticeScope::Site === $scope || NoticeScope::Network === $scope ? 'manage_options' : null; - - return new Notice( $id, '' === $message ? "Notice $id." : $message, ...$arguments ); - } - protected function script_handle(): string { - return "{$this->prefix}-notice-delivery"; + return "$this->prefix-notice-delivery"; } #[After] @@ -213,8 +218,4 @@ protected function remove_the_script(): void { wp_dequeue_script( $this->script_handle() ); wp_deregister_script( $this->script_handle() ); } - - protected static function ids( array $notices ): array { - return \array_map( static fn ( Notice $notice ): string => $notice->id, $notices ); - } } diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesDismissalTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesDismissalTest.php index 04bf1e56..6640ec21 100644 --- a/tests/Integration/Core/AdminNotice/AdminNoticesDismissalTest.php +++ b/tests/Integration/Core/AdminNotice/AdminNoticesDismissalTest.php @@ -3,46 +3,33 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Core\AdminNotice; use DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; -use DeepWebSolutions\Framework\Core\AdminNotice\Notice; use DeepWebSolutions\Framework\Core\AdminNotice\NoticeScope; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesSitesTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\RecordingLogger; use DeepWebSolutions\Framework\Tests\Integration\Support\RendersAdminScreensTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\RunsAdminRequestsTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\ThrowingLogger; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; -use Psr\Log\AbstractLogger as PsrAbstractLogger; - -final class DismissalLogger extends PsrAbstractLogger { - public array $records = array(); - - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - $this->records[] = array( $level, (string) $message, $context ); - } -} - -final class ThrowingDismissalLogger extends PsrAbstractLogger { - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - throw new \UnexpectedValueException( 'The log is not writable.' ); - } -} #[CoversClass( AdminNotices::class )] final class AdminNoticesDismissalTest extends TestCase { + use AdminNoticeFixturesTrait; use CreatesSitesTrait; use CreatesUsersTrait; use IsolatesHooksTrait; use IsolatesStorageTrait; use RendersAdminScreensTrait; + use RunsAdminRequestsTrait; protected int $admin; protected int $other_admin; - protected DismissalLogger $logger; + protected RecordingLogger $logger; protected AdminNotices $notices; protected array $dismissed = array(); protected array $saved_request = array(); @@ -51,10 +38,10 @@ final class AdminNoticesDismissalTest extends TestCase { protected function setUp(): void { $this->admin = $this->make_user( 'acme_notices_admin', 'administrator' ); $this->other_admin = $this->make_user( 'acme_notices_other_admin', 'administrator' ); - $this->logger = new DismissalLogger(); + $this->logger = new RecordingLogger(); $this->notices = new AdminNotices( $this->prefix, $this->logger ); add_action( - "{$this->prefix}/admin_notice/dismissed", + "$this->prefix/admin_notice/dismissed", function ( mixed ...$arguments ): void { $this->dismissed[] = $arguments; }, @@ -76,37 +63,34 @@ protected function tearDown(): void { list( $_SERVER, $_POST, $_COOKIE ) = $this->saved_request; $GLOBALS['wp_rest_server'] = null; $GLOBALS['wp_rest_auth_cookie'] = null; - while ( is_multisite() && ms_is_switched() ) { - restore_current_blog(); - } } public function test_a_dismissed_persistent_user_notice_stays_stored_and_hidden_when_added_again(): void { - $this->notices->add( $this->notice( 'kept', NoticeScope::User ), $this->admin ); + $this->notices->add( $this->notice( 'kept', NoticeScope::User, persistent: true ), $this->admin ); $response = $this->dismiss( $this->shown( $this->admin, 'user', 'kept' ) ); self::assertSame( array( 200, array( 'changed' => true ) ), array( $response->get_status(), $response->get_data() ) ); self::assertSame( array(), $this->render( $this->admin ) ); self::assertCount( 1, $this->notices->get_notices( NoticeScope::User, $this->admin ) ); - $this->notices->add( $this->notice( 'kept', NoticeScope::User ), $this->admin ); + $this->notices->add( $this->notice( 'kept', NoticeScope::User, persistent: true ), $this->admin ); self::assertSame( array(), $this->render( $this->admin ) ); } public function test_a_shared_site_notice_dismissed_by_one_capable_user_is_deleted_for_everyone(): void { - $this->notices->add( $this->notice( 'setup', NoticeScope::Site ) ); + $this->notices->add( $this->notice( 'setup', NoticeScope::Site, persistent: true ) ); self::assertSame( array( 'changed' => true ), $this->dismiss( $this->shown( $this->admin, 'site', 'setup' ) )->get_data() ); self::assertSame( array(), $this->render( $this->other_admin ) ); self::assertSame( array(), $this->notices->get_notices( NoticeScope::Site ) ); self::assertFalse( get_user_option( "{$this->prefix}_hidden_notices", $this->admin ) ); - $this->notices->add( $this->notice( 'setup', NoticeScope::Site ) ); + $this->notices->add( $this->notice( 'setup', NoticeScope::Site, persistent: true ) ); self::assertSame( array( $this->dom_id( 'site', 'setup' ) ), \array_keys( $this->render( $this->admin ) ) ); } public function test_a_per_viewer_site_notice_dismissed_by_one_user_still_shows_to_another(): void { - $this->notices->add( $this->notice( 'news', NoticeScope::Site, per_viewer: true ) ); + $this->notices->add( $this->notice( 'news', NoticeScope::Site, per_viewer: true, persistent: true ) ); self::assertSame( 200, $this->dismiss( $this->shown( $this->admin, 'site', 'news' ) )->get_status() ); @@ -116,8 +100,8 @@ public function test_a_per_viewer_site_notice_dismissed_by_one_user_still_shows_ } public function test_dismissing_a_request_notice_records_only_the_callers_state_and_leaves_a_site_notice_of_the_same_id(): void { - $this->notices->add( $this->notice( 'x', NoticeScope::Request ) ); - $this->notices->add( $this->notice( 'x', NoticeScope::Site ) ); + $this->notices->add( $this->notice( 'x', NoticeScope::Request, persistent: true ) ); + $this->notices->add( $this->notice( 'x', NoticeScope::Site, persistent: true ) ); self::assertSame( 200, $this->dismiss( $this->shown( $this->admin, 'request', 'x' ) )->get_status() ); @@ -126,9 +110,9 @@ public function test_dismissing_a_request_notice_records_only_the_callers_state_ } public function test_the_dismissal_action_fires_once_per_dismissal_that_takes_effect_with_the_full_identity(): void { - $this->notices->add( $this->notice( 'setup', NoticeScope::Site ) ); - $this->notices->add( $this->notice( 'news', NoticeScope::Site, per_viewer: true ) ); - $this->notices->add( $this->notice( 'mine', NoticeScope::User ), $this->admin ); + $this->notices->add( $this->notice( 'setup', NoticeScope::Site, persistent: true ) ); + $this->notices->add( $this->notice( 'news', NoticeScope::Site, per_viewer: true, persistent: true ) ); + $this->notices->add( $this->notice( 'mine', NoticeScope::User, persistent: true ), $this->admin ); $setup = $this->shown( $this->admin, 'site', 'setup' ); $news = $this->shown( $this->admin, 'site', 'news' ); $mine = $this->shown( $this->admin, 'user', 'mine' ); @@ -156,7 +140,7 @@ public static function refusals(): array { 'missing-nonce' => array( static fn ( self $test ): array => array( array( 'nonce' => null ) + $test->shown( $test->admin, 'site', 'x' ), 400 ) ), 'another-notice' => array( static function ( self $test ): array { - $test->notices->add( $test->notice( 'other', NoticeScope::Site ) ); + $test->notices->add( $test->notice( 'other', NoticeScope::Site, persistent: true ) ); return array( array( 'nonce' => $test->shown( $test->admin, 'site', 'other' )['nonce'] ) + $test->shown( $test->admin, 'site', 'x' ), 403 ); }, ), @@ -184,14 +168,14 @@ static function ( self $test ): array { 'lacking-capability' => array( static function ( self $test ): array { $shown = $test->shown( $test->admin, 'site', 'x' ); - $test->notices->add( $test->notice( 'x', NoticeScope::Site, capability: 'acme_never_granted' ) ); + $test->notices->add( $test->notice( 'x', NoticeScope::Site, capability: 'acme_never_granted', persistent: true ) ); return array( $shown, 403 ); }, ), 'not-dismissible' => array( static function ( self $test ): array { $shown = $test->shown( $test->admin, 'site', 'x' ); - $test->notices->add( $test->notice( 'x', NoticeScope::Site, dismissible: false ) ); + $test->notices->add( $test->notice( 'x', NoticeScope::Site, dismissible: false, persistent: true ) ); return array( $shown, 403 ); }, ), @@ -207,7 +191,7 @@ static function ( self $test ): array { #[DataProvider( 'refusals' )] public function test_a_dismissal_without_a_matching_nonce_audience_or_record_changes_nothing( \Closure $arrange ): void { - $this->notices->add( $this->notice( 'x', NoticeScope::Site ) ); + $this->notices->add( $this->notice( 'x', NoticeScope::Site, persistent: true ) ); wp_set_current_user( $this->admin ); list( $params, $status ) = $arrange( $this ); $stored = $this->notices->get_notices( NoticeScope::Site ); @@ -275,7 +259,7 @@ public static function failing_stores(): array { #[DataProvider( 'failing_stores' )] public function test_a_failing_store_answers_500_and_is_logged( bool $per_viewer, \Closure $fail ): void { - $this->notices->add( $this->notice( 'x', NoticeScope::Site, per_viewer: $per_viewer ) ); + $this->notices->add( $this->notice( 'x', NoticeScope::Site, per_viewer: $per_viewer, persistent: true ) ); $shown = $this->shown( $this->admin, 'site', 'x' ); $fail( $this ); @@ -287,8 +271,8 @@ public function test_a_failing_store_answers_500_and_is_logged( bool $per_viewer } public function test_a_failing_store_whose_logger_throws_answers_500_and_reaches_the_error_log(): void { - $notices = new AdminNotices( $this->prefix, new ThrowingDismissalLogger() ); - $notices->add( $this->notice( 'x', NoticeScope::Site ) ); + $notices = new AdminNotices( $this->prefix, new ThrowingLogger( new \UnexpectedValueException( 'The log is not writable.' ) ) ); + $notices->add( $this->notice( 'x', NoticeScope::Site, persistent: true ) ); $request = new \WP_REST_Request( 'POST' ); $request->set_body_params( $this->shown( $this->admin, 'site', 'x' ) ); add_filter( "pre_update_option_{$this->prefix}_notices", static fn (): never => throw new \RuntimeException( 'The options table is unavailable.' ) ); @@ -310,11 +294,9 @@ public function test_a_failing_store_whose_logger_throws_answers_500_and_reaches } public function test_the_http_path_with_cookie_authentication_needs_the_rest_nonce(): void { - $this->notices->add( $this->notice( 'setup', NoticeScope::Site ) ); + $this->notices->add( $this->notice( 'setup', NoticeScope::Site, persistent: true ) ); $_SERVER['REQUEST_METHOD'] = 'GET'; - $expiration = \time() + DAY_IN_SECONDS; - $token = \WP_Session_Tokens::get_instance( $this->admin )->create( $expiration ); - $_COOKIE[ LOGGED_IN_COOKIE ] = wp_generate_auth_cookie( $this->admin, $expiration, 'logged_in', $token ); + $_COOKIE[ LOGGED_IN_COOKIE ] = $this->get_session_cookies( $this->admin )[ LOGGED_IN_COOKIE ]; $params = $this->shown( $this->admin, 'site', 'setup' ); $rest_nonce = wp_create_nonce( 'wp_rest' ); @@ -329,7 +311,7 @@ public function test_the_http_path_with_cookie_authentication_needs_the_rest_non public function test_a_shared_network_notice_dismissed_on_one_site_is_deleted_for_the_network(): void { $site_id = $this->make_site( 'acme-notices' ); add_user_to_blog( $site_id, $this->admin, 'administrator' ); - $this->notices->add( $this->notice( 'x', NoticeScope::Network, capability: 'read' ) ); + $this->notices->add( $this->notice( 'x', NoticeScope::Network, capability: 'read', persistent: true ) ); switch_to_blog( $site_id ); self::assertSame( 200, $this->dismiss( $this->shown( $this->admin, 'network', 'x' ) )->get_status() ); @@ -344,7 +326,7 @@ public function test_a_shared_network_notice_dismissed_on_one_site_is_deleted_fo public function test_a_per_viewer_network_notice_dismissed_on_one_site_stays_hidden_on_every_site_and_in_the_network_admin(): void { $site_id = $this->make_site( 'acme-notices' ); add_user_to_blog( $site_id, $this->admin, 'administrator' ); - $this->notices->add( $this->notice( 'x', NoticeScope::Network, capability: 'read', per_viewer: true ) ); + $this->notices->add( $this->notice( 'x', NoticeScope::Network, capability: 'read', per_viewer: true, persistent: true ) ); switch_to_blog( $site_id ); self::assertSame( 200, $this->dismiss( $this->shown( $this->admin, 'network', 'x' ) )->get_status() ); @@ -358,7 +340,7 @@ public function test_a_per_viewer_network_notice_dismissed_on_one_site_stays_hid #[Group( 'multisite' )] public function test_a_per_viewer_network_notice_dismissed_on_one_network_still_shows_on_another(): void { - $this->notices->add( $this->notice( 'x', NoticeScope::Network, capability: 'read', per_viewer: true ) ); + $this->notices->add( $this->notice( 'x', NoticeScope::Network, capability: 'read', per_viewer: true, persistent: true ) ); self::assertSame( 200, $this->dismiss( $this->shown( $this->admin, 'network', 'x' ) )->get_status() ); // User meta is installation-wide, so a second network sees the same dismissals. @@ -371,7 +353,7 @@ public function test_a_per_viewer_network_notice_dismissed_on_one_network_still_ ) ); try { - $this->notices->add( $this->notice( 'x', NoticeScope::Network, capability: 'read', per_viewer: true ) ); + $this->notices->add( $this->notice( 'x', NoticeScope::Network, capability: 'read', per_viewer: true, persistent: true ) ); $shown = \array_keys( $this->render( $this->admin ) ); } finally { delete_network_option( 2, "{$this->prefix}_network_notices" ); @@ -382,14 +364,6 @@ public function test_a_per_viewer_network_notice_dismissed_on_one_network_still_ self::assertSame( array( 'network:1:x' ), \array_keys( get_user_meta( $this->admin, "{$this->prefix}_network_hidden_notices", true ) ) ); } - protected function notice( string $id, NoticeScope $scope, mixed ...$arguments ): Notice { - $arguments['scope'] = $scope; - $arguments['persistent'] ??= true; - $arguments['capability'] ??= NoticeScope::Site === $scope || NoticeScope::Network === $scope ? 'manage_options' : null; - - return new Notice( $id, "Notice $id.", ...$arguments ); - } - // What the dismiss script posts for the notice as the user is shown it. protected function shown( int $user_id, string $scope, string $id ): array { $attributes = $this->render( $user_id )[ $this->dom_id( $scope, $id ) ]; @@ -411,7 +385,7 @@ protected function render( int $user_id, string $screen = 'dashboard' ): array { protected function dismiss( array $params ): \WP_REST_Response { $request = new \WP_REST_Request( 'POST', '/' . \str_replace( '_', '-', $this->prefix ) . '/v1/notices/dismissals' ); - $request->set_body_params( \array_filter( $params, static fn ( mixed $value ): bool => null !== $value ) ); + $request->set_body_params( \array_filter( $params, static fn ( mixed $value ): bool => ! \is_null( $value ) ) ); return rest_do_request( $request ); } @@ -424,7 +398,7 @@ protected function serve( array $params, ?string $rest_nonce ): array { wp_get_current_user(); $_SERVER['REQUEST_METHOD'] = 'POST'; unset( $_SERVER['HTTP_X_WP_NONCE'] ); - if ( null !== $rest_nonce ) { + if ( ! \is_null( $rest_nonce ) ) { $_SERVER['HTTP_X_WP_NONCE'] = $rest_nonce; } $_POST = $params; @@ -434,8 +408,4 @@ protected function serve( array $params, ?string $rest_nonce ): array { return \json_decode( (string) \ob_get_clean(), true ); } - - protected function dom_id( string $scope, string $id ): string { - return "{$this->prefix}-$scope-notice-$id"; - } } diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php index 65cec4f0..174dad93 100644 --- a/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php +++ b/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php @@ -16,6 +16,7 @@ #[CoversClass( AdminNotices::class )] final class AdminNoticesStorageTest extends TestCase { + use AdminNoticeFixturesTrait; use CreatesSitesTrait; use CreatesUsersTrait; use IsolatesStorageTrait; @@ -33,9 +34,6 @@ protected function setUp(): void { #[\Override] protected function tearDown(): void { wp_set_current_user( 0 ); - while ( is_multisite() && ms_is_switched() ) { - restore_current_blog(); - } } public function test_each_scope_keeps_its_notices_as_one_array_by_id_in_its_home(): void { @@ -179,13 +177,6 @@ public function test_network_notices_live_in_the_network_option_whichever_site_a self::assertSame( array( 'one' ), self::ids( $this->notices->get_notices( NoticeScope::Network ) ) ); } - protected function notice( string $id, NoticeScope $scope = NoticeScope::User, string $message = 'Hi.', mixed ...$arguments ): Notice { - $arguments['scope'] = $scope; - $arguments['capability'] ??= NoticeScope::User === $scope ? null : 'manage_options'; - - return new Notice( $id, $message, ...$arguments ); - } - protected function view( string $screen ): void { require_once ABSPATH . 'wp-admin/includes/class-wp-screen.php'; require_once ABSPATH . 'wp-admin/includes/screen.php'; @@ -215,10 +206,6 @@ protected function option_row( string $name ): array { return array( maybe_unserialize( $row['option_value'] ?? '' ), $row['autoload'] ?? null ); } - protected static function ids( array $notices ): array { - return \array_map( static fn ( Notice $notice ): string => $notice->id, $notices ); - } - protected static function messages( array $notices ): array { return \array_map( static fn ( Notice $notice ): string => $notice->message, $notices ); } diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesTest.php index 14f177a4..0356e676 100644 --- a/tests/Integration/Core/AdminNotice/AdminNoticesTest.php +++ b/tests/Integration/Core/AdminNotice/AdminNoticesTest.php @@ -3,7 +3,6 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Core\AdminNotice; use DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; -use DeepWebSolutions\Framework\Core\AdminNotice\Notice; use DeepWebSolutions\Framework\Core\AdminNotice\NoticeScope; use DeepWebSolutions\Framework\Core\AdminNotice\NoticeType; use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; @@ -12,49 +11,36 @@ use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\RecordingLogger; +use DeepWebSolutions\Framework\Tests\Integration\Support\RecordsDoingItWrongTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\RendersAdminScreensTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; -use Psr\Log\AbstractLogger as PsrAbstractLogger; - -final class NoticesLogger extends PsrAbstractLogger { - public array $records = array(); - - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - $this->records[] = array( $level, (string) $message, $context ); - } -} #[CoversClass( AdminNotices::class )] final class AdminNoticesTest extends TestCase { + use AdminNoticeFixturesTrait; use CreatesSitesTrait; use CreatesUsersTrait; use IsolatesHooksTrait; use IsolatesStorageTrait; + use RecordsDoingItWrongTrait; use RendersAdminScreensTrait; protected int $admin; protected int $other_admin; protected int $editor; - protected NoticesLogger $logger; + protected RecordingLogger $logger; #[\Override] protected function setUp(): void { $this->admin = $this->make_user( 'acme_notices_admin', 'administrator' ); $this->other_admin = $this->make_user( 'acme_notices_other_admin', 'administrator' ); $this->editor = $this->make_user( 'acme_notices_editor', 'editor' ); - $this->logger = new NoticesLogger(); - } - - #[\Override] - protected function tearDown(): void { - while ( is_multisite() && ms_is_switched() ) { - restore_current_blog(); - } + $this->logger = new RecordingLogger(); } public static function invalid_prefixes(): array { @@ -123,38 +109,32 @@ public function test_a_user_notice_can_be_added_for_another_user(): void { public static function misaddressed_notices(): array { return array( - 'a-user-for-a-site-notice' => array( NoticeScope::Site, 5, InvalidArgumentException::class ), - 'no-user-logged-in' => array( NoticeScope::User, 0, LogicException::class ), - 'a-negative-user' => array( NoticeScope::User, -5, LogicException::class ), + 'a-user-for-a-site-notice' => array( NoticeScope::Site, 5, InvalidArgumentException::class, "but user '5' was given." ), + 'no-user-logged-in' => array( NoticeScope::User, 0, LogicException::class, "but it has user '0'." ), + 'a-negative-user' => array( NoticeScope::User, -5, LogicException::class, "but it has user '-5'." ), ); } #[DataProvider( 'misaddressed_notices' )] - public function test_a_notice_addressed_to_no_user_or_to_a_user_outside_the_user_scope_is_rejected( NoticeScope $scope, int $user_id, string $exception ): void { + public function test_a_notice_addressed_to_no_user_or_to_a_user_outside_the_user_scope_is_rejected( NoticeScope $scope, int $user_id, string $exception, string $ending ): void { $this->expectException( $exception ); + $this->expectExceptionMessageMatches( '/' . \preg_quote( $ending, '/' ) . '$/' ); $this->notices()->add( $this->notice( 'saved', $scope ), $user_id ); } public function test_a_request_notice_added_after_the_notices_showed_is_reported(): void { - $reported = array(); - add_filter( 'doing_it_wrong_trigger_error', '__return_false' ); - add_action( - 'doing_it_wrong_run', - static function ( string $function_name ) use ( &$reported ): void { - $reported[] = $function_name; - } - ); + $this->record_doing_it_wrong(); wp_set_current_user( $this->admin ); $notices = $this->notices(); $notices->add( $this->notice( 'before', NoticeScope::Request ) ); - self::assertSame( array(), $reported ); + self::assertSame( array(), $this->doing_it_wrong ); do_action( 'all_admin_notices' ); $notices->add( $this->notice( 'user' ) ); $notices->add( $this->notice( 'after', NoticeScope::Request ) ); - self::assertSame( array( AdminNotices::class . '::add' ), $reported ); + self::assertSame( array( AdminNotices::class . '::add' ), $this->doing_it_wrong ); self::assertSame( array( 'before', 'after' ), self::ids( $notices->get_notices( NoticeScope::Request ) ) ); } @@ -305,7 +285,7 @@ public function test_a_notice_that_has_not_expired_yet_is_listed_and_shows(): vo public function test_the_visibility_filter_hides_a_notice_without_consuming_it_unless_it_returns_true(): void { $calls = array(); add_filter( - "{$this->prefix}/admin_notice/should_show", + "$this->prefix/admin_notice/should_show", static function ( mixed $visible, string $id, string $scope ) use ( &$calls ): mixed { $calls[] = array( $visible, $id, $scope ); return match ( $id ) { @@ -330,7 +310,7 @@ static function ( mixed $visible, string $id, string $scope ) use ( &$calls ): m public function test_the_message_filter_replaces_the_printed_message_unless_it_returns_no_string(): void { $calls = array(); add_filter( - "{$this->prefix}/admin_notice/message", + "$this->prefix/admin_notice/message", static function ( string $message, string $id, string $scope ) use ( &$calls ): ?string { $calls[] = array( $message, $id, $scope ); return 'kept' === $id ? null : 'Translated.'; @@ -377,12 +357,12 @@ public function test_the_dismiss_script_loads_on_wp_api_fetch_only_after_a_persi $notices = $this->notices(); $notices->add( $this->notice( 'once', NoticeScope::Request ) ); $this->render( $this->admin, $notices ); - $notices->enqueue_dismiss_script(); + $notices->enqueue_dismissal_script(); self::assertFalse( wp_script_is( $this->script_handle(), 'registered' ) ); $notices->add( $this->notice( 'kept', NoticeScope::Request, persistent: true ) ); $this->render( $this->admin, $notices ); - $notices->enqueue_dismiss_script(); + $notices->enqueue_dismissal_script(); $script = \implode( "\n", wp_scripts()->get_data( $this->script_handle(), 'after' ) ); self::assertTrue( wp_script_is( $this->script_handle() ) ); @@ -510,7 +490,7 @@ protected function hide( int $user_id, string $key, string $state ): void { } protected function script_handle(): string { - return "{$this->prefix}-notice-dismissal"; + return "$this->prefix-notice-dismissal"; } #[After] @@ -518,19 +498,4 @@ protected function remove_the_script(): void { wp_dequeue_script( $this->script_handle() ); wp_deregister_script( $this->script_handle() ); } - - protected function notice( string $id, NoticeScope $scope = NoticeScope::User, string $message = '', mixed ...$arguments ): Notice { - $arguments['scope'] = $scope; - $arguments['capability'] ??= NoticeScope::Site === $scope || NoticeScope::Network === $scope ? 'manage_options' : null; - - return new Notice( $id, '' === $message ? "Notice $id." : $message, ...$arguments ); - } - - protected function dom_id( string $scope, string $id ): string { - return "{$this->prefix}-$scope-notice-$id"; - } - - protected static function ids( array $notices ): array { - return \array_map( static fn ( Notice $notice ): string => $notice->id, $notices ); - } } diff --git a/tests/Integration/Core/Caching/TransientCacheTest.php b/tests/Integration/Core/Caching/TransientCacheTest.php index 31fa1208..ab903117 100644 --- a/tests/Integration/Core/Caching/TransientCacheTest.php +++ b/tests/Integration/Core/Caching/TransientCacheTest.php @@ -130,7 +130,7 @@ public function test_an_entry_expires_after_its_lifetime( null|int|\DateInterval $this->cache()->set( 'key', 'Value.', $ttl ); $after = \time(); - $seconds ??= ( new \DateTimeImmutable( "@$before" ) )->add( $ttl )->getTimestamp() - $before; + $seconds ??= new \DateTimeImmutable( "@$before" )->add( $ttl )->getTimestamp() - $before; self::assertGreaterThanOrEqual( $before + $seconds, $this->timeout( 'key' ) ); self::assertLessThanOrEqual( $after + $seconds, $this->timeout( 'key' ) ); } diff --git a/tests/Integration/Core/Logging/ErrorLogLoggerTest.php b/tests/Integration/Core/Logging/ErrorLogLoggerTest.php index 6cf67ef4..44a18df5 100644 --- a/tests/Integration/Core/Logging/ErrorLogLoggerTest.php +++ b/tests/Integration/Core/Logging/ErrorLogLoggerTest.php @@ -49,7 +49,7 @@ public static function levels(): array { public function test_every_level_reaches_the_error_log_under_wp_debug( string $level ): void { self::assertTrue( WP_DEBUG ); - ( new ErrorLogLogger( 'acme' ) )->log( $level, 'Cache {name} is cold.', array( 'name' => 'acme' ) ); + new ErrorLogLogger( 'acme' )->log( $level, 'Cache {name} is cold.', array( 'name' => 'acme' ) ); self::assertStringContainsString( "[acme.$level] Cache acme is cold.", (string) \file_get_contents( $this->log_file ) ); } @@ -66,14 +66,14 @@ static function ( mixed ...$args ) use ( &$received ): void { 2 ); - ( new ErrorLogLogger( 'acme' ) )->log( $level, 'Cache {name} is cold.', array( 'name' => 'acme' ) ); + new ErrorLogLogger( 'acme' )->log( $level, 'Cache {name} is cold.', array( 'name' => 'acme' ) ); self::assertSame( array( array( 'Cache {name} is cold.', array( 'name' => 'acme' ) ) ), $received ); } #[DataProvider( 'log_messages' )] public function test_a_record_interpolates_only_printable_context_values( string $message, array $context, string $expected ): void { - ( new ErrorLogLogger( 'acme' ) )->error( $message, $context ); + new ErrorLogLogger( 'acme' )->error( $message, $context ); self::assertStringContainsString( "[acme.error] $expected" . \PHP_EOL, (string) \file_get_contents( $this->log_file ) ); } @@ -82,6 +82,7 @@ public function test_a_record_casts_only_the_context_values_its_message_uses(): $unused = new class() implements \Stringable { public int $casts = 0; + #[\Override] public function __toString(): string { ++$this->casts; @@ -89,7 +90,7 @@ public function __toString(): string { } }; - ( new ErrorLogLogger( 'acme' ) )->error( + new ErrorLogLogger( 'acme' )->error( 'Hello {name}.', array( 'name' => 'world', @@ -104,7 +105,7 @@ public function __toString(): string { public function test_an_exception_in_the_context_is_logged_with_its_class_location_and_trace(): void { $exception = new \RuntimeException( 'Cache is cold.' ); - ( new ErrorLogLogger( 'acme' ) )->error( + new ErrorLogLogger( 'acme' )->error( 'Warm-up failed: {message}', array( 'message' => $exception->getMessage(), @@ -121,13 +122,13 @@ public function test_an_exception_in_the_context_is_logged_with_its_class_locati public function test_a_previous_exception_is_logged_with_the_exception(): void { $exception = new \RuntimeException( 'Warm-up failed.', 0, new \LogicException( 'Cache is cold.' ) ); - ( new ErrorLogLogger( 'acme' ) )->error( 'Warm-up failed.', array( 'exception' => $exception ) ); + new ErrorLogLogger( 'acme' )->error( 'Warm-up failed.', array( 'exception' => $exception ) ); self::assertStringContainsString( 'LogicException: Cache is cold. in ' . __FILE__, (string) \file_get_contents( $this->log_file ) ); } public function test_an_exception_context_value_that_is_not_a_throwable_is_ignored(): void { - ( new ErrorLogLogger( 'acme' ) )->error( 'Warm-up failed.', array( 'exception' => 'Cache is cold.' ) ); + new ErrorLogLogger( 'acme' )->error( 'Warm-up failed.', array( 'exception' => 'Cache is cold.' ) ); self::assertStringNotContainsString( 'Cache is cold.', (string) \file_get_contents( $this->log_file ) ); } @@ -136,6 +137,6 @@ public function test_a_level_psr_3_does_not_define_is_rejected(): void { $this->expectException( PsrInvalidArgumentException::class ); $this->expectExceptionMessage( "Log level 'verbose' is not a PSR-3 level." ); - ( new ErrorLogLogger( 'acme' ) )->log( 'verbose', 'Cache is cold.' ); + new ErrorLogLogger( 'acme' )->log( 'verbose', 'Cache is cold.' ); } } diff --git a/tests/Integration/Core/Logging/GuardedLoggerTest.php b/tests/Integration/Core/Logging/GuardedLoggerTest.php index 2aa66eaf..7b5ee4da 100644 --- a/tests/Integration/Core/Logging/GuardedLoggerTest.php +++ b/tests/Integration/Core/Logging/GuardedLoggerTest.php @@ -3,32 +3,13 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Core\Logging; use DeepWebSolutions\Framework\Core\Logging\GuardedLogger; +use DeepWebSolutions\Framework\Tests\Integration\Support\RecordingLogger; +use DeepWebSolutions\Framework\Tests\Integration\Support\ThrowingLogger; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use Psr\Log\AbstractLogger as PsrAbstractLogger; use Psr\Log\InvalidArgumentException as PsrInvalidArgumentException; use Psr\Log\LogLevel as PsrLogLevel; -final class RecordingLogger extends PsrAbstractLogger { - public array $records = array(); - - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - $this->records[] = array( $level, $message, $context ); - } -} - -final class ThrowingLogger extends PsrAbstractLogger { - public function __construct( - protected \Throwable $failure - ) {} - - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - throw $this->failure; - } -} - #[CoversClass( GuardedLogger::class )] final class GuardedLoggerTest extends TestCase { protected string $log_file; @@ -50,7 +31,7 @@ protected function tearDown(): void { public function test_a_record_passes_to_the_wrapped_logger_unchanged_and_skips_the_error_log(): void { $inner = new RecordingLogger(); - ( new GuardedLogger( $inner, 'acme' ) )->log( PsrLogLevel::INFO, 'Cache {name} is cold.', array( 'name' => 'acme' ) ); + new GuardedLogger( $inner, 'acme' )->log( PsrLogLevel::INFO, 'Cache {name} is cold.', array( 'name' => 'acme' ) ); self::assertSame( array( array( PsrLogLevel::INFO, 'Cache {name} is cold.', array( 'name' => 'acme' ) ) ), $inner->records ); self::assertSame( '', \file_get_contents( $this->log_file ) ); @@ -60,7 +41,7 @@ public function test_a_record_whose_logger_throws_reaches_the_error_log_raw_with $exception = new \RuntimeException( 'Cache is cold.' ); $failure = new \UnexpectedValueException( 'The log is not writable.' ); - ( new GuardedLogger( new ThrowingLogger( $failure ), 'acme' ) )->error( + new GuardedLogger( new ThrowingLogger( $failure ), 'acme' )->error( 'Warm-up of {name} failed.', array( 'name' => 'acme', @@ -77,7 +58,7 @@ public function test_a_level_psr_3_does_not_define_throws_before_the_wrapped_log $inner = new RecordingLogger(); try { - ( new GuardedLogger( $inner, 'acme' ) )->log( 'verbose', 'Cache is cold.' ); + new GuardedLogger( $inner, 'acme' )->log( 'verbose', 'Cache is cold.' ); self::fail( 'The level was accepted.' ); } catch ( PsrInvalidArgumentException $exception ) { self::assertSame( "Log level 'verbose' is not a PSR-3 level.", $exception->getMessage() ); diff --git a/tests/Integration/Core/Migration/MigrationFixturesTrait.php b/tests/Integration/Core/Migration/MigrationFixturesTrait.php new file mode 100644 index 00000000..30caa3b8 --- /dev/null +++ b/tests/Integration/Core/Migration/MigrationFixturesTrait.php @@ -0,0 +1,27 @@ +option = 'acme_db_' . \bin2hex( \random_bytes( 4 ) ); + } + + #[After] + protected function delete_runner_options(): void { + foreach ( array( '', '_error', '_high_water', '_first_run', '_lock' ) as $suffix ) { + delete_option( $this->option . $suffix ); + } + } + + protected function get_lock(): mixed { + return OptionRows::get( "{$this->option}_lock" )?->value; + } +} diff --git a/tests/Integration/Core/Migration/MigrationRunnerInLocalEnvironmentTest.php b/tests/Integration/Core/Migration/MigrationRunnerInLocalEnvironmentTest.php index 6d76f33b..7a12703a 100644 --- a/tests/Integration/Core/Migration/MigrationRunnerInLocalEnvironmentTest.php +++ b/tests/Integration/Core/Migration/MigrationRunnerInLocalEnvironmentTest.php @@ -4,7 +4,6 @@ use DeepWebSolutions\Framework\Core\Migration\MigrationRunner; use DeepWebSolutions\Framework\Core\Scheduling\Scheduler; -use DeepWebSolutions\Framework\Core\Storage\OptionRows; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; use PHPUnit\Framework\TestCase; @@ -14,9 +13,9 @@ #[CoversClass( MigrationRunner::class )] #[RunTestsInSeparateProcesses] final class MigrationRunnerInLocalEnvironmentTest extends TestCase { - protected static string|false $environment_type = false; + use MigrationFixturesTrait; - protected string $option; + protected static string|false $environment_type = false; #[\Override] public static function setUpBeforeClass(): void { @@ -29,18 +28,6 @@ public static function tearDownAfterClass(): void { \putenv( false === self::$environment_type ? 'WP_ENVIRONMENT_TYPE' : 'WP_ENVIRONMENT_TYPE=' . self::$environment_type ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_putenv -- Restores the outer value. } - #[\Override] - protected function setUp(): void { - $this->option = 'acme_db_' . \bin2hex( \random_bytes( 4 ) ); - } - - #[\Override] - protected function tearDown(): void { - foreach ( array( '', '_error', '_high_water', '_first_run', '_lock' ) as $suffix ) { - delete_option( $this->option . $suffix ); - } - } - public function test_a_failing_step_releases_the_lock_and_is_rethrown_so_the_fixed_step_runs_next(): void { self::assertSame( 'local', wp_get_environment_type() ); $failure = new \RuntimeException( 'Step failed locally.' ); @@ -98,8 +85,4 @@ function (): void { protected function make_runner( array $steps ): MigrationRunner { return new MigrationRunner( __DIR__ . '/../../../Fixtures/Core/acme-boot.php', $this->option, $steps, new PsrNullLogger(), new Scheduler( 'acme' ) ); } - - protected function get_lock(): mixed { - return OptionRows::get( "{$this->option}_lock" )?->value; - } } diff --git a/tests/Integration/Core/Migration/MigrationRunnerTest.php b/tests/Integration/Core/Migration/MigrationRunnerTest.php index 22de6b7a..c1667452 100644 --- a/tests/Integration/Core/Migration/MigrationRunnerTest.php +++ b/tests/Integration/Core/Migration/MigrationRunnerTest.php @@ -6,36 +6,22 @@ use DeepWebSolutions\Framework\Core\Migration\MigrationRunner; use DeepWebSolutions\Framework\Core\Scheduling\Scheduler; use DeepWebSolutions\Framework\Core\Scheduling\SchedulerInterface; -use DeepWebSolutions\Framework\Core\Storage\OptionRows; use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; use DeepWebSolutions\Framework\Shared\Exception\RuntimeException; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\DispatchesScheduledHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\RecordingLogger; +use DeepWebSolutions\Framework\Tests\Integration\Support\RendersAdminScreensTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\ReportsActionSchedulerVersionsTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\SettableClock; +use DeepWebSolutions\Framework\Tests\Integration\Support\ThrowingLogger; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Psr\Clock\ClockInterface as PsrClockInterface; -use Psr\Log\AbstractLogger as PsrAbstractLogger; use Psr\Log\LoggerInterface as PsrLoggerInterface; -final class MigrationLogger extends PsrAbstractLogger { - public array $records = array(); - - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - $this->records[] = array( $level, (string) $message, $context ); - } -} - -final class FailingMigrationLogger extends PsrAbstractLogger { - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - throw new \UnexpectedValueException( 'The log is not writable.' ); - } -} - final class RecordedBatches implements BatchedMigrationStepInterface { public int $processed = 0; @@ -58,7 +44,10 @@ public function process_batch(): bool { #[CoversClass( MigrationRunner::class )] final class MigrationRunnerTest extends TestCase { use CreatesUsersTrait; + use DispatchesScheduledHooksTrait; use IsolatesHooksTrait; + use MigrationFixturesTrait; + use RendersAdminScreensTrait; use ReportsActionSchedulerVersionsTrait; protected const string PLUGIN_FILE = __DIR__ . '/../../../Fixtures/Core/acme-boot.php'; @@ -67,9 +56,7 @@ final class MigrationRunnerTest extends TestCase { protected const string PROGRESS_NOTICE = 'Acme Boot is updating its data in the background.'; - protected string $option; - - protected MigrationLogger $logger; + protected RecordingLogger $logger; protected array $ran = array(); @@ -79,8 +66,7 @@ final class MigrationRunnerTest extends TestCase { #[\Override] protected function setUp(): void { - $this->option = 'acme_db_' . \bin2hex( \random_bytes( 4 ) ); - $this->logger = new MigrationLogger(); + $this->logger = new RecordingLogger(); // The runner pauses near memory_limit, so step counts must not depend on what the test process already holds. $this->memory_limit = (string) \ini_get( 'memory_limit' ); @@ -92,13 +78,9 @@ protected function tearDown(): void { global $wpdb; \ini_set( 'memory_limit', $this->memory_limit ); - foreach ( array( '', '_error', '_high_water', '_first_run', '_lock' ) as $suffix ) { - delete_option( $this->option . $suffix ); - } - wp_unschedule_hook( "{$this->option}/migration/run" ); - wp_set_current_user( 0 ); + wp_unschedule_hook( "$this->option/migration/run" ); unset( $_REQUEST['_wpnonce'], $_SERVER['HTTP_REFERER'] ); - $wpdb->delete( $wpdb->actionscheduler_actions, array( 'hook' => "{$this->option}/migration/run" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the actions the test created. + $wpdb->delete( $wpdb->actionscheduler_actions, array( 'hook' => "$this->option/migration/run" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the actions the test created. } public function test_pending_steps_run_once_each_in_order_with_a_checkpoint_after_each(): void { @@ -274,7 +256,7 @@ public function test_a_failed_revision_read_stops_the_run_with_an_error(): void add_filter( 'query', function ( string $query ): string { - return \str_starts_with( \trim( $query ), 'SELECT option_value' ) && \str_contains( $query, "'{$this->option}'" ) ? 'SELECT option_value FROM acme_missing_table' : $query; + return \str_starts_with( \trim( $query ), 'SELECT option_value' ) && \str_contains( $query, "'$this->option'" ) ? 'SELECT option_value FROM acme_missing_table' : $query; } ); $suppressed = $wpdb->suppress_errors(); @@ -376,14 +358,14 @@ public function test_an_array_stored_as_the_revision_reads_as_0_and_stops_the_ru } public function test_an_unconfirmed_checkpoint_stops_the_run_with_an_error(): void { - add_filter( "pre_update_option_{$this->option}", static fn ( mixed $value, mixed $old_value ): mixed => $old_value, 10, 2 ); + add_filter( "pre_update_option_$this->option", static fn ( mixed $value, mixed $old_value ): mixed => $old_value, 10, 2 ); $this->make_runner( array( $this->step( 1 ), $this->step( 2 ) ) )->run(); self::assertSame( array( array( 1, 0 ) ), $this->ran ); $error = get_option( "{$this->option}_error" ); self::assertIsArray( $error ); - self::assertSame( "Could not record schema revision '1'.", $error['message'] ); + self::assertSame( "Could not record data revision '1'.", $error['message'] ); } public function test_a_resumable_step_rerun_after_an_interruption_creates_no_duplicate(): void { @@ -559,7 +541,7 @@ public function test_a_cached_revision_above_the_code_is_checked_against_the_dat public function test_a_lock_taken_over_during_a_checkpoint_stops_the_run_before_its_next_step(): void { add_action( - "update_option_{$this->option}", + "update_option_$this->option", function (): void { update_option( "{$this->option}_lock", self::hold( 'acme-other', \time() ), false ); // Stands in for another run taking the lock over. } @@ -646,7 +628,7 @@ static function (): void { }, ), null, - new FailingMigrationLogger() + new ThrowingLogger( new \UnexpectedValueException( 'The log is not writable.' ) ) ); try { @@ -657,7 +639,7 @@ static function (): void { wp_delete_file( $log_file ); } - self::assertStringContainsString( "[{$this->option}.error] Data migration failed: {message} {\"message\":\"Step failed with a broken logger.\"}\nRuntimeException: Step failed with a broken logger. in ", $log ); + self::assertStringContainsString( "[$this->option.error] Data migration failed: {message} {\"message\":\"Step failed with a broken logger.\"}\nRuntimeException: Step failed with a broken logger. in ", $log ); self::assertStringContainsString( "\nThe logger failed: UnexpectedValueException: The log is not writable.", $log ); self::assertIsArray( get_option( "{$this->option}_error" ) ); } @@ -676,7 +658,7 @@ function ( string $query ): string { $previous = \ini_set( 'error_log', $log_file ); try { - $this->make_runner( array( $this->step( 1 ) ), null, new FailingMigrationLogger() )->run(); + $this->make_runner( array( $this->step( 1 ) ), null, new ThrowingLogger( new \UnexpectedValueException( 'The log is not writable.' ) ) )->run(); $log = (string) \file_get_contents( $log_file ); } finally { \ini_set( 'error_log', (string) $previous ); @@ -684,7 +666,7 @@ function ( string $query ): string { $wpdb->suppress_errors( $suppressed ); } - self::assertStringContainsString( "[{$this->option}.warning] Could not take the data migration lock: {error} {\"error\":\"", $log ); + self::assertStringContainsString( "[$this->option.warning] Could not take the data migration lock: {error} {\"error\":\"", $log ); self::assertStringContainsString( "\nThe logger failed: UnexpectedValueException: The log is not writable.", $log ); self::assertSame( array(), $this->ran ); } @@ -712,10 +694,10 @@ public function test_a_request_outside_the_runner_hook_runs_plain_steps_but_no_b } public function test_a_request_outside_the_runner_hook_adds_nothing_while_the_hook_runs_elsewhere(): void { - as_enqueue_async_action( "{$this->option}/migration/run", array(), 'acme' ); + as_enqueue_async_action( "$this->option/migration/run", array(), 'acme' ); $running = as_get_scheduled_actions( array( - 'hook' => "{$this->option}/migration/run", + 'hook' => "$this->option/migration/run", 'status' => \ActionScheduler_Store::STATUS_PENDING, ), 'ids' @@ -731,7 +713,7 @@ public function test_the_runner_hook_runs_batches_until_the_step_finishes(): voi $batches = new RecordedBatches( 3 ); $this->make_runner( array( $batches, $this->step( 2 ) ) )->register_hooks(); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); self::assertSame( 3, $batches->processed ); self::assertSame( array( array( 2, 1 ) ), $this->ran ); @@ -754,7 +736,7 @@ function ( mixed $event ) use ( &$locks ): mixed { } ); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); self::assertSame( 2, $batches->processed ); self::assertSame( array( null ), $locks ); @@ -769,9 +751,9 @@ public function test_a_batched_step_spans_requests_while_its_revision_stays_behi $revisions = array(); for ( $request = 1; $request <= 3; ++$request ) { - remove_all_actions( "{$this->option}/migration/run" ); + remove_all_actions( "$this->option/migration/run" ); $this->make_runner( array( $batches ), $clock )->register_hooks(); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); $revisions[] = array( $batches->processed, MigrationRunner::get_revision( $this->option ) ); } @@ -792,7 +774,7 @@ static function () use ( $clock ): void { ); $this->load_wordpress( $runner ); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); self::assertSame( 1, $batches->processed ); self::assertSame( 1, MigrationRunner::get_revision( $this->option ) ); @@ -811,7 +793,7 @@ static function (): void { $batches = new RecordedBatches( 3, static fn ( int $processed ) => 1 === $processed && $takeover->run() ); $this->make_runner( array( $batches ) )->register_hooks(); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); self::assertSame( 1, $batches->processed ); self::assertSame( $later->now->getTimestamp(), $this->get_lock()['time'] ?? null ); @@ -851,7 +833,7 @@ public function test_the_runtime_cache_is_flushed_after_every_batch(): void { }; $this->make_runner( array( new RecordedBatches( 2, $batch ), new RecordedBatches( 1, $batch ) ) )->register_hooks(); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); self::assertSame( array( false, false, false ), $cached ); self::assertFalse( wp_cache_get( 'acme_batch', 'acme' ) ); @@ -909,7 +891,7 @@ public function test_memory_past_90_percent_of_the_limit_stops_the_loop( bool $t \ini_set( 'memory_limit', $tight ? (string) (int) ( \memory_get_usage( true ) / 0.93 ) : '-1' ); try { - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); } finally { \ini_set( 'memory_limit', $previous ); } @@ -922,7 +904,7 @@ public function test_the_runner_hook_returns_without_enqueueing_while_another_ru $this->insert_lock( \time() - 5 ); $this->make_runner( array( $batches ) )->register_hooks(); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); self::assertSame( 0, $batches->processed ); self::assertSame( 0, $this->count_pending_runs() ); @@ -940,7 +922,7 @@ static function ( int $processed ): void { $this->make_runner( array( $this->step( 1 ), $batches ) )->register_hooks(); update_option( $this->option, 1 ); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); $error = get_option( "{$this->option}_error" ); self::assertIsArray( $error ); @@ -966,7 +948,7 @@ public function test_a_successful_batch_clears_the_error_of_an_earlier_failure() false ); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); self::assertSame( 1, $batches->processed ); self::assertFalse( get_option( "{$this->option}_error" ) ); @@ -974,7 +956,7 @@ public function test_a_successful_batch_clears_the_error_of_an_earlier_failure() public static function dispatchers(): array { return array( - 'action-scheduler' => array( 'production' ), + 'action-scheduler' => array( 'action-scheduler' ), 'wp-cron' => array( 'wp-cron' ), ); } @@ -983,14 +965,14 @@ public static function dispatchers(): array { public function test_a_batched_step_finishes_through_real_scheduler_dispatch( string $kind ): void { $clock = new SettableClock( new \DateTimeImmutable( '@' . \time() ) ); $batches = new RecordedBatches( 5, static fn () => $clock->advance( 10 ) ); - $scheduler = 'production' === $kind ? new Scheduler( 'acme' ) : $this->make_wp_cron_scheduler(); + $scheduler = 'action-scheduler' === $kind ? new Scheduler( 'acme' ) : $this->make_wp_cron_scheduler(); $requests = 0; while ( 1 > MigrationRunner::get_revision( $this->option ) && $requests < 5 ) { // Each request's wp_loaded pass enqueues the runner, then its queue run dispatches what is due. - remove_all_actions( "{$this->option}/migration/run" ); + remove_all_actions( "$this->option/migration/run" ); $this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, $scheduler ) ); - 'production' === $kind ? $this->run_due_actions() : $this->run_due_cron_events(); + 'action-scheduler' === $kind ? self::run_due_actions( "$this->option/migration/run" ) : self::run_due_cron_events( "$this->option/migration/run" ); ++$requests; } @@ -1005,16 +987,16 @@ public function test_a_spent_budget_leaves_nothing_due_for_the_queue_run_in_prog $batches = new RecordedBatches( 5, static fn () => $clock->advance( 30 ) ); $this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, new Scheduler( 'acme' ) ) ); - $this->run_due_actions(); + self::run_due_actions( "$this->option/migration/run" ); // A queue run that pauses between actions claims again later, as wp action-scheduler run --pause does. - $this->run_due_actions( \time() + MINUTE_IN_SECONDS ); + self::run_due_actions( "$this->option/migration/run", \time() + MINUTE_IN_SECONDS ); self::assertSame( 1, $batches->processed ); self::assertSame( 0, $this->count_pending_runs() ); - remove_all_actions( "{$this->option}/migration/run" ); + remove_all_actions( "$this->option/migration/run" ); $this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, new Scheduler( 'acme' ) ) ); - $this->run_due_actions(); + self::run_due_actions( "$this->option/migration/run" ); self::assertSame( 2, $batches->processed ); self::assertSame( 0, MigrationRunner::get_revision( $this->option ) ); @@ -1041,31 +1023,27 @@ static function (): void { ); $runner = $this->make_runner( array( $batches ) ); $runner->register_hooks(); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); - wp_set_current_user( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); - $notices = $this->render_admin_notices(); + $notices = $this->render_all_admin_notices( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); self::assertStringContainsString( self::ERROR_NOTICE . 'The <batch> failed.', $notices ); self::assertStringNotContainsString( self::PROGRESS_NOTICE, $notices ); - wp_set_current_user( $this->make_user( 'acme-migrations-editor', 'editor' ) ); - self::assertStringNotContainsString( self::ERROR_NOTICE, $this->render_admin_notices() ); + self::assertStringNotContainsString( self::ERROR_NOTICE, $this->render_all_admin_notices( $this->make_user( 'acme-migrations-editor', 'editor' ) ) ); } public function test_a_pending_batched_step_shows_plugin_activators_a_run_now_link(): void { $this->make_runner( array( $this->step( 1 ), new RecordedBatches( 3 ) ) )->register_hooks(); update_option( $this->option, 1 ); - wp_set_current_user( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); - $notices = $this->render_admin_notices(); + $notices = $this->render_all_admin_notices( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); self::assertStringContainsString( self::PROGRESS_NOTICE, $notices ); self::assertSame( 1, \preg_match( '/Run the update now<\/a>/', $notices, $link ) ); \parse_str( (string) wp_parse_url( \html_entity_decode( $link[1] ), \PHP_URL_QUERY ), $query ); - self::assertSame( "{$this->option}/migration/run_now", $query['action'] ); - self::assertSame( 1, wp_verify_nonce( $query['_wpnonce'], "{$this->option}/migration/run_now" ) ); + self::assertSame( "$this->option/migration/run_now", $query['action'] ); + self::assertSame( 1, wp_verify_nonce( $query['_wpnonce'], "$this->option/migration/run_now" ) ); - wp_set_current_user( $this->make_user( 'acme-migrations-editor', 'editor' ) ); - self::assertStringNotContainsString( self::PROGRESS_NOTICE, $this->render_admin_notices() ); + self::assertStringNotContainsString( self::PROGRESS_NOTICE, $this->render_all_admin_notices( $this->make_user( 'acme-migrations-editor', 'editor' ) ) ); } public function test_a_failed_batch_shows_its_error_with_a_run_now_link_that_completes_the_repaired_step(): void { @@ -1079,12 +1057,11 @@ static function () use ( &$broken ): void { } ); $this->make_runner( array( $batches ) )->register_hooks(); - do_action( "{$this->option}/migration/run" ); + do_action( "$this->option/migration/run" ); $this->age_lock(); $broken = false; - wp_set_current_user( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); - $notices = $this->render_admin_notices(); + $notices = $this->render_all_admin_notices( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); self::assertStringContainsString( self::ERROR_NOTICE . 'The batch failed.', $notices ); self::assertStringNotContainsString( self::PROGRESS_NOTICE, $notices ); self::assertSame( 1, \preg_match( '/Run the update now<\/a>/', $notices, $link ) ); @@ -1126,8 +1103,7 @@ public function test_an_invalid_error_row_shows_no_error_notice( mixed $row ): v $this->make_runner( array( new RecordedBatches( 3 ) ) )->register_hooks(); update_option( "{$this->option}_error", $row, false ); - wp_set_current_user( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); - $notices = $this->render_admin_notices(); + $notices = $this->render_all_admin_notices( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); self::assertStringNotContainsString( self::ERROR_NOTICE, $notices ); self::assertStringContainsString( self::PROGRESS_NOTICE, $notices ); @@ -1136,9 +1112,7 @@ public function test_an_invalid_error_row_shows_no_error_notice( mixed $row ): v public function test_a_pending_plain_step_shows_no_progress_notice(): void { $this->make_runner( array( $this->step( 1 ) ) )->register_hooks(); - wp_set_current_user( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); - - self::assertStringNotContainsString( self::PROGRESS_NOTICE, $this->render_admin_notices() ); + self::assertStringNotContainsString( self::PROGRESS_NOTICE, $this->render_all_admin_notices( $this->make_user( 'acme-migrations-admin', 'administrator' ) ) ); } public function test_no_notice_shows_once_every_step_has_run(): void { @@ -1156,8 +1130,7 @@ public function test_no_notice_shows_once_every_step_has_run(): void { false ); - wp_set_current_user( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); - $notices = $this->render_admin_notices(); + $notices = $this->render_all_admin_notices( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); self::assertStringNotContainsString( self::ERROR_NOTICE, $notices ); self::assertStringNotContainsString( self::PROGRESS_NOTICE, $notices ); @@ -1175,7 +1148,7 @@ public function test_run_now_runs_the_runner_hook_and_returns_to_a_local_referri $batches = new RecordedBatches( 3 ); $this->make_runner( array( $batches ) )->register_hooks(); wp_set_current_user( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); - $_REQUEST['_wpnonce'] = wp_create_nonce( "{$this->option}/migration/run_now" ); + $_REQUEST['_wpnonce'] = wp_create_nonce( "$this->option/migration/run_now" ); $_SERVER['HTTP_REFERER'] = \str_starts_with( $referer, 'https://' ) ? $referer : admin_url( $referer ); self::assertSame( admin_url( $destination ), $this->post_run_now() ); @@ -1195,7 +1168,7 @@ public function test_run_now_refuses_an_invalid_nonce_and_users_who_cannot_activ $batches = new RecordedBatches( 3 ); $this->make_runner( array( $batches ) )->register_hooks(); wp_set_current_user( $this->make_user( "acme-migrations-$role", $role ) ); - $_REQUEST['_wpnonce'] = $valid_nonce ? wp_create_nonce( "{$this->option}/migration/run_now" ) : 'invalid'; + $_REQUEST['_wpnonce'] = $valid_nonce ? wp_create_nonce( "$this->option/migration/run_now" ) : 'invalid'; self::assertSame( 'died', $this->post_run_now() ); self::assertSame( 0, $batches->processed ); @@ -1253,13 +1226,6 @@ protected static function hold( string $owner, int $time ): array { ); } - protected function render_admin_notices(): string { - \ob_start(); - do_action( 'all_admin_notices' ); - - return (string) \ob_get_clean(); - } - // The handler exits after redirecting, so the redirect and wp_die() throw to return here first. protected function post_run_now(): string { add_filter( @@ -1276,7 +1242,7 @@ static function ( string $location ): never { ); try { - do_action( "admin_post_{$this->option}/migration/run_now" ); + do_action( "admin_post_$this->option/migration/run_now" ); } catch ( \OutOfBoundsException | \OverflowException $stop ) { return $stop->getMessage(); } @@ -1287,42 +1253,14 @@ static function ( string $location ): never { protected function count_pending_runs(): int { $cron = 0; foreach ( _get_cron_array() as $hooks ) { - $cron += \count( $hooks[ "{$this->option}/migration/run" ] ?? array() ); + $cron += \count( $hooks[ "$this->option/migration/run" ] ?? array() ); } $query = array( - 'hook' => "{$this->option}/migration/run", + 'hook' => "$this->option/migration/run", 'status' => \ActionScheduler_Store::STATUS_PENDING, 'per_page' => -1, ); return $cron + \count( as_get_scheduled_actions( $query, 'ids' ) ); } - - // Mirrors one Action Scheduler queue-runner batch, which claims the actions due by the given time or now. - protected function run_due_actions( ?int $at = null ): void { - $store = \ActionScheduler::store(); - $claim = $store->stake_claim( 10, \is_null( $at ) ? null : new \DateTime( "@$at" ), array( "{$this->option}/migration/run" ) ); - foreach ( $claim->get_actions() as $action_id ) { - \ActionScheduler::runner()->process_action( (int) $action_id, 'Tests' ); - } - $store->release_claim( $claim ); - } - - // Mirrors one wp-cron.php run, which runs the events due by the given time or now. - protected function run_due_cron_events( ?int $at = null ): void { - foreach ( _get_cron_array() as $timestamp => $hooks ) { - if ( $timestamp > ( $at ?? \time() ) ) { - break; - } - - foreach ( $hooks[ "{$this->option}/migration/run" ] ?? array() as $event ) { - wp_unschedule_event( $timestamp, "{$this->option}/migration/run", $event['args'], true ); - do_action_ref_array( "{$this->option}/migration/run", $event['args'] ); - } - } - } - - protected function get_lock(): mixed { - return OptionRows::get( "{$this->option}_lock" )?->value; - } } diff --git a/tests/Integration/Core/Permission/CapabilityGrantsTest.php b/tests/Integration/Core/Permission/CapabilityGrantsTest.php index 2cffd072..e8784614 100644 --- a/tests/Integration/Core/Permission/CapabilityGrantsTest.php +++ b/tests/Integration/Core/Permission/CapabilityGrantsTest.php @@ -9,27 +9,13 @@ use DeepWebSolutions\Framework\Tests\Integration\Support\InterceptsQueriesTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\RecordingLogger; +use DeepWebSolutions\Framework\Tests\Integration\Support\ThrowingLogger; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; -use Psr\Log\AbstractLogger as PsrAbstractLogger; - -final class GrantsLogger extends PsrAbstractLogger { - public array $records = array(); - - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - $this->records[] = array( $level, (string) $message, $context ); - } -} - -final class FailingGrantsLogger extends PsrAbstractLogger { - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - throw new \UnexpectedValueException( 'The log is not writable.' ); - } -} +use Psr\Log\LoggerInterface as PsrLoggerInterface; #[CoversClass( CapabilityGrants::class )] final class CapabilityGrantsTest extends TestCase { @@ -39,11 +25,11 @@ final class CapabilityGrantsTest extends TestCase { use IsolatesHooksTrait; use IsolatesStorageTrait; - protected GrantsLogger $logger; + protected RecordingLogger $logger; #[\Override] protected function setUp(): void { - $this->logger = new GrantsLogger(); + $this->logger = new RecordingLogger(); } #[\Override] @@ -123,7 +109,7 @@ public function test_the_first_run_grants_each_declared_pair_to_its_role_and_led $this->stored_caps( $editor ) ); self::assertSame( array( $this->cap( 'a' ) => true ), $this->stored_caps( $author ) ); - self::assertTrue( ( new \WP_User( $user ) )->has_cap( $this->cap( 'a' ) ) ); + self::assertTrue( new \WP_User( $user )->has_cap( $this->cap( 'a' ) ) ); self::assertEquals( $declared, $this->ledger() ); } @@ -352,7 +338,7 @@ public function test_a_warning_whose_logger_throws_reaches_the_error_log_without $this->keep_roles_row(); try { - $this->grants( array( $editor => array( $this->cap( 'a' ) ) ), new FailingGrantsLogger() )->run(); + $this->grants( array( $editor => array( $this->cap( 'a' ) ) ), new ThrowingLogger( new \UnexpectedValueException( 'The log is not writable.' ) ) )->run(); $log = (string) \file_get_contents( $log_file ); } finally { \ini_set( 'error_log', (string) $previous ); @@ -529,7 +515,7 @@ public function test_a_run_and_a_role_deletion_change_only_their_own_sites_roles self::assertFalse( get_option( "{$this->prefix}_grants" ) ); } - protected function grants( array $grants, ?PsrAbstractLogger $logger = null ): CapabilityGrants { + protected function grants( array $grants, ?PsrLoggerInterface $logger = null ): CapabilityGrants { return new CapabilityGrants( "{$this->prefix}_grants", $grants, $logger ?? $this->logger ); } diff --git a/tests/Integration/Core/PluginBootInLocalEnvironmentTest.php b/tests/Integration/Core/PluginBootInLocalEnvironmentTest.php index 92939160..ff941da5 100644 --- a/tests/Integration/Core/PluginBootInLocalEnvironmentTest.php +++ b/tests/Integration/Core/PluginBootInLocalEnvironmentTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Core; use DeepWebSolutions\Framework\Core\PluginBoot; +use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; use PHPUnit\Framework\TestCase; @@ -12,6 +13,8 @@ #[CoversClass( PluginBoot::class )] #[RunTestsInSeparateProcesses] final class PluginBootInLocalEnvironmentTest extends TestCase { + use IsolatesStorageTrait; + protected static string|false $environment_type = false; protected array $fired = array(); @@ -32,31 +35,26 @@ public static function tearDownAfterClass(): void { #[\Override] protected function setUp(): void { add_action( - 'acme/boot/succeeded', + "$this->prefix/boot/succeeded", function (): void { - $this->fired[] = 'acme/boot/succeeded'; + $this->fired[] = 'succeeded'; }, 10, 0 ); add_action( - 'acme/boot/failed', + "$this->prefix/boot/failed", function ( \Throwable $failure ): void { - $this->fired[] = 'acme/boot/failed'; + $this->fired[] = 'failed'; $this->failures[] = $failure; } ); } - #[\Override] - protected function tearDown(): void { - delete_transient( 'acme_boot_failure_' . \md5( 'composition failed' ) ); - } - public function test_a_failing_composition_is_rethrown_after_the_failure_action(): void { self::assertSame( 'local', wp_get_environment_type() ); $failure = new \RuntimeException( 'composition failed' ); - $boot = new PluginBoot( __DIR__ . '/../../Fixtures/Core/acme-boot.php', 'acme', new PsrNullLogger() ); + $boot = new PluginBoot( __DIR__ . '/../../Fixtures/Core/acme-boot.php', $this->prefix, new PsrNullLogger() ); $caught = null; try { @@ -67,7 +65,7 @@ public function test_a_failing_composition_is_rethrown_after_the_failure_action( self::assertSame( $failure, $caught, 'The failure was not rethrown.' ); self::assertSame( 'failed', $boot->status ); - self::assertSame( array( 'acme/boot/failed' ), $this->fired ); + self::assertSame( array( 'failed' ), $this->fired ); self::assertSame( array( $failure ), $this->failures ); } } diff --git a/tests/Integration/Core/PluginBootTest.php b/tests/Integration/Core/PluginBootTest.php index 9b60b0bd..914dfdc1 100644 --- a/tests/Integration/Core/PluginBootTest.php +++ b/tests/Integration/Core/PluginBootTest.php @@ -5,35 +5,20 @@ use DeepWebSolutions\Framework\Core\PluginBoot; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\RecordingLogger; +use DeepWebSolutions\Framework\Tests\Integration\Support\RendersAdminScreensTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\ThrowingLogger; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use Psr\Log\AbstractLogger as PsrAbstractLogger; - -final class RecordingLogger extends PsrAbstractLogger { - public array $records = array(); - - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - $this->records[] = array( $level, (string) $message, $context ); - } -} - -final class ThrowingLogger extends PsrAbstractLogger { - public function __construct( - protected \Throwable $failure - ) {} - - #[\Override] - public function log( mixed $level, string|\Stringable $message, array $context = array() ): void { - throw $this->failure; - } -} #[CoversClass( PluginBoot::class )] final class PluginBootTest extends TestCase { use CreatesUsersTrait; use IsolatesHooksTrait; + use IsolatesStorageTrait; + use RendersAdminScreensTrait; protected const string PLUGIN_FILE = __DIR__ . '/../../Fixtures/Core/acme-boot.php'; @@ -47,46 +32,39 @@ final class PluginBootTest extends TestCase { #[\Override] protected function setUp(): void { - $this->delete_failure_transients(); $this->logger = new RecordingLogger(); add_action( - 'acme/boot/succeeded', + "$this->prefix/boot/succeeded", function (): void { - $this->fired[] = 'acme/boot/succeeded'; + $this->fired[] = 'succeeded'; }, 10, 0 ); add_action( - 'acme/boot/blocked', + "$this->prefix/boot/blocked", function (): void { - $this->fired[] = 'acme/boot/blocked'; + $this->fired[] = 'blocked'; }, 10, 0 ); add_action( - 'acme/boot/failed', + "$this->prefix/boot/failed", function ( \Throwable $failure ): void { - $this->fired[] = 'acme/boot/failed'; + $this->fired[] = 'failed'; $this->failures[] = $failure; } ); } - #[\Override] - protected function tearDown(): void { - $this->delete_failure_transients(); - wp_set_current_user( 0 ); - } - - public function test_a_composition_that_completes_boots_and_announces_initialization_once(): void { + public function test_a_composition_that_completes_boots_and_announces_success_once(): void { $boot = $this->make_boot(); $boot->run( static fn (): bool => true ); self::assertSame( 'booted', $boot->status ); - self::assertSame( array( 'acme/boot/succeeded' ), $this->fired ); + self::assertSame( array( 'succeeded' ), $this->fired ); } public function test_a_composition_named_as_a_callable_boots(): void { @@ -95,16 +73,17 @@ public function test_a_composition_named_as_a_callable_boots(): void { $boot->run( '__return_true' ); self::assertSame( 'booted', $boot->status ); - self::assertSame( array( 'acme/boot/succeeded' ), $this->fired ); + self::assertSame( array( 'succeeded' ), $this->fired ); } public function test_a_composition_that_reports_an_unmet_requirement_is_blocked(): void { // The all hook receives an action's own arguments, before do_action() pads an argumentless call with an empty string. $arguments = array(); + $blocked = "$this->prefix/boot/blocked"; add_action( 'all', - static function ( string $hook_name, mixed ...$args ) use ( &$arguments ): void { - if ( 'acme/boot/blocked' === $hook_name ) { + static function ( string $hook_name, mixed ...$args ) use ( &$arguments, $blocked ): void { + if ( $blocked === $hook_name ) { $arguments[] = $args; } } @@ -114,7 +93,7 @@ static function ( string $hook_name, mixed ...$args ) use ( &$arguments ): void $boot->run( static fn (): bool => false ); self::assertSame( 'blocked', $boot->status ); - self::assertSame( array( 'acme/boot/blocked' ), $this->fired ); + self::assertSame( array( 'blocked' ), $this->fired ); self::assertSame( array( array() ), $arguments ); } @@ -125,14 +104,10 @@ public function test_a_failing_composition_fails_and_tells_only_plugin_activator $boot->run( static fn (): bool => throw $failure ); self::assertSame( 'failed', $boot->status ); - self::assertSame( array( 'acme/boot/failed' ), $this->fired ); + self::assertSame( array( 'failed' ), $this->fired ); self::assertSame( array( $failure ), $this->failures ); - - wp_set_current_user( $this->make_user( 'acme-boot-admin', 'administrator' ) ); - self::assertStringContainsString( self::NOTICE, $this->render_admin_notices() ); - - wp_set_current_user( $this->make_user( 'acme-boot-editor', 'editor' ) ); - self::assertStringNotContainsString( self::NOTICE, $this->render_admin_notices() ); + self::assertStringContainsString( self::NOTICE, $this->render_all_admin_notices( $this->make_user( 'acme-boot-admin', 'administrator' ) ) ); + self::assertStringNotContainsString( self::NOTICE, $this->render_all_admin_notices( $this->make_user( 'acme-boot-editor', 'editor' ) ) ); } public function test_the_same_failure_is_logged_once_an_hour(): void { @@ -144,7 +119,7 @@ public function test_the_same_failure_is_logged_once_an_hour(): void { self::assertSame( 'error', $this->logger->records[0][0] ); self::assertSame( 'Boot failed; the plugin did not finish starting: {message}', $this->logger->records[0][1] ); self::assertSame( 'first failure', $this->logger->records[0][2]['message'] ); - self::assertEqualsWithDelta( \time() + HOUR_IN_SECONDS, (int) get_option( '_transient_timeout_acme_boot_failure_' . \md5( 'first failure' ) ), 5 ); + self::assertEqualsWithDelta( \time() + HOUR_IN_SECONDS, (int) get_option( "_transient_timeout_{$this->prefix}_boot_failure_" . \md5( 'first failure' ) ), 5 ); } public function test_distinct_failures_are_each_logged_once(): void { @@ -165,7 +140,7 @@ public static function logging_failures(): array { #[DataProvider( 'logging_failures' )] public function test_a_failure_whose_logger_throws_still_reaches_the_error_log_and_is_announced( \Throwable $logging_failure ): void { $failure = new \RuntimeException( 'composition failed' ); - $boot = new PluginBoot( self::PLUGIN_FILE, 'acme', new ThrowingLogger( $logging_failure ) ); + $boot = new PluginBoot( self::PLUGIN_FILE, $this->prefix, new ThrowingLogger( $logging_failure ) ); $log_file = (string) \tempnam( \sys_get_temp_dir(), 'acme-log-' ); $previous = \ini_set( 'error_log', $log_file ); @@ -178,31 +153,15 @@ public function test_a_failure_whose_logger_throws_still_reaches_the_error_log_a } self::assertSame( 'failed', $boot->status ); - self::assertSame( array( 'acme/boot/failed' ), $this->fired ); + self::assertSame( array( 'failed' ), $this->fired ); self::assertSame( array( $failure ), $this->failures ); - self::assertStringContainsString( '[acme.error] Boot failed; the plugin did not finish starting: {message} {"message":"composition failed"}', $log ); + self::assertStringContainsString( "[$this->prefix.error] Boot failed; the plugin did not finish starting: {message} {\"message\":\"composition failed\"}", $log ); self::assertStringContainsString( 'RuntimeException: composition failed in ' . __FILE__ . ':' . $failure->getLine(), $log ); self::assertStringContainsString( $logging_failure::class . ': ' . $logging_failure->getMessage(), $log ); - - wp_set_current_user( $this->make_user( 'acme-boot-admin', 'administrator' ) ); - self::assertStringContainsString( self::NOTICE, $this->render_admin_notices() ); - } - - // An interrupted run leaves its throttle transients behind, which silence the next run's logging for up to an hour. - protected function delete_failure_transients(): void { - foreach ( array( 'composition failed', 'first failure', 'second failure' ) as $message ) { - delete_transient( 'acme_boot_failure_' . \md5( $message ) ); - } + self::assertStringContainsString( self::NOTICE, $this->render_all_admin_notices( $this->make_user( 'acme-boot-admin', 'administrator' ) ) ); } protected function make_boot(): PluginBoot { - return new PluginBoot( self::PLUGIN_FILE, 'acme', $this->logger ); - } - - protected function render_admin_notices(): string { - \ob_start(); - do_action( 'all_admin_notices' ); - - return (string) \ob_get_clean(); + return new PluginBoot( self::PLUGIN_FILE, $this->prefix, $this->logger ); } } diff --git a/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php b/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php index 4aa88d3e..6f327b0f 100644 --- a/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php +++ b/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php @@ -2,13 +2,13 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Core\Scheduling; -use DeepWebSolutions\Framework\Core\Scheduling\Recurrence; use DeepWebSolutions\Framework\Core\Scheduling\ScheduledHook; use DeepWebSolutions\Framework\Core\Scheduling\Scheduler; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesSitesTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\RecordsDoingItWrongTrait; use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -17,26 +17,19 @@ final class SchedulerOnSwitchedSiteTest extends TestCase { use CreatesSitesTrait; use IsolatesHooksTrait; + use RecordsDoingItWrongTrait; protected string $hook; protected int $other_site; - protected array $doing_it_wrong = array(); - protected array $writes = array(); #[\Override] protected function setUp(): void { $this->hook = 'acme_scheduling_' . \bin2hex( \random_bytes( 4 ) ); $this->other_site = $this->make_site( 'acme-scheduling' ); - add_filter( 'doing_it_wrong_trigger_error', '__return_false' ); - add_action( - 'doing_it_wrong_run', - function ( string $function_name ): void { - $this->doing_it_wrong[] = $function_name; - } - ); + $this->record_doing_it_wrong(); add_filter( 'pre_update_option_cron', function ( mixed $value, mixed $old_value ): mixed { @@ -53,27 +46,15 @@ function ( mixed $value, mixed $old_value ): mixed { #[\Override] protected function tearDown(): void { - while ( ms_is_switched() ) { - restore_current_blog(); - } - + // The cleanup starts on the main site, and the trait restores the sites only after tearDown(). + $this->restore_switched_sites(); wp_unschedule_hook( $this->hook ); switch_to_blog( $this->other_site ); wp_unschedule_hook( $this->hook ); restore_current_blog(); } - public static function writes(): array { - return array( - 'enqueue' => array( static fn ( Scheduler $scheduler, ScheduledHook $hook ): mixed => $scheduler->enqueue( $hook ) ), - 'schedule-single' => array( static fn ( Scheduler $scheduler, ScheduledHook $hook ): mixed => $scheduler->schedule_single( $hook, \time() + 60 ) ), - 'ensure-recurring' => array( static fn ( Scheduler $scheduler, ScheduledHook $hook ): mixed => $scheduler->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ) ), - 'clear-scheduled-hook' => array( static fn ( Scheduler $scheduler, ScheduledHook $hook ): mixed => $scheduler->clear_scheduled_hook( $hook ) ), - 'unschedule-hook' => array( static fn ( Scheduler $scheduler, ScheduledHook $hook ): mixed => $scheduler->unschedule_hook( $hook->hook ) ), - ); - } - - #[DataProvider( 'writes' )] + #[DataProviderExternal( SchedulerTest::class, 'writes' )] public function test_a_write_on_a_switched_site_is_an_error_that_writes_nothing( \Closure $write ): void { $scheduler = $this->make_scheduler(); switch_to_blog( $this->other_site ); diff --git a/tests/Integration/Core/Scheduling/SchedulerTest.php b/tests/Integration/Core/Scheduling/SchedulerTest.php index 73b3e1f7..7caf58a9 100644 --- a/tests/Integration/Core/Scheduling/SchedulerTest.php +++ b/tests/Integration/Core/Scheduling/SchedulerTest.php @@ -8,8 +8,10 @@ use DeepWebSolutions\Framework\Core\Scheduling\ScheduledHook; use DeepWebSolutions\Framework\Core\Scheduling\Scheduler; use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; +use DeepWebSolutions\Framework\Tests\Integration\Support\DispatchesScheduledHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\InterceptsQueriesTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\RecordsDoingItWrongTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\ReportsActionSchedulerVersionsTrait; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -19,16 +21,16 @@ #[CoversClass( ActionSchedulerBackend::class )] #[CoversClass( WpCronBackend::class )] final class SchedulerTest extends TestCase { + use DispatchesScheduledHooksTrait; use InterceptsQueriesTrait; use IsolatesHooksTrait; + use RecordsDoingItWrongTrait; use ReportsActionSchedulerVersionsTrait; protected const string GROUP = 'acme'; protected string $hook; - protected array $doing_it_wrong = array(); - protected array $writes = array(); protected array $calls = array(); @@ -36,13 +38,7 @@ final class SchedulerTest extends TestCase { #[\Override] protected function setUp(): void { $this->hook = 'acme_scheduling_' . \bin2hex( \random_bytes( 4 ) ); - add_filter( 'doing_it_wrong_trigger_error', '__return_false' ); - add_action( - 'doing_it_wrong_run', - function ( string $function_name ): void { - $this->doing_it_wrong[] = $function_name; - } - ); + $this->record_doing_it_wrong(); add_action( 'action_scheduler_stored_action', function ( int $action_id ): void { @@ -113,12 +109,12 @@ public function test_an_invalid_group_is_rejected( string $group ): void { public function test_unscheduling_a_hook_rejects_an_empty_hook_name(): void { $this->expectException( InvalidArgumentException::class ); - ( new Scheduler( 'acme' ) )->unschedule_hook( '' ); + new Scheduler( 'acme' )->unschedule_hook( '' ); } public static function schedulers(): array { return array( - 'action-scheduler' => array( 'production' ), + 'action-scheduler' => array( 'action-scheduler' ), 'wp-cron' => array( 'wp-cron' ), ); } @@ -131,8 +127,8 @@ public function test_enqueueing_twice_leaves_one_pending_invocation( string $kin self::assertTrue( $scheduler->enqueue( $hook ) ); self::assertTrue( $scheduler->enqueue( $hook ) ); - self::assertSame( 'production' === $kind ? 1 : 0, \count( $this->pending_action_ids() ) ); - self::assertSame( 'production' === $kind ? 0 : 1, \count( $this->cron_events() ) ); + self::assertSame( 'action-scheduler' === $kind ? 1 : 0, \count( $this->pending_action_ids() ) ); + self::assertSame( 'action-scheduler' === $kind ? 0 : 1, \count( $this->cron_events() ) ); $ran = array(); add_action( $this->hook, @@ -140,7 +136,7 @@ static function ( int $arg ) use ( &$ran ): void { $ran[] = $arg; } ); - 'production' === $kind ? $this->run_due_actions() : $this->run_due_cron_events(); + 'action-scheduler' === $kind ? self::run_due_actions( $this->hook ) : self::run_due_cron_events( $this->hook ); self::assertSame( array( 7 ), $ran ); } @@ -148,14 +144,14 @@ public function test_enqueueing_with_a_pending_wp_cron_invocation_adds_nothing_t $hook = new ScheduledHook( $this->hook ); $this->make_scheduler( 'wp-cron' )->enqueue( $hook ); - self::assertTrue( $this->make_scheduler( 'production' )->enqueue( $hook ) ); + self::assertTrue( $this->make_scheduler( 'action-scheduler' )->enqueue( $hook ) ); self::assertSame( array(), $this->pending_action_ids() ); self::assertCount( 1, $this->cron_events() ); } public function test_enqueueing_inside_its_own_callback_counts_the_running_invocation(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); $scheduler->enqueue( $hook ); add_action( @@ -167,14 +163,14 @@ function () use ( $scheduler, $hook ): void { } ); - $this->run_due_actions(); + self::run_due_actions( $this->hook ); self::assertSame( array( true, array() ), $this->calls ); self::assertSame( array(), $this->pending_action_ids() ); } public function test_a_callback_continues_itself_with_a_single_invocation_due_now(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); $scheduler->enqueue( $hook ); $running = $this->pending_action_ids(); @@ -196,7 +192,7 @@ function () use ( $scheduler, $hook ): void { } ); - self::assertSame( 1, $this->run_due_actions() ); + self::assertSame( 1, self::run_due_actions( $this->hook ) ); self::assertSame( array( true ), $this->calls ); self::assertSame( array( false ), $unique, 'Action Scheduler data stores differ on whether a running action refuses a unique insert.' ); @@ -206,7 +202,7 @@ function () use ( $scheduler, $hook ): void { } public function test_a_failed_continuation_insert_inside_its_own_callback_is_an_error(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); $scheduler->enqueue( $hook ); add_action( @@ -217,7 +213,7 @@ function () use ( $scheduler, $hook ): void { } ); - $this->run_due_actions(); + self::run_due_actions( $this->hook ); self::assertCount( 1, $this->calls ); self::assert_wp_error( $this->calls[0], 'scheduler_failed' ); @@ -225,7 +221,7 @@ function () use ( $scheduler, $hook ): void { } public function test_an_action_scheduler_duplicate_of_a_running_invocation_is_success_without_a_write(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); $scheduler->enqueue( $hook ); \ActionScheduler::store()->log_execution( $this->pending_action_ids()[0] ); @@ -247,7 +243,7 @@ public function test_an_enqueue_that_races_another_request_adds_no_duplicate_on_ }; add_filter( 'pre_as_enqueue_async_action', $race ); - self::assertTrue( $this->make_scheduler( 'production' )->enqueue( new ScheduledHook( $this->hook ) ) ); + self::assertTrue( $this->make_scheduler( 'action-scheduler' )->enqueue( new ScheduledHook( $this->hook ) ) ); self::assertCount( 1, $this->pending_action_ids() ); } @@ -255,7 +251,7 @@ public function test_an_enqueue_that_races_another_request_adds_no_duplicate_on_ public function test_an_action_scheduler_store_failure_is_an_error(): void { add_filter( 'pre_as_enqueue_async_action', '__return_zero' ); - $result = $this->make_scheduler( 'production' )->enqueue( new ScheduledHook( $this->hook ) ); + $result = $this->make_scheduler( 'action-scheduler' )->enqueue( new ScheduledHook( $this->hook ) ); self::assert_wp_error( $result, 'scheduler_failed' ); } @@ -268,7 +264,7 @@ static function (): never { } ); - $result = $this->make_scheduler( 'production' )->enqueue( new ScheduledHook( $this->hook ) ); + $result = $this->make_scheduler( 'action-scheduler' )->enqueue( new ScheduledHook( $this->hook ) ); self::assert_wp_error( $result, 'scheduler_failed' ); self::assertSame( 'Store unavailable.', $result->get_error_message() ); @@ -286,7 +282,7 @@ public static function writes(): array { #[DataProvider( 'writes' )] public function test_a_write_before_action_scheduler_initializes_is_an_error_that_writes_nothing( \Closure $write ): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); unset( $GLOBALS['wp_actions']['action_scheduler_init'] ); $GLOBALS['wp_current_filter'][] = 'init'; $this->writes = array(); @@ -321,7 +317,7 @@ public function test_ensuring_a_recurring_hook_creates_one_chain_and_then_writes self::assertTrue( $scheduler->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ) ); self::assertSame( array(), $this->writes ); - if ( 'production' === $kind ) { + if ( 'action-scheduler' === $kind ) { $pending = $this->pending_action_ids(); self::assertCount( 1, $pending ); self::assertSame( 3600, \ActionScheduler::store()->fetch_action( $pending[0] )->get_schedule()->get_recurrence() ); @@ -346,7 +342,7 @@ public function test_ensuring_counts_only_recurring_occurrences_as_chains( strin self::assertTrue( $scheduler->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ) ); self::assertSame( array(), $this->writes ); - if ( 'production' === $kind ) { + if ( 'action-scheduler' === $kind ) { $recurring = \array_filter( $this->pending_action_ids(), static fn ( int $action_id ): bool => \ActionScheduler::store()->fetch_action( $action_id )->get_schedule()->is_recurring() ); self::assertCount( 2, $this->pending_action_ids() ); self::assertCount( 1, $recurring ); @@ -360,7 +356,7 @@ public function test_ensuring_leaves_a_sole_wp_cron_chain_where_it_is(): void { $this->make_scheduler( 'wp-cron' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ); $this->writes = array(); - self::assertTrue( $this->make_scheduler( 'production' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ) ); + self::assertTrue( $this->make_scheduler( 'action-scheduler' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ) ); self::assertSame( array(), $this->writes ); self::assertCount( 1, $this->cron_events() ); @@ -375,7 +371,7 @@ public function test_ensuring_moves_an_existing_chain_to_the_requested_interval( self::assertTrue( $scheduler->ensure_recurring( $hook, Recurrence::from_interval( 7200 ) ) ); - if ( 'production' === $kind ) { + if ( 'action-scheduler' === $kind ) { $pending = $this->pending_action_ids(); self::assertCount( 1, $pending ); self::assertSame( 7200, \ActionScheduler::store()->fetch_action( $pending[0] )->get_schedule()->get_recurrence() ); @@ -388,7 +384,7 @@ public function test_ensuring_moves_a_sole_wp_cron_chain_at_another_interval_ont $hook = new ScheduledHook( $this->hook ); $this->make_scheduler( 'wp-cron' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ); - self::assertTrue( $this->make_scheduler( 'production' )->ensure_recurring( $hook, Recurrence::from_interval( 7200 ) ) ); + self::assertTrue( $this->make_scheduler( 'action-scheduler' )->ensure_recurring( $hook, Recurrence::from_interval( 7200 ) ) ); self::assertSame( array(), $this->cron_events() ); $pending = $this->pending_action_ids(); @@ -401,7 +397,7 @@ public function test_ensuring_with_a_chain_on_each_scheduler_leaves_one_on_actio $this->make_scheduler( 'wp-cron' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ); as_schedule_recurring_action( \time(), 3600, $this->hook, array(), self::GROUP ); - self::assertTrue( $this->make_scheduler( 'production' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ) ); + self::assertTrue( $this->make_scheduler( 'action-scheduler' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ) ); self::assertSame( array(), $this->cron_events() ); self::assertCount( 1, $this->pending_action_ids() ); @@ -410,7 +406,7 @@ public function test_ensuring_with_a_chain_on_each_scheduler_leaves_one_on_actio public function test_ensuring_while_a_recurring_occurrence_runs_creates_and_clears_nothing(): void { $hook = new ScheduledHook( $this->hook ); $this->make_scheduler( 'wp-cron' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ); - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); as_schedule_recurring_action( \time(), 3600, $this->hook, array(), self::GROUP ); as_schedule_recurring_action( \time() + 3600, 3600, $this->hook, array(), self::GROUP ); add_action( @@ -422,7 +418,7 @@ function () use ( $scheduler, $hook ): void { } ); - self::assertSame( 1, $this->run_due_actions() ); + self::assertSame( 1, self::run_due_actions( $this->hook ) ); self::assertSame( array( true, array() ), $this->calls ); self::assertCount( 1, $this->cron_events() ); @@ -449,7 +445,7 @@ static function () use ( &$ran ): void { } ); - $rescheduled = $this->run_due_cron_events(); + $rescheduled = self::run_due_cron_events( $this->hook ); self::assertSame( 1, $ran ); self::assertSame( array( true ), $rescheduled ); @@ -469,7 +465,7 @@ public function test_clearing_a_scheduled_hook_removes_every_pending_occurrence_ wp_schedule_single_event( \time() + 7200, $this->hook, array( 1 ) ); wp_schedule_single_event( \time() + 3600, $this->hook, array( 2 ) ); - self::assertTrue( $this->make_scheduler( 'production' )->clear_scheduled_hook( $hook ) ); + self::assertTrue( $this->make_scheduler( 'action-scheduler' )->clear_scheduled_hook( $hook ) ); self::assertCount( 1, $this->pending_action_ids( array( 1 ) ), 'An occurrence outside the group survives.' ); self::assertCount( 1, $this->pending_action_ids( array( 2 ) ) ); @@ -486,7 +482,7 @@ public function test_an_invocation_that_survives_clearing_is_an_error(): void { } public function test_a_failed_clear_is_returned_by_clearing_a_scheduled_hook(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); wp_schedule_single_event( \time() + 3600, $this->hook ); add_filter( 'pre_clear_scheduled_hook', static fn (): \WP_Error => new \WP_Error( 'acme_clear_failed' ) ); @@ -501,7 +497,7 @@ public function test_a_failed_clear_stops_collapsing_duplicate_chains(): void { add_filter( 'pre_clear_scheduled_hook', static fn (): \WP_Error => new \WP_Error( 'acme_clear_failed' ) ); $this->writes = array(); - $result = $this->make_scheduler( 'production' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ); + $result = $this->make_scheduler( 'action-scheduler' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ); self::assert_wp_error( $result, 'acme_clear_failed' ); self::assertSame( array(), \array_filter( $this->writes, static fn ( string $write ): bool => \str_starts_with( $write, 'stored' ) ) ); @@ -514,7 +510,7 @@ public function test_a_clear_that_leaves_a_chain_stops_collapsing_duplicate_chai add_filter( 'pre_clear_scheduled_hook', '__return_zero' ); $this->writes = array(); - $result = $this->make_scheduler( 'production' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ); + $result = $this->make_scheduler( 'action-scheduler' )->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ); self::assert_wp_error( $result, 'scheduler_failed' ); self::assertSame( array(), \array_filter( $this->writes, static fn ( string $write ): bool => \str_starts_with( $write, 'stored' ) ) ); @@ -523,14 +519,14 @@ public function test_a_clear_that_leaves_a_chain_stops_collapsing_duplicate_chai public function test_a_failed_clear_is_returned_by_unscheduling_a_hook(): void { add_filter( 'pre_unschedule_hook', static fn (): \WP_Error => new \WP_Error( 'acme_clear_failed' ) ); - self::assert_wp_error( $this->make_scheduler( 'production' )->unschedule_hook( $this->hook ), 'acme_clear_failed' ); + self::assert_wp_error( $this->make_scheduler( 'action-scheduler' )->unschedule_hook( $this->hook ), 'acme_clear_failed' ); } public function test_unscheduling_a_hook_on_action_scheduler_is_best_effort(): void { as_enqueue_async_action( $this->hook ); $this->fail_queries( "a.hook='$this->hook'" ); - self::assertTrue( $this->make_scheduler( 'production' )->unschedule_hook( $this->hook ) ); + self::assertTrue( $this->make_scheduler( 'action-scheduler' )->unschedule_hook( $this->hook ) ); remove_all_filters( 'query' ); self::assertCount( 1, $this->pending_action_ids() ); @@ -540,14 +536,14 @@ public function test_a_wp_cron_duplicate_event_is_success(): void { $backend = new WpCronBackend( self::GROUP ); $hook = new ScheduledHook( $this->hook ); - self::assertTrue( $backend->enqueue( $hook, 10, true ) ); - self::assertTrue( $backend->enqueue( $hook, 10, true ) ); + self::assertTrue( $backend->enqueue( $hook, 10 ) ); + self::assertTrue( $backend->enqueue( $hook, 10 ) ); self::assertCount( 1, $this->cron_events() ); } public function test_clearing_a_scheduled_hook_while_a_recurring_occurrence_runs_is_an_error(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); $scheduler->ensure_recurring( $hook, Recurrence::from_interval( 3600 ) ); add_action( @@ -557,14 +553,14 @@ function () use ( $scheduler, $hook ): void { } ); - $this->run_due_actions(); + self::run_due_actions( $this->hook ); self::assertCount( 1, $this->calls ); self::assert_wp_error( $this->calls[0], 'scheduler_running' ); } public function test_clearing_a_one_off_inside_its_own_callback_succeeds(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); $scheduler->enqueue( $hook ); add_action( @@ -574,7 +570,7 @@ function () use ( $scheduler, $hook ): void { } ); - $this->run_due_actions(); + self::run_due_actions( $this->hook ); self::assertSame( array( true ), $this->calls ); } @@ -587,7 +583,7 @@ public function test_unscheduling_a_hook_removes_every_argument_variant_in_every wp_schedule_single_event( \time() + 3600, $this->hook ); wp_schedule_single_event( \time() + 3600, $this->hook, array( 1 ) ); - self::assertTrue( $this->make_scheduler( 'production' )->unschedule_hook( $this->hook ) ); + self::assertTrue( $this->make_scheduler( 'action-scheduler' )->unschedule_hook( $this->hook ) ); self::assertSame( array(), $this->pending_action_ids() ); self::assertSame( array(), $this->cron_events() ); @@ -599,7 +595,7 @@ public function test_unscheduling_a_hook_removes_every_argument_variant_in_every } public function test_is_scheduled_sees_a_pending_invocation_on_either_scheduler(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $on_cron = new ScheduledHook( $this->hook, array( 'cron' ) ); $on_queue = new ScheduledHook( $this->hook, array( 'queue' ) ); wp_schedule_single_event( \time() + 3600, $this->hook, array( 'cron' ) ); @@ -611,7 +607,7 @@ public function test_is_scheduled_sees_a_pending_invocation_on_either_scheduler( } public function test_is_scheduled_is_false_with_a_notice_before_action_scheduler_initializes(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); wp_schedule_single_event( \time() + 3600, $this->hook ); unset( $GLOBALS['wp_actions']['action_scheduler_init'] ); $GLOBALS['wp_current_filter'][] = 'init'; @@ -621,7 +617,7 @@ public function test_is_scheduled_is_false_with_a_notice_before_action_scheduler } public function test_is_scheduled_sees_a_running_action_scheduler_invocation(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); $scheduler->enqueue( $hook ); \ActionScheduler::store()->log_execution( $this->pending_action_ids()[0] ); @@ -638,7 +634,7 @@ public function test_scheduling_a_single_invocation_twice_keeps_the_first( strin self::assertTrue( $scheduler->schedule_single( $hook, $first ) ); self::assertTrue( $scheduler->schedule_single( $hook, $first + 86400 ) ); - if ( 'production' === $kind ) { + if ( 'action-scheduler' === $kind ) { $pending = $this->pending_action_ids(); self::assertCount( 1, $pending ); self::assertSame( $first, \ActionScheduler::store()->fetch_action( $pending[0] )->get_schedule()->get_date()->getTimestamp() ); @@ -650,7 +646,7 @@ public function test_scheduling_a_single_invocation_twice_keeps_the_first( strin } public function test_scheduling_a_single_invocation_while_the_identity_runs_adds_a_pending_one(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); $scheduler->enqueue( $hook ); \ActionScheduler::store()->log_execution( $this->pending_action_ids()[0] ); @@ -663,7 +659,7 @@ public function test_scheduling_a_single_invocation_while_the_identity_runs_adds public function test_a_timestamp_below_1_is_rejected(): void { $this->expectException( InvalidArgumentException::class ); - $this->make_scheduler( 'production' )->schedule_single( new ScheduledHook( $this->hook ), 0 ); + $this->make_scheduler( 'action-scheduler' )->schedule_single( new ScheduledHook( $this->hook ), 0 ); } #[DataProvider( 'schedulers' )] @@ -677,7 +673,7 @@ static function () use ( &$ran ): void { ); self::assertTrue( $this->make_scheduler( $kind )->schedule_single( new ScheduledHook( $this->hook ), \time() - 60 ) ); - 'production' === $kind ? $this->run_due_actions() : $this->run_due_cron_events(); + 'action-scheduler' === $kind ? self::run_due_actions( $this->hook ) : self::run_due_cron_events( $this->hook ); self::assertSame( 1, $ran ); } @@ -692,14 +688,14 @@ public static function prioritized_writes(): array { #[DataProvider( 'prioritized_writes' )] public function test_a_priority_is_stored_on_the_action_scheduler_row( \Closure $write ): void { - self::assertTrue( $write( $this->make_scheduler( 'production' ), new ScheduledHook( $this->hook ), 7 ) ); + self::assertTrue( $write( $this->make_scheduler( 'action-scheduler' ), new ScheduledHook( $this->hook ), 7 ) ); self::assertSame( 7, \ActionScheduler::store()->fetch_action( $this->pending_action_ids()[0] )->get_priority() ); } #[DataProvider( 'prioritized_writes' )] public function test_action_scheduler_clamps_a_priority_outside_0_to_255( \Closure $write ): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); self::assertTrue( $write( $scheduler, new ScheduledHook( $this->hook, array( -1 ) ), -1 ) ); self::assertTrue( $write( $scheduler, new ScheduledHook( $this->hook, array( 256 ) ), 256 ) ); @@ -728,7 +724,7 @@ public static function versions_below_the_floor(): array { #[DataProvider( 'versions_below_the_floor' )] public function test_action_scheduler_below_the_floor_takes_no_writes( array $versions ): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $this->report_action_scheduler_versions( $versions ); self::assertTrue( $scheduler->enqueue( new ScheduledHook( $this->hook ) ) ); @@ -740,7 +736,7 @@ public function test_action_scheduler_below_the_floor_takes_no_writes( array $ve public function test_enqueueing_counts_a_running_invocation_on_action_scheduler_below_the_floor(): void { as_enqueue_async_action( $this->hook, array(), self::GROUP ); \ActionScheduler::store()->log_execution( $this->pending_action_ids()[0] ); - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $this->report_action_scheduler_versions( array( '4.0.0' ) ); self::assertTrue( $scheduler->enqueue( new ScheduledHook( $this->hook ) ) ); @@ -749,7 +745,7 @@ public function test_enqueueing_counts_a_running_invocation_on_action_scheduler_ } public function test_an_action_scheduler_that_never_initialized_by_the_end_of_init_is_unsupported(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $this->report_action_scheduler_versions( array( '3.5.4' ) ); unset( $GLOBALS['wp_actions']['action_scheduler_init'] ); @@ -760,7 +756,7 @@ public function test_an_action_scheduler_that_never_initialized_by_the_end_of_in } public function test_action_scheduler_below_the_floor_is_still_read_and_cleared(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); as_schedule_single_action( \time() + 3600, $this->hook, array(), self::GROUP ); $this->report_action_scheduler_versions( array( '4.0.0' ) ); @@ -772,7 +768,7 @@ public function test_action_scheduler_below_the_floor_is_still_read_and_cleared( } public function test_one_scheduler_follows_action_scheduler_readiness_from_call_to_call(): void { - $scheduler = $this->make_scheduler( 'production' ); + $scheduler = $this->make_scheduler( 'action-scheduler' ); $hook = new ScheduledHook( $this->hook ); $ready = $GLOBALS['wp_actions']['action_scheduler_init']; unset( $GLOBALS['wp_actions']['action_scheduler_init'] ); @@ -787,7 +783,7 @@ public function test_one_scheduler_follows_action_scheduler_readiness_from_call_ public static function arguments(): array { $cases = array(); - foreach ( array( 'production', 'wp-cron' ) as $kind ) { + foreach ( array( 'action-scheduler', 'wp-cron' ) as $kind ) { $cases[ "$kind-every-scalar-kind" ] = array( $kind, array( true, false, null, 0, 'Acme' ) ); $cases[ "$kind-hashed-by-action-scheduler" ] = array( $kind, array( \str_repeat( 'a', 300 ) ) ); } @@ -812,7 +808,7 @@ static function ( mixed ...$passed ) use ( &$received ): void { $scheduler->enqueue( $hook ); self::assertTrue( $scheduler->is_scheduled( $hook ) ); self::assertFalse( $scheduler->is_scheduled( new ScheduledHook( $this->hook, array( 'other' ) ) ) ); - 'production' === $kind ? $this->run_due_actions() : $this->run_due_cron_events(); + 'action-scheduler' === $kind ? self::run_due_actions( $this->hook ) : self::run_due_cron_events( $this->hook ); self::assertSame( array( $args ), $received ); } @@ -820,7 +816,7 @@ static function ( mixed ...$passed ) use ( &$received ): void { public function test_ensuring_replaces_a_cron_expression_chain_with_one_interval_chain(): void { as_schedule_cron_action( \time(), '0 * * * *', $this->hook, array(), self::GROUP ); - self::assertTrue( $this->make_scheduler( 'production' )->ensure_recurring( new ScheduledHook( $this->hook ), Recurrence::from_interval( 3600 ) ) ); + self::assertTrue( $this->make_scheduler( 'action-scheduler' )->ensure_recurring( new ScheduledHook( $this->hook ), Recurrence::from_interval( 3600 ) ) ); $pending = $this->pending_action_ids(); self::assertCount( 1, $pending ); @@ -865,36 +861,4 @@ protected function cron_events(): array { return $events; } - - // Mirrors one Action Scheduler queue-runner batch. - protected function run_due_actions(): int { - $store = \ActionScheduler::store(); - $claim = $store->stake_claim( 10, null, array( $this->hook ) ); - foreach ( $claim->get_actions() as $action_id ) { - \ActionScheduler::runner()->process_action( (int) $action_id, 'Tests' ); - } - $store->release_claim( $claim ); - - return \count( $claim->get_actions() ); - } - - // Mirrors one wp-cron.php run. - protected function run_due_cron_events(): array { - $rescheduled = array(); - foreach ( _get_cron_array() as $timestamp => $hooks ) { - if ( $timestamp > \time() ) { - break; - } - - foreach ( $hooks[ $this->hook ] ?? array() as $event ) { - if ( false !== $event['schedule'] ) { - $rescheduled[] = wp_reschedule_event( $timestamp, $event['schedule'], $this->hook, $event['args'], true ); - } - wp_unschedule_event( $timestamp, $this->hook, $event['args'], true ); - do_action_ref_array( $this->hook, $event['args'] ); - } - } - - return $rescheduled; - } } diff --git a/tests/Integration/Core/Storage/LockTest.php b/tests/Integration/Core/Storage/LockTest.php index 94bdefbb..d5ca1ff6 100644 --- a/tests/Integration/Core/Storage/LockTest.php +++ b/tests/Integration/Core/Storage/LockTest.php @@ -53,7 +53,7 @@ public function test_a_hold_past_its_ttl_is_taken_over(): void { } public function test_expiry_is_judged_by_the_ttl_the_row_holds(): void { - ( new Lock( "{$this->prefix}_lock", 600, $this->clock ) )->acquire( 'first' ); + new Lock( "{$this->prefix}_lock", 600, $this->clock )->acquire( 'first' ); $this->clock->advance( 61 ); self::assertSame( LockClaim::Busy, $this->make_lock()->acquire( 'second' ) ); @@ -219,7 +219,7 @@ public function test_a_release_that_races_a_takeover_leaves_the_new_hold(): void public function test_without_a_clock_the_lock_reads_the_system_time(): void { $before = \time(); - ( new Lock( "{$this->prefix}_lock", 60 ) )->acquire( 'first' ); + new Lock( "{$this->prefix}_lock", 60 )->acquire( 'first' ); $time = $this->stored()['time'] ?? 0; self::assertGreaterThanOrEqual( $before, $time ); @@ -231,7 +231,7 @@ public static function invalid_calls(): array { 'zero-ttl' => array( static fn (): mixed => new Lock( 'acme_lock', 0 ) ), 'negative-ttl' => array( static fn (): mixed => new Lock( 'acme_lock', -60 ) ), 'empty-name' => array( static fn (): mixed => new Lock( '', 60 ) ), - 'empty-owner' => array( static fn (): mixed => ( new Lock( 'acme_lock', 60 ) )->acquire( '' ) ), + 'empty-owner' => array( static fn (): mixed => new Lock( 'acme_lock', 60 )->acquire( '' ) ), ); } diff --git a/tests/Integration/Core/Storage/OptionRowsTest.php b/tests/Integration/Core/Storage/OptionRowsTest.php index 4f7fe8bf..248f9aaa 100644 --- a/tests/Integration/Core/Storage/OptionRowsTest.php +++ b/tests/Integration/Core/Storage/OptionRowsTest.php @@ -23,13 +23,6 @@ final class OptionRowsTest extends TestCase { use IsolatesHooksTrait; use IsolatesStorageTrait; - #[\Override] - protected function tearDown(): void { - while ( is_multisite() && ms_is_switched() ) { - restore_current_blog(); - } - } - public function test_an_inserted_row_reads_back_unchanged_as_one_row_that_is_not_autoloaded(): void { $written = self::insert( "{$this->prefix}_one", array( 'message' => "Can't \\ \"save\"." ) ); diff --git a/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php b/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php index a15e3552..210bfbaf 100644 --- a/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php +++ b/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php @@ -295,14 +295,10 @@ protected function get_posts_of_type( string $type ): array { return $wpdb->get_col( $wpdb->prepare( 'SELECT ID FROM %i WHERE BINARY post_type = %s', $wpdb->posts, $type ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Counts every status, trash and auto-draft included. } - protected function post_type_has_posts( string $type ): bool { - return array() !== $this->get_posts_of_type( $type ); - } - protected function table_exists( string $name ): bool { global $wpdb; - return null !== $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->prefix . $name ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Schema read. + return ! \is_null( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->prefix . $name ) ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Schema read. } protected function role_has( string $role, string $capability ): bool { diff --git a/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php b/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php index cfadcbfa..07ace5d0 100644 --- a/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php +++ b/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php @@ -39,9 +39,6 @@ protected function setUp(): void { #[\Override] protected function tearDown(): void { - while ( ms_is_switched() ) { - restore_current_blog(); - } foreach ( get_sites( array( 'fields' => 'ids', @@ -52,6 +49,13 @@ protected function tearDown(): void { } } + public static function constant_consents(): array { + return array( + 'with-the-constant' => array( true ), + 'without-the-constant' => array( false ), + ); + } + public function test_a_subsite_only_plugin_deleted_from_the_network_admin_cleans_its_subsite_and_unschedules_everywhere(): void { $this->seed_site( $this->main_site, null ); $this->seed_site( $this->other_site, 'yes' ); @@ -129,13 +133,6 @@ function () use ( $p ): void { self::assertSame( array( false, false, false, false ), $other ); } - public static function constant_consents(): array { - return array( - 'with-the-constant' => array( true ), - 'without-the-constant' => array( false ), - ); - } - #[DataProvider( 'constant_consents' )] public function test_the_network_pass_runs_only_on_the_consent_constant( bool $constant ): void { $this->seed_site( $this->main_site, 'yes' ); @@ -314,7 +311,7 @@ protected function seed_site( int $site_id, ?string $consent ): void { function () use ( $consent ): void { global $wpdb; - if ( null !== $consent ) { + if ( ! \is_null( $consent ) ) { update_option( "{$this->prefix}_remove_data", $consent ); } update_option( "{$this->prefix}_settings", 'x' ); diff --git a/tests/Integration/Core/Uninstall/UninstallerTest.php b/tests/Integration/Core/Uninstall/UninstallerTest.php index db14305f..670bb4e2 100644 --- a/tests/Integration/Core/Uninstall/UninstallerTest.php +++ b/tests/Integration/Core/Uninstall/UninstallerTest.php @@ -54,6 +54,30 @@ static function ( string $prefix ): array { ); } + public static function retaining_and_consenting(): array { + return array( + 'retaining' => array( false ), + 'consenting' => array( true ), + ); + } + + public static function failing_statements(): array { + return array( + 'an-option-delete' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_settings'", '_settings', static fn ( self $test ): bool => $test->option_exists( "{$test->prefix}_settings" ) ), + 'a-prefix-read' => array( static fn ( \wpdb $wpdb, string $prefix ): string => 'SELECT DISTINCT CAST( `option_name`', '_cache_', static fn ( self $test ): bool => $test->option_exists( "{$test->prefix}_cache_a" ) ), + 'a-post-type-read' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "AS value FROM `$wpdb->posts`", '_type', static fn ( self $test ): bool => array() !== $test->get_posts_of_type( "{$test->prefix}_type" ) ), + 'a-network-option' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_network_settings'", '_network_settings', static fn ( self $test ): bool => $test->network_option_exists( "{$test->prefix}_network_settings" ) ), + 'an-option-delete-with-prefix-consent' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_settings'", '_settings', static fn ( self $test ): bool => $test->option_exists( "{$test->prefix}_settings" ), '_cache_remove_data' ), + ); + } + + public static function refused_deletes(): array { + return array( + 'a-post' => array( 'pre_delete_post', '_type' ), + 'a-meta-row' => array( 'delete_post_metadata_by_mid', '_flag' ), + ); + } + #[DataProvider( 'retaining_consents' )] public function test_a_run_without_consent_keeps_the_footprint_and_runs_no_cleanup( \Closure $consent ): void { $this->seed_footprint(); @@ -324,18 +348,8 @@ public function test_an_action_scheduler_hook_or_group_named_0_cancels_no_other_ self::assertTrue( as_has_scheduled_action( "{$this->prefix}_grouped" ) ); } - public static function failing_statements(): array { - return array( - 'an-option-delete' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_settings'", '_settings', 'option_exists', '_settings' ), - 'a-prefix-read' => array( static fn ( \wpdb $wpdb, string $prefix ): string => 'SELECT DISTINCT CAST( `option_name`', '_cache_', 'option_exists', '_cache_a' ), - 'a-post-type-read' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "AS value FROM `$wpdb->posts`", '_type', 'post_type_has_posts', '_type' ), - 'a-network-option' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_network_settings'", '_network_settings', 'network_option_exists', '_network_settings' ), - 'an-option-delete-with-prefix-consent' => array( static fn ( \wpdb $wpdb, string $prefix ): string => "DELETE FROM `$wpdb->options` WHERE `option_name` = '{$prefix}_settings'", '_settings', 'option_exists', '_settings', '_cache_remove_data' ), - ); - } - #[DataProvider( 'failing_statements' )] - public function test_a_failed_statement_keeps_the_consent_row_and_throws_after_the_rest_so_a_retry_completes( \Closure $needle, string $subject, string $kept, string $row, string $consent = '_remove_data' ): void { + public function test_a_failed_statement_keeps_the_consent_row_and_throws_after_the_rest_so_a_retry_completes( \Closure $needle, string $subject, \Closure $kept, string $consent = '_remove_data' ): void { global $wpdb; $this->seed_footprint(); @@ -352,7 +366,7 @@ public function test_a_failed_statement_keeps_the_consent_row_and_throws_after_t self::assertInstanceOf( RuntimeException::class, $failure ); self::assertStringContainsString( $this->prefix . $subject, $failure->getMessage() ); - self::assertTrue( $this->$kept( $this->prefix . $row ) ); + self::assertTrue( $kept( $this ) ); self::assertFalse( $this->table_exists( "{$this->prefix}_log" ) ); self::assertSame( array( 'cleanup' ), \array_column( $this->calls, 0 ) ); self::assertTrue( $this->option_exists( $this->prefix . $consent ) ); @@ -361,17 +375,10 @@ public function test_a_failed_statement_keeps_the_consent_row_and_throws_after_t $this->restore_query_errors(); Uninstaller::run( self::PLUGIN_FILE, $this->footprint(), $args ); - self::assertFalse( $this->$kept( $this->prefix . $row ) ); + self::assertFalse( $kept( $this ) ); self::assertFalse( $this->option_exists( $this->prefix . $consent ) ); } - public static function refused_deletes(): array { - return array( - 'a-post' => array( 'pre_delete_post', '_type' ), - 'a-meta-row' => array( 'delete_post_metadata_by_mid', '_flag' ), - ); - } - #[DataProvider( 'refused_deletes' )] public function test_a_delete_wordpress_refuses_is_thrown_after_the_rest_and_a_retry_completes( string $filter, string $subject ): void { $this->seed_footprint(); @@ -427,7 +434,7 @@ public function test_a_failing_cleanup_keeps_that_sites_footprint_and_consent_ro } self::assertInstanceOf( RuntimeException::class, $failure ); - self::assertStringContainsString( 'cleanup: The cleanup failed.', $failure->getMessage() ); + self::assertStringEndsWith( 'cleanup: The cleanup failed.', $failure->getMessage() ); self::assertFalse( wp_next_scheduled( "{$this->prefix}_cron" ) ); self::assertTrue( $this->option_exists( "{$this->prefix}_settings" ) ); self::assertTrue( $this->option_exists( "{$this->prefix}_remove_data" ) ); @@ -491,13 +498,6 @@ public function test_an_action_scheduler_cancel_that_throws_is_logged_without_ho self::assertFalse( $this->option_exists( "{$this->prefix}_remove_data" ) ); } - public static function retaining_and_consenting(): array { - return array( - 'retaining' => array( false ), - 'consenting' => array( true ), - ); - } - protected static function stored_consent( mixed $value, string $key = '' ): \Closure { return static function ( string $prefix ) use ( $value, $key ): array { update_option( "{$prefix}_remove_data", $value ); @@ -508,8 +508,8 @@ protected static function stored_consent( mixed $value, string $key = '' ): \Clo protected static function defined_consent( mixed $value ): \Closure { return static function ( string $prefix ) use ( $value ): array { - $constant = \strtoupper( "{$prefix}_remove_all_data_" . \bin2hex( \random_bytes( 2 ) ) ); - \define( $constant, $value ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.VariableConstantNameFound -- A unique name per run, since a constant cannot be undefined. + $constant = \strtoupper( "{$prefix}_remove_all_data" ); + \define( $constant, $value ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.VariableConstantNameFound -- A unique name per test, since a constant cannot be undefined. return array( 'consent' => array( 'constant' => $constant ) ); }; diff --git a/tests/Integration/Personas/CorePersonaTest.php b/tests/Integration/Personas/CorePersonaTest.php index f9cb284c..612805fa 100644 --- a/tests/Integration/Personas/CorePersonaTest.php +++ b/tests/Integration/Personas/CorePersonaTest.php @@ -26,4 +26,21 @@ public function test_the_plugin_boots_its_components_and_runs_its_migration_with self::assertTrue( $request['records']['greeting_shortcode'] ); self::assertSame( array( 'greeting' => 'Hello' ), get_option( 'acme_pc_settings' ) ); } + + public function test_a_consenting_uninstall_removes_the_plugin_s_options(): void { + update_option( + 'acme_pc_settings', + array( + 'greeting' => 'Hello', + 'delete_data' => 'yes', + ) + ); + update_option( 'acme_pc_revision', 1 ); + $plugin = $this->install_consumer( 'personas/core', 'acme-persona-core' ); + $request = $this->consumer_request( 'GET', 'index.php', $this->make_user( 'acme_pc_admin', 'administrator' ), array( 'uninstall' => $plugin ) ); + + self::assertTrue( $request['records']['deleted'] ); + self::assertFalse( get_option( 'acme_pc_settings' ) ); + self::assertFalse( get_option( 'acme_pc_revision' ) ); + } } diff --git a/tests/Integration/Personas/PersonasScopingTest.php b/tests/Integration/Personas/PersonasScopingTest.php deleted file mode 100644 index 188ef5f7..00000000 --- a/tests/Integration/Personas/PersonasScopingTest.php +++ /dev/null @@ -1,48 +0,0 @@ - array( 'bootstrap', array( 'ahegyes', 'scoper-autoload.php' ), array( 'wp-framework-bootstrap' ), array() ), - 'shared' => array( 'shared', array( 'ahegyes', 'scoper-autoload.php' ), array( 'wp-framework-shared' ), array() ), - 'core' => array( 'core', null, array( 'wp-framework-bootstrap', 'wp-framework-core', 'wp-framework-shared' ), $psr ), - 'settings' => array( 'settings', array( 'ahegyes', 'psr', 'scoper-autoload.php' ), array( 'wp-framework-bootstrap', 'wp-framework-core', 'wp-framework-settings', 'wp-framework-shared' ), $psr ), - 'woocommerce' => array( 'woocommerce', array( 'ahegyes', 'psr', 'scoper-autoload.php' ), array( 'wp-framework-bootstrap', 'wp-framework-core', 'wp-framework-settings', 'wp-framework-shared', 'wp-framework-woocommerce' ), $psr ), - ); - } - - // PHP-DI's own dependencies change between releases, so the core persona's top level stays unlisted. - #[DataProvider( 'trees' )] - public function test_the_production_build_ships_its_closure_scoped_and_nothing_unscoped( string $persona, ?array $top_level, array $packages, array $psr ): void { - $build = self::PERSONAS . "/$persona"; - self::assertFileExists( "$build/vendor/composer/installed.json", "Build personas/$persona first with `composer fixtures:build`." ); - - $installed = \json_decode( (string) \file_get_contents( "$build/vendor/composer/installed.json" ), true ); - self::assertIsArray( $installed ); - self::assertFalse( $installed['dev'] ); - self::assertSame( array(), $installed['packages'] ); - if ( null !== $top_level ) { - self::assertSame( $top_level, self::list_entries( "$build/vendor-prefixed" ) ); - } - self::assertSame( $packages, self::list_entries( "$build/vendor-prefixed/ahegyes" ) ); - self::assertSame( $psr, self::list_entries( "$build/vendor-prefixed/psr" ) ); - } - - protected static function list_entries( string $path ): array { - $entries = \is_dir( $path ) ? \array_values( \array_diff( (array) \scandir( $path ), array( '.', '..' ) ) ) : array(); - \sort( $entries ); - - return $entries; - } -} diff --git a/tests/Integration/Personas/SettingsPersonaTest.php b/tests/Integration/Personas/SettingsPersonaTest.php index 7ad0aa87..3124df5d 100644 --- a/tests/Integration/Personas/SettingsPersonaTest.php +++ b/tests/Integration/Personas/SettingsPersonaTest.php @@ -39,6 +39,23 @@ public function test_a_rejected_limit_keeps_the_stored_one_and_its_message_shows self::assertStringContainsString( 'Limit was not saved and keeps its previous value. The other changes were saved. Enter a whole number of at least 1.', $page['body'] ); } + public function test_the_post_form_saves_its_field_on_the_post_through_the_block_editor_s_meta_box_save(): void { + $user = $this->make_user( 'acme_ps_admin', 'administrator' ); + $spec = array( 'activate' => $this->install_consumer( 'personas/settings', 'acme-persona-settings' ) ); + $post_id = wp_insert_post( array( 'post_title' => 'Acme post' ) ); + + try { + $body = $this->consumer_request( 'GET', "post.php?post=$post_id&action=edit", $user, $spec )['body']; + $data = \array_replace_recursive( self::serialize_form( $body, 'form.metabox-base-form' ), self::serialize_form( $body, 'form.metabox-location-advanced', array( 'acme_ps_details[_acme_ps_subtitle]' => 'Hello' ) ) ); + self::assertSame( 1, \preg_match( '/var _wpMetaBoxUrl = ("[^"]+");/', $body, $matches ) ); + $this->consumer_request( 'POST', \explode( '/wp-admin/', (string) \json_decode( $matches[1] ), 2 )[1], $user, $spec, $data ); + + self::assertSame( 'Hello', get_post_meta( $post_id, '_acme_ps_subtitle', true ) ); + } finally { + wp_delete_post( $post_id, true ); + } + } + // Posts the settings tab as the browser submits it after the user typed the limit. protected function save_limit( string $limit ): array { $user = $this->make_user( 'acme_ps_admin', 'administrator' ); diff --git a/tests/Integration/Personas/SharedPersonaTest.php b/tests/Integration/Personas/SharedPersonaTest.php index 86dbe23b..b0f82d55 100644 --- a/tests/Integration/Personas/SharedPersonaTest.php +++ b/tests/Integration/Personas/SharedPersonaTest.php @@ -10,7 +10,7 @@ final class SharedPersonaTest extends TestCase { protected const string BUILD = __DIR__ . '/../../Fixtures/personas/shared'; // The script runs in its own PHP process, so WordPress never loads beside the scoped shared package. - public function test_the_scoped_package_parses_versions_matches_results_and_compares_value_objects_in_plain_php(): void { + public function test_the_scoped_package_parses_versions_throws_its_mirrors_matches_and_narrows_results_and_compares_value_objects_in_plain_php(): void { self::assertFileExists( self::BUILD . '/vendor-prefixed/scoper-autoload.php', 'Build personas/shared first with `composer fixtures:build`.' ); $descriptors = array( @@ -29,7 +29,9 @@ public function test_the_scoped_package_parses_versions_matches_results_and_comp array( 'wordpress' => false, 'version' => array( '2.1.0-rc.1', 1 ), + 'rejected' => 'Acme\\PersonaShared\\Scoped\\DeepWebSolutions\\Framework\\Shared\\Exception\\InvalidArgumentException', 'results' => array( 'found acme', 'unknown acme' ), + 'narrowed' => array( 'found box', 'unknown other' ), 'equality' => array( true, false ), ), \json_decode( $output, true, flags: \JSON_THROW_ON_ERROR ) diff --git a/tests/Integration/Personas/WooCommercePersonaTest.php b/tests/Integration/Personas/WooCommercePersonaTest.php index 98321133..0d360353 100644 --- a/tests/Integration/Personas/WooCommercePersonaTest.php +++ b/tests/Integration/Personas/WooCommercePersonaTest.php @@ -47,4 +47,23 @@ public function test_the_product_data_tab_saves_its_field_on_the_product_through $product->delete( true ); } } + + public function test_a_consenting_uninstall_removes_the_plugin_s_options_and_product_meta(): void { + update_option( 'acme_pw_default_box', 'Large' ); + update_option( 'acme_pw_delete_data', 'yes' ); + $product = new \WC_Product_Simple(); + $product->update_meta_data( '_acme_pw_box', 'Small' ); + $product->save(); + + try { + $request = $this->consumer_request( 'GET', 'index.php', $this->admin, array( 'uninstall' => $this->spec['activate'] ) ); + + self::assertTrue( $request['records']['deleted'] ); + self::assertFalse( get_option( 'acme_pw_default_box' ) ); + self::assertFalse( get_option( 'acme_pw_delete_data' ) ); + self::assertSame( '', get_post_meta( $product->get_id(), '_acme_pw_box', true ) ); + } finally { + $product->delete( true ); + } + } } diff --git a/tests/Integration/Settings/AdminPage/OptionsPageTest.php b/tests/Integration/Settings/AdminPage/OptionsPageTest.php index 60fcd2a9..53ed702f 100644 --- a/tests/Integration/Settings/AdminPage/OptionsPageTest.php +++ b/tests/Integration/Settings/AdminPage/OptionsPageTest.php @@ -86,7 +86,9 @@ public function test_an_invalid_id_is_rejected( string $id ): void { #[DataProvider( 'v1_helper_ids' )] public function test_every_v1_key_is_a_valid_id( string $id ): void { - self::assertInstanceOf( OptionsPage::class, new OptionsPage( $id, new RecordedDefinition( static fn (): array => array() ) ) ); + $this->expectNotToPerformAssertions(); + + new OptionsPage( $id, new RecordedDefinition( static fn (): array => array() ) ); } public function test_the_definition_is_evaluated_neither_at_construction_nor_at_registration(): void { @@ -123,7 +125,7 @@ public function test_a_first_save_validates_once_and_stores_the_result_without_a $saved = $this->save( $spec, $this->admin, $data ); self::assertSame( array( 'name' => 'v!' ), get_option( $this->option ) ); - self::assertSame( array( array( 'name', 'v' ) ), $saved['records'] ); + self::assertSame( array( array( 'validate', 'name', 'v' ) ), $saved['records'] ); self::assertSame( array( 'Settings saved.' ), self::notices( $this->follow( $spec, $this->admin, $saved )['body'] ) ); } @@ -131,7 +133,7 @@ public function test_a_programmatic_update_outside_options_php_is_stored_as_give $value = array( 'name' => ' raw ' ); $spec = $this->spec( array( self::field( 'text', 'name', array( 'validate' => 'acme:append' ) ) ), array(), array( 'update' => array( $this->option => $value ) ) ); - $request = $this->admin_request( 'GET', "admin-post.php?option_page={$this->option}", $this->admin, self::FIXTURE, $spec ); + $request = $this->admin_request( 'GET', "admin-post.php?option_page=$this->option", $this->admin, self::FIXTURE, $spec ); self::assertSame( $value, get_option( $this->option ) ); self::assertSame( array(), $request['records'] ); @@ -204,7 +206,7 @@ public function test_titles_and_messages_print_as_text_and_an_untitled_field_is_ $page = $this->follow( $spec, $this->admin, $this->save( $spec, $this->admin, $data ) )['body']; self::assertSame( array( 'Email', 'site' ), self::row_titles( $form ) ); - self::assertSame( "{$this->option}.site", self::document( $form )->querySelectorAll( 'form tr' )->item( 1 )?->querySelector( 'th label' )?->getAttribute( 'for' ) ); + self::assertSame( "$this->option.site", self::document( $form )->querySelectorAll( 'form tr' )->item( 1 )?->querySelector( 'th label' )?->getAttribute( 'for' ) ); self::assertNull( self::document( $form )->querySelector( 'form em' ) ); self::assertSame( array( @@ -360,7 +362,7 @@ public function test_an_option_page_outside_the_post_body_or_padded_still_valida unset( $data['option_page'] ); $query = '?option_page=' . \rawurlencode( $this->option ); } else { - $data['option_page'] = " {$this->option} "; + $data['option_page'] = " $this->option "; } $saved = $this->save( $spec, $this->admin, $data, $query ); @@ -398,7 +400,7 @@ public function test_a_cleared_multi_value_field_reaches_validate_as_an_empty_ar $saved = $this->save( $spec, $this->admin, $data ); - self::assertSame( array( array( 'colors', array() ) ), $saved['records'] ); + self::assertSame( array( array( 'validate', 'colors', array() ) ), $saved['records'] ); self::assertSame( array( 'colors' => array() ), get_option( $this->option ) ); } @@ -439,6 +441,14 @@ public function test_the_submenu_entry_and_the_page_exist_only_for_users_holding self::assertStringNotContainsString( 'Acme settings', $editor_page['body'] ); } + public function test_a_label_points_at_its_control_when_the_field_id_holds_an_entity(): void { + $form = self::document( $this->open( $this->spec( array( self::field( 'text', 'r&d' ) ) ), $this->admin )['body'] )->querySelector( 'form[action="options.php"]' ); + + $id = (string) $form?->querySelector( 'td input[type=text]' )?->getAttribute( 'id' ); + self::assertSame( "$this->option.r&d", $id ); + self::assertSame( $id, $form?->querySelector( 'th label' )?->getAttribute( 'for' ) ); + } + public function test_a_form_posted_back_with_one_edit_changes_only_that_field_and_labels_point_at_single_controls(): void { $stored = array( 'name' => 'old', @@ -486,16 +496,16 @@ public function test_a_form_posted_back_with_one_edit_changes_only_that_field_an } self::assertSame( array( - 'Name' => "{$this->option}.name", - 'Count' => "{$this->option}.count", - 'Email' => "{$this->option}.email", - 'Site' => "{$this->option}.site", - 'Notes' => "{$this->option}.notes", - 'On' => "{$this->option}.on", - 'Off' => "{$this->option}.off", - 'Size' => "{$this->option}.size", + 'Name' => "$this->option.name", + 'Count' => "$this->option.count", + 'Email' => "$this->option.email", + 'Site' => "$this->option.site", + 'Notes' => "$this->option.notes", + 'On' => "$this->option.on", + 'Off' => "$this->option.off", + 'Size' => "$this->option.size", 'Tone' => null, - 'Colors' => "{$this->option}.colors", + 'Colors' => "$this->option.colors", 'Days' => null, 'Agree' => null, ), @@ -626,7 +636,7 @@ protected function open( array $spec, int $user_id, string $tab = '' ): array { $parent_slug = $spec['page']['parent']; $file = \str_ends_with( $parent_slug, '.php' ) ? $parent_slug : 'admin.php'; - return $this->admin_request( 'GET', "$file?page={$this->id}" . ( '' === $tab ? '' : "&tab=$tab" ), $user_id, self::FIXTURE, $spec ); + return $this->admin_request( 'GET', "$file?page=$this->id" . ( '' === $tab ? '' : "&tab=$tab" ), $user_id, self::FIXTURE, $spec ); } protected function save( array $spec, int $user_id, array $data, string $query = '' ): array { diff --git a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php index 535a3cc6..d1370e8d 100644 --- a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php +++ b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php @@ -8,12 +8,11 @@ use DeepWebSolutions\Framework\Settings\EditScreen\ObjectForm; use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\InspectsObjectFormsTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\ProvidesHelperIdsTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\RecordedDefinition; -use DeepWebSolutions\Framework\Tests\Integration\Support\RunsAdminRequestsTrait; -use DeepWebSolutions\Framework\Tests\Integration\Support\SerializesFormsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -23,19 +22,10 @@ #[CoversClass( PostMetaBox::class )] final class PostMetaBoxTest extends TestCase { use CreatesUsersTrait; + use InspectsObjectFormsTrait; use IsolatesHooksTrait; use IsolatesStorageTrait; use ProvidesHelperIdsTrait; - use RunsAdminRequestsTrait; - use SerializesFormsTrait; - - protected const string FIXTURE = __DIR__ . '/../../../../Fixtures/Settings/acme-object-forms.php'; - - protected const array COLORS = array( - 'red' => 'Red', - 'blue' => 'Blue', - 'teal' => 'Teal', - ); protected int $admin; protected int $editor; @@ -85,11 +75,9 @@ public function test_an_invalid_form_id_is_rejected( string $id ): void { #[DataProvider( 'v1_helper_ids' )] public function test_every_v1_key_is_a_valid_form_id( string $id ): void { - $host = new PostMetaBox( array( 'post' ) ); - $form = new ObjectForm( $id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) ); - $form->register_hooks(); + $form = new ObjectForm( $id, new RecordedDefinition( static fn (): array => array() ), new PostMetaBox( array( 'post' ) ), new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) ); - self::assertSame( 10, has_action( 'add_meta_boxes', array( $host, 'add_meta_box' ) ) ); + self::assertSame( $id, $form->get_id() ); } public function test_the_definition_is_evaluated_only_when_a_screen_needs_it(): void { @@ -115,9 +103,9 @@ public function test_the_box_shows_on_the_edit_screens_of_its_post_types_only(): $post = self::document( $this->edit_screen( $spec, $post_id )['body'] ); $page = self::document( $this->edit_screen( $spec, $page_id )['body'] ); - self::assertSame( 'Acme details', \trim( (string) $post->querySelector( "form#post div.postbox[id=\"{$this->id}\"] h2.hndle" )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertSame( 'Acme details', \trim( (string) $post->querySelector( "form#post div.postbox[id=\"$this->id\"] h2.hndle" )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertNotNull( $this->box( $post )->querySelector( "input[type=hidden][name=\"{$this->id}_nonce\"]" ) ); - self::assertNull( $page->querySelector( "div.postbox[id=\"{$this->id}\"]" ) ); + self::assertNull( $page->querySelector( "div.postbox[id=\"$this->id\"]" ) ); } public function test_the_box_sits_in_its_context(): void { @@ -135,7 +123,7 @@ public function test_the_box_sits_in_its_context(): void { $screen = self::document( $this->edit_screen( $spec, $post_id )['body'] ); - self::assertNotNull( $screen->querySelector( "#side-sortables div.postbox[id=\"{$this->id}\"]" ) ); + self::assertNotNull( $screen->querySelector( "#side-sortables div.postbox[id=\"$this->id\"]" ) ); } public function test_a_user_lacking_the_forms_capability_gets_no_box(): void { @@ -145,7 +133,7 @@ public function test_a_user_lacking_the_forms_capability_gets_no_box(): void { $screen = self::document( $this->edit_screen( $spec, $post_id, $this->editor )['body'] ); self::assertNotNull( $screen->querySelector( 'form#post' ) ); - self::assertNull( $screen->querySelector( "div.postbox[id=\"{$this->id}\"]" ) ); + self::assertNull( $screen->querySelector( "div.postbox[id=\"$this->id\"]" ) ); } public function test_a_single_control_is_labelled_by_its_row(): void { @@ -154,10 +142,21 @@ public function test_a_single_control_is_labelled_by_its_row(): void { $box = $this->box( self::document( $this->edit_screen( $spec, $post_id )['body'] ) ); - $label = $box->querySelector( "div.form-field > p.post-attributes-label-wrapper > label.post-attributes-label[for=\"{$this->id}._{$this->prefix}_name\"]" ); + $label = $box->querySelector( "div.form-field > p.post-attributes-label-wrapper > label.post-attributes-label[for=\"$this->id._{$this->prefix}_name\"]" ); self::assertNotNull( $label ); self::assertSame( 'Name', \trim( $label->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - self::assertNotNull( $box->querySelector( "div.form-field > input[type=text][id=\"{$this->id}._{$this->prefix}_name\"][name=\"{$this->id}[_{$this->prefix}_name]\"]" ) ); + self::assertNotNull( $box->querySelector( "div.form-field > input[type=text][id=\"$this->id._{$this->prefix}_name\"][name=\"{$this->id}[_{$this->prefix}_name]\"]" ) ); + } + + public function test_a_label_points_at_its_control_when_the_field_id_holds_an_entity(): void { + $post_id = $this->make_post(); + $spec = $this->spec( array( $this->field( 'text', 'r&d' ) ) ); + + $box = $this->box( self::document( $this->edit_screen( $spec, $post_id )['body'] ) ); + + $id = (string) $box->querySelector( 'input[type=text]' )?->getAttribute( 'id' ); + self::assertSame( "$this->id._{$this->prefix}_r&d", $id ); + self::assertSame( $id, $box->querySelector( 'label.post-attributes-label' )?->getAttribute( 'for' ) ); } #[DataProvider( 'group_fields' )] @@ -172,7 +171,7 @@ public function test_a_group_is_one_fieldset_named_by_its_own_legend_under_its_r self::assertCount( 1, $box->querySelectorAll( 'fieldset' ) ); self::assertSame( 'Color', \trim( (string) $fieldset->querySelector( 'legend.screen-reader-text' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertSame( 'Color', \trim( (string) $fieldset->parentElement?->querySelector( 'p.post-attributes-label-wrapper > span.post-attributes-label' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - self::assertNull( $box->querySelector( "label.post-attributes-label[for=\"{$this->id}._{$this->prefix}_color\"]" ) ); + self::assertNull( $box->querySelector( "label.post-attributes-label[for=\"$this->id._{$this->prefix}_color\"]" ) ); self::assertNotNull( $fieldset->querySelector( "input[name^=\"{$this->id}[_{$this->prefix}_color]\"]" ) ); } public function test_controls_carry_the_stored_value_or_else_the_default(): void { @@ -254,16 +253,16 @@ public function test_a_custom_control_renders_with_its_value_name_and_id(): void $request = $this->edit_screen( $spec, $post_id ); $box = $this->box( self::document( $request['body'] ) ); - self::assertContains( array( 'render', "_{$this->prefix}_widget", 'stored', "{$this->id}[_{$this->prefix}_widget]", "{$this->id}._{$this->prefix}_widget" ), $request['records'] ); - self::assertNotNull( $box->querySelector( "label[for=\"{$this->id}._{$this->prefix}_widget\"]" ) ); - self::assertNotNull( $box->querySelector( "input.acme-widget[id=\"{$this->id}._{$this->prefix}_widget\"]" ) ); + self::assertContains( array( 'render', "_{$this->prefix}_widget", 'stored', "{$this->id}[_{$this->prefix}_widget]", "$this->id._{$this->prefix}_widget" ), $request['records'] ); + self::assertNotNull( $box->querySelector( "label[for=\"$this->id._{$this->prefix}_widget\"]" ) ); + self::assertNotNull( $box->querySelector( "input.acme-widget[id=\"$this->id._{$this->prefix}_widget\"]" ) ); } public function test_titles_print_as_text(): void { $post_id = $this->make_post(); $spec = $this->spec( array( $this->field( 'text', 'note', array( 'title' => 'Customer\'s note' ) ) ) ); - $label = $this->box( self::document( $this->edit_screen( $spec, $post_id )['body'] ) )->querySelector( "label[for=\"{$this->id}._{$this->prefix}_note\"]" ); + $label = $this->box( self::document( $this->edit_screen( $spec, $post_id )['body'] ) )->querySelector( "label[for=\"$this->id._{$this->prefix}_note\"]" ); self::assertSame( 'Customer\'s note', \trim( (string) $label?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertNull( $label?->querySelector( 'b' ) ); @@ -273,7 +272,7 @@ public function test_an_untitled_field_is_labelled_by_its_id(): void { $post_id = $this->make_post(); $spec = $this->spec( array( $this->field( 'text', 'name', array( 'title' => '' ) ) ) ); - $label = $this->box( self::document( $this->edit_screen( $spec, $post_id )['body'] ) )->querySelector( "label[for=\"{$this->id}._{$this->prefix}_name\"]" ); + $label = $this->box( self::document( $this->edit_screen( $spec, $post_id )['body'] ) )->querySelector( "label[for=\"$this->id._{$this->prefix}_name\"]" ); self::assertSame( "_{$this->prefix}_name", \trim( (string) $label?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. } @@ -677,7 +676,7 @@ public function test_a_rejection_in_the_block_editor_reaches_the_editor_after_th $saved = $this->block_editor_save( $spec, $post_id, array( $this->control_name( 'note' ) => 'too long' ) ); $redirected = $this->follow( $spec, $saved ); wp_set_current_user( $this->admin ); - $delivered = ( new AdminNotices( $this->id, new ErrorLogLogger( 'acme' ) ) )->rest_deliver(); + $delivered = new AdminNotices( $this->id, new ErrorLogLogger( 'acme' ) )->rest_deliver(); $list = $this->admin_request( 'GET', 'edit.php', $this->admin, self::FIXTURE, $spec ); self::assertSame( array( 'kept' ), $this->meta( $post_id, 'note' ) ); @@ -738,18 +737,6 @@ protected function spec( array $fields, array $form = array(), array $extra = ar ); } - protected function field( string $type, string $name, array $keys = array() ): array { - return $keys + array( - 'type' => $type, - 'id' => "_{$this->prefix}_$name", - 'title' => \ucfirst( $name ), - ); - } - - protected function control_name( string $name ): string { - return "{$this->id}[_{$this->prefix}_$name]"; - } - // The form posts as the Update button submits it, after the user set the named controls; tampering replaces posted keys and removes posted keys or a key of the form's array. protected function save( array $spec, int $post_id, array $changes = array(), array $tampered = array(), array $removed = array() ): array { $data = \array_replace_recursive( self::serialize_form( $this->edit_screen( $spec, $post_id )['body'], 'form#post', $changes ), $tampered ) + array( 'save' => 'Update' ); @@ -765,29 +752,10 @@ protected function save( array $spec, int $post_id, array $changes = array(), ar return $this->admin_request( 'POST', 'post.php', $this->admin, self::FIXTURE, $spec, $data ); } - protected function follow( array $spec, array $saved ): array { - self::assertIsString( $saved['location'] ); - - return $this->admin_request( 'GET', \explode( '/wp-admin/', $saved['location'], 2 )[1], $this->admin, self::FIXTURE, $spec ); - } - protected function meta( int $post_id, string $name ): array { return get_post_meta( $post_id, "_{$this->prefix}_$name", false ); } - protected function records( array $request, string $kind ): array { - return \array_values( \array_filter( $request['records'], static fn ( array $record ): bool => $kind === $record[0] ) ); - } - - protected static function rejections( string $html ): array { - $messages = array(); - foreach ( self::document( $html )->querySelectorAll( 'div.notice-error.is-dismissible > p' ) as $message ) { - $messages[] = \trim( $message->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - } - - return $messages; - } - // The block editor posts its meta box forms with the base form to the meta box loader's URL, as its requestMetaBoxUpdates() does. protected function block_editor_save( array $spec, int $post_id, array $changes = array() ): array { $body = $this->edit_screen( $spec, $post_id )['body']; @@ -857,29 +825,6 @@ protected function edit_screen( array $spec, int $post_id, ?int $user_id = null return $this->admin_request( 'GET', "post.php?post=$post_id&action=edit", $user_id ?? $this->admin, self::FIXTURE, $spec ); } - protected function box( \Dom\HTMLDocument $screen ): \Dom\Element { - $box = $screen->querySelector( "div.postbox[id=\"{$this->id}\"] div.inside" ); - self::assertNotNull( $box, "The screen printed no {$this->id} box." ); - - return $box; - } - - protected function control( \Dom\Element $box, string $name ): \Dom\Element { - $control = $box->querySelector( "[id=\"{$this->id}._{$this->prefix}_$name\"]" ); - self::assertNotNull( $control, "The box printed no _{$this->prefix}_$name." ); - - return $control; - } - - protected function selected( \Dom\Element $box, string $name ): array { - $values = array(); - foreach ( $this->control( $box, $name )->querySelectorAll( 'option[selected]' ) as $option ) { - $values[] = (string) $option->getAttribute( 'value' ); - } - - return $values; - } - #[After] protected function delete_posts(): void { foreach ( $this->post_ids as $post_id ) { diff --git a/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php b/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php index 9322e367..13aa3a7f 100644 --- a/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php +++ b/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php @@ -7,11 +7,10 @@ use DeepWebSolutions\Framework\Settings\EditScreen\Hosts\TermForm; use DeepWebSolutions\Framework\Settings\EditScreen\ObjectForm; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\InspectsObjectFormsTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\RecordedDefinition; -use DeepWebSolutions\Framework\Tests\Integration\Support\RunsAdminRequestsTrait; -use DeepWebSolutions\Framework\Tests\Integration\Support\SerializesFormsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -20,18 +19,9 @@ #[CoversClass( TermForm::class )] final class TermFormTest extends TestCase { use CreatesUsersTrait; + use InspectsObjectFormsTrait; use IsolatesHooksTrait; use IsolatesStorageTrait; - use RunsAdminRequestsTrait; - use SerializesFormsTrait; - - protected const string FIXTURE = __DIR__ . '/../../../../Fixtures/Settings/acme-object-forms.php'; - - protected const array COLORS = array( - 'red' => 'Red', - 'blue' => 'Blue', - 'teal' => 'Teal', - ); protected int $admin; protected int $editor; @@ -47,7 +37,7 @@ protected function setUp(): void { public function test_the_host_registers_on_each_of_its_taxonomies(): void { $host = new TermForm( array( 'category', 'post_tag' ) ); - ( new ObjectForm( $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) ) )->register_hooks(); + new ObjectForm( $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks(); foreach ( array( 'category', 'post_tag' ) as $taxonomy ) { self::assertSame( 10, has_action( "{$taxonomy}_edit_form", array( $host, 'render_section' ) ) ); @@ -66,7 +56,7 @@ public function test_the_form_shows_after_cores_fields_on_its_taxonomies_edit_sc $section = $edit->querySelector( "form#edittag > table.form-table ~ input[type=hidden][name=\"{$this->id}_nonce\"] + h2 + table.form-table" ); self::assertNotNull( $section ); self::assertSame( 'Acme term', \trim( (string) $section->previousElementSibling?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - self::assertSame( 'Name', \trim( (string) $section->querySelector( "tr > th[scope=row] > label[for=\"{$this->id}._{$this->prefix}_name\"]" )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertSame( 'Name', \trim( (string) $section->querySelector( "tr > th[scope=row] > label[for=\"$this->id._{$this->prefix}_name\"]" )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertSame( 'Color', \trim( (string) $section->querySelector( 'tr:nth-child(2) > td > fieldset > legend.screen-reader-text' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertCount( 1, $section->querySelectorAll( 'tr:nth-child(2) fieldset' ) ); self::assertNotNull( $other->querySelector( 'form#edittag' ) ); @@ -80,8 +70,8 @@ public function test_controls_carry_the_stored_value_or_else_the_default(): void $edit = self::document( $this->edit_screen( $spec, 'category', $term_id )['body'] ); - self::assertSame( 'stored', $edit->querySelector( "[id=\"{$this->id}._{$this->prefix}_name\"]" )?->getAttribute( 'value' ) ); - self::assertSame( 'fresh', $edit->querySelector( "[id=\"{$this->id}._{$this->prefix}_absent\"]" )?->getAttribute( 'value' ) ); + self::assertSame( 'stored', $edit->querySelector( "[id=\"$this->id._{$this->prefix}_name\"]" )?->getAttribute( 'value' ) ); + self::assertSame( 'fresh', $edit->querySelector( "[id=\"$this->id._{$this->prefix}_absent\"]" )?->getAttribute( 'value' ) ); } public function test_a_user_lacking_the_forms_capability_gets_no_section(): void { @@ -135,13 +125,13 @@ public function test_quick_edit_stores_nothing(): void { '_inline_edit' => (string) $list->querySelector( 'input#_inline_edit' )?->getAttribute( 'value' ), 'taxonomy' => 'category', 'tax_ID' => (string) $term_id, - 'name' => "{$this->prefix} quick", - 'slug' => "{$this->prefix}-quick", + 'name' => "$this->prefix quick", + 'slug' => "$this->prefix-quick", $this->id => array( "_{$this->prefix}_name" => 'quick' ), ) ); - self::assertSame( "{$this->prefix} quick", get_term( $term_id )->name ); + self::assertSame( "$this->prefix quick", get_term( $term_id )->name ); self::assertSame( array( 'kept' ), $this->meta( $term_id, 'name' ) ); } @@ -165,13 +155,13 @@ public function test_an_update_in_code_stores_nothing(): void { $_POST[ $this->id ] = array( "_{$this->prefix}_name" => 'programmatic' ); try { - wp_update_term( $term_id, 'category', array( 'name' => "{$this->prefix} in code" ) ); + wp_update_term( $term_id, 'category', array( 'name' => "$this->prefix in code" ) ); } finally { unset( $_POST[ $this->id ] ); wp_set_current_user( 0 ); } - self::assertSame( "{$this->prefix} in code", get_term( $term_id )->name ); + self::assertSame( "$this->prefix in code", get_term( $term_id )->name ); self::assertSame( array( 'kept' ), $this->meta( $term_id, 'name' ) ); } @@ -188,20 +178,8 @@ protected function spec( array $fields, array $form = array() ): array { ); } - protected function field( string $type, string $name, array $keys = array() ): array { - return $keys + array( - 'type' => $type, - 'id' => "_{$this->prefix}_$name", - 'title' => \ucfirst( $name ), - ); - } - - protected function control_name( string $name ): string { - return "{$this->id}[_{$this->prefix}_$name]"; - } - protected function make_term( string $taxonomy ): int { - $term = wp_insert_term( "{$this->prefix} " . \count( $this->terms ), $taxonomy ); + $term = wp_insert_term( "$this->prefix " . \count( $this->terms ), $taxonomy ); self::assertIsArray( $term ); $this->terms[] = array( (int) $term['term_id'], $taxonomy ); @@ -216,15 +194,6 @@ protected function meta( int $term_id, string $name ): array { return get_term_meta( $term_id, "_{$this->prefix}_$name", false ); } - protected static function rejections( string $html ): array { - $messages = array(); - foreach ( self::document( $html )->querySelectorAll( 'div.notice-error.is-dismissible > p' ) as $message ) { - $messages[] = \trim( $message->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - } - - return $messages; - } - #[After] protected function delete_terms(): void { foreach ( $this->terms as list( $term_id, $taxonomy ) ) { diff --git a/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php b/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php index 942b43ee..6d699d68 100644 --- a/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php +++ b/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php @@ -7,11 +7,10 @@ use DeepWebSolutions\Framework\Settings\EditScreen\Hosts\UserProfile; use DeepWebSolutions\Framework\Settings\EditScreen\ObjectForm; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\InspectsObjectFormsTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\RecordedDefinition; -use DeepWebSolutions\Framework\Tests\Integration\Support\RunsAdminRequestsTrait; -use DeepWebSolutions\Framework\Tests\Integration\Support\SerializesFormsTrait; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -21,18 +20,9 @@ #[CoversClass( UserProfile::class )] final class UserProfileTest extends TestCase { use CreatesUsersTrait; + use InspectsObjectFormsTrait; use IsolatesHooksTrait; use IsolatesStorageTrait; - use RunsAdminRequestsTrait; - use SerializesFormsTrait; - - protected const string FIXTURE = __DIR__ . '/../../../../Fixtures/Settings/acme-object-forms.php'; - - protected const array COLORS = array( - 'red' => 'Red', - 'blue' => 'Blue', - 'teal' => 'Teal', - ); protected int $admin; protected int $subscriber; @@ -61,7 +51,7 @@ public static function multisite_profiles(): array { public function test_the_host_registers_on_both_profile_screens(): void { $host = new UserProfile(); - ( new ObjectForm( $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) ) )->register_hooks(); + new ObjectForm( $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks(); self::assertSame( 10, has_action( 'show_user_profile', array( $host, 'render_section' ) ) ); self::assertSame( 10, has_action( 'edit_user_profile', array( $host, 'render_section' ) ) ); @@ -78,14 +68,24 @@ public function test_the_form_shows_as_a_titled_section_of_labelled_rows( string $section = $form?->querySelector( "input[type=hidden][name=\"{$this->id}_nonce\"] ~ h2 + table.form-table" ); self::assertNotNull( $section ); self::assertSame( 'Acme profile', \trim( (string) $section->previousElementSibling?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - self::assertSame( 'Name', \trim( (string) $section->querySelector( "tr > th[scope=row] > label[for=\"{$this->id}._{$this->prefix}_name\"]" )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - self::assertNotNull( $section->querySelector( "tr > td > input[id=\"{$this->id}._{$this->prefix}_name\"][name=\"{$this->id}[_{$this->prefix}_name]\"]" ) ); + self::assertSame( 'Name', \trim( (string) $section->querySelector( "tr > th[scope=row] > label[for=\"$this->id._{$this->prefix}_name\"]" )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertNotNull( $section->querySelector( "tr > td > input[id=\"$this->id._{$this->prefix}_name\"][name=\"{$this->id}[_{$this->prefix}_name]\"]" ) ); self::assertSame( 'Color', \trim( (string) $section->querySelector( 'tr:nth-child(2) > th[scope=row]' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertSame( 'Color', \trim( (string) $section->querySelector( 'tr:nth-child(2) > td > fieldset > legend.screen-reader-text' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertCount( 1, $section->querySelectorAll( 'tr:nth-child(2) legend' ) ); self::assertCount( 1, $section->querySelectorAll( 'tr:nth-child(2) fieldset' ) ); } + public function test_a_label_points_at_its_control_when_the_field_id_holds_an_entity(): void { + $spec = $this->spec( array( $this->field( 'text', 'r&d' ) ) ); + + $section = self::document( $this->open( $spec, 'own' )['body'] )->querySelector( "form#your-profile input[name=\"{$this->id}_nonce\"] ~ h2 + table.form-table" ); + + $id = (string) $section?->querySelector( 'td > input[type=text]' )?->getAttribute( 'id' ); + self::assertSame( "$this->id._{$this->prefix}_r&d", $id ); + self::assertSame( $id, $section?->querySelector( 'th > label' )?->getAttribute( 'for' ) ); + } + public function test_controls_carry_the_stored_value_or_else_the_default(): void { add_user_meta( $this->subscriber, "_{$this->prefix}_name", 'stored' ); add_user_meta( $this->subscriber, "_{$this->prefix}_empty", '' ); @@ -97,8 +97,9 @@ public function test_controls_carry_the_stored_value_or_else_the_default(): void ) ); - $form = self::document( $this->open( $spec, 'other' )['body'] ); + $form = self::document( $this->open( $spec, 'other' )['body'] )->querySelector( 'form#your-profile' ); + self::assertNotNull( $form ); self::assertSame( 'stored', $this->control( $form, 'name' )->getAttribute( 'value' ) ); self::assertSame( '', $this->control( $form, 'empty' )->getAttribute( 'value' ) ); self::assertSame( 'fresh', $this->control( $form, 'absent' )->getAttribute( 'value' ) ); @@ -247,50 +248,16 @@ protected function spec( array $fields, array $form = array() ): array { ); } - protected function field( string $type, string $name, array $keys = array() ): array { - return $keys + array( - 'type' => $type, - 'id' => "_{$this->prefix}_$name", - 'title' => \ucfirst( $name ), - ); - } - - protected function control_name( string $name ): string { - return "{$this->id}[_{$this->prefix}_$name]"; - } - // The administrator opens their own profile, or the subscriber's. protected function open( array $spec, string $screen ): array { - return $this->admin_request( 'GET', 'own' === $screen ? 'profile.php' : "user-edit.php?user_id={$this->subscriber}", $this->admin, self::FIXTURE, $spec ); + return $this->admin_request( 'GET', 'own' === $screen ? 'profile.php' : "user-edit.php?user_id=$this->subscriber", $this->admin, self::FIXTURE, $spec ); } protected function save( array $spec, string $screen, array $changes ): array { return $this->admin_request( 'POST', 'own' === $screen ? 'profile.php' : 'user-edit.php', $this->admin, self::FIXTURE, $spec, self::serialize_form( $this->open( $spec, $screen )['body'], 'form#your-profile', $changes ) ); } - protected function follow( array $spec, array $saved ): array { - self::assertIsString( $saved['location'] ); - - return $this->admin_request( 'GET', \explode( '/wp-admin/', $saved['location'], 2 )[1], $this->admin, self::FIXTURE, $spec ); - } - protected function meta( int $user_id, string $name ): array { return get_user_meta( $user_id, "_{$this->prefix}_$name", false ); } - - protected function control( \Dom\HTMLDocument $screen, string $name ): \Dom\Element { - $control = $screen->querySelector( "form#your-profile [id=\"{$this->id}._{$this->prefix}_$name\"]" ); - self::assertNotNull( $control, "The profile printed no _{$this->prefix}_$name." ); - - return $control; - } - - protected static function rejections( string $html ): array { - $messages = array(); - foreach ( self::document( $html )->querySelectorAll( 'div.notice-error.is-dismissible > p' ) as $message ) { - $messages[] = \trim( $message->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - } - - return $messages; - } } diff --git a/tests/Integration/Settings/EditScreen/ObjectFormTest.php b/tests/Integration/Settings/EditScreen/ObjectFormTest.php index f9c914fe..9a9e1c39 100644 --- a/tests/Integration/Settings/EditScreen/ObjectFormTest.php +++ b/tests/Integration/Settings/EditScreen/ObjectFormTest.php @@ -210,21 +210,25 @@ public function test_a_host_serves_one_form(): void { $this->expectException( LogicException::class ); - ( new ObjectForm( "{$this->prefix}_other", new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) ) )->register_hooks(); + new ObjectForm( "{$this->prefix}_other", new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks(); } public function test_a_host_the_consumer_writes_serves_one_form_too(): void { $host = new class() implements ObjectFormHostInterface { + #[\Override] public function register_hooks( ObjectForm $form ): void {} + #[\Override] public function get_object_capability(): string { return 'edit_post'; } + #[\Override] public function get_default_store(): ObjectStoreInterface { return new MetadataStore( 'post' ); } + #[\Override] public function render_rows( string $title, array $rows ): void {} }; $form = $this->form( $host ); @@ -233,7 +237,7 @@ public function render_rows( string $title, array $rows ): void {} $this->expectException( LogicException::class ); - ( new ObjectForm( "{$this->prefix}_other", new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) ) )->register_hooks(); + new ObjectForm( "{$this->prefix}_other", new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks(); } protected function form( ObjectFormHostInterface $host, string $capability = 'edit_posts' ): ObjectForm { diff --git a/tests/Integration/Settings/EditScreen/Stores/MetadataStoreTest.php b/tests/Integration/Settings/EditScreen/Stores/MetadataStoreTest.php index 95492de0..807b1ce3 100644 --- a/tests/Integration/Settings/EditScreen/Stores/MetadataStoreTest.php +++ b/tests/Integration/Settings/EditScreen/Stores/MetadataStoreTest.php @@ -34,7 +34,7 @@ public function test_only_the_keys_the_object_has_are_returned_with_their_stored add_metadata( $meta_type, $object_id, "_{$this->prefix}_empty", '' ); add_metadata( $meta_type, $object_id, "_{$this->prefix}_list", array() ); - $values = ( new MetadataStore( $meta_type ) )->get_values( $object_id, array( "_{$this->prefix}_text", "_{$this->prefix}_empty", "_{$this->prefix}_list", "_{$this->prefix}_absent" ) ); + $values = new MetadataStore( $meta_type )->get_values( $object_id, array( "_{$this->prefix}_text", "_{$this->prefix}_empty", "_{$this->prefix}_list", "_{$this->prefix}_absent" ) ); self::assertSame( array( @@ -55,7 +55,7 @@ public function test_a_null_row_reads_as_absent_with_or_without_a_registered_def update_metadata( $meta_type, $object_id, "_{$this->prefix}_registered", null ); update_metadata( $meta_type, $object_id, "_{$this->prefix}_plain", null ); - self::assertSame( array(), ( new MetadataStore( $meta_type ) )->get_values( $object_id, array( "_{$this->prefix}_registered", "_{$this->prefix}_plain" ) ) ); + self::assertSame( array(), new MetadataStore( $meta_type )->get_values( $object_id, array( "_{$this->prefix}_registered", "_{$this->prefix}_plain" ) ) ); } finally { unregister_meta_key( $meta_type, "_{$this->prefix}_registered" ); } @@ -66,7 +66,7 @@ public function test_values_are_stored_as_given_backslashes_included( string $me $object_id = $this->make_object( $meta_type ); add_metadata( $meta_type, $object_id, "_{$this->prefix}_text", 'old' ); - ( new MetadataStore( $meta_type ) )->update_values( + new MetadataStore( $meta_type )->update_values( $object_id, array( "_{$this->prefix}_text" => 'O\'Brien C:\\dir', @@ -90,7 +90,7 @@ public function test_a_value_holding_anything_but_scalars_null_and_arrays_throws $object_id = $this->make_object( 'post' ); try { - ( new MetadataStore( 'post' ) )->update_values( + new MetadataStore( 'post' )->update_values( $object_id, array( "_{$this->prefix}_first" => 'Written first.', @@ -137,7 +137,7 @@ protected function make_object( string $meta_type ): int { ) ), 'user' => $this->make_user( "{$this->prefix}_user", 'subscriber' ), - 'term' => $this->term_ids[] = (int) wp_insert_term( "{$this->prefix} term", 'category' )['term_id'], + 'term' => $this->term_ids[] = (int) wp_insert_term( "$this->prefix term", 'category' )['term_id'], }; } diff --git a/tests/Integration/Settings/FormsCheckHelperIdTest.php b/tests/Integration/Settings/FormsCheckHelperIdTest.php index e283fccf..e665bb83 100644 --- a/tests/Integration/Settings/FormsCheckHelperIdTest.php +++ b/tests/Integration/Settings/FormsCheckHelperIdTest.php @@ -23,8 +23,8 @@ public function test_an_invalid_id_is_rejected_by_name( string $id ): void { #[DataProvider( 'v1_helper_ids' )] public function test_every_v1_key_is_a_valid_id( string $id ): void { - Forms::check_helper_id( $id ); + $this->expectNotToPerformAssertions(); - $this->addToAssertionCount( 1 ); + Forms::check_helper_id( $id ); } } diff --git a/tests/Integration/Settings/FormsRenderControlTest.php b/tests/Integration/Settings/FormsRenderControlTest.php index ff335540..009da9d1 100644 --- a/tests/Integration/Settings/FormsRenderControlTest.php +++ b/tests/Integration/Settings/FormsRenderControlTest.php @@ -35,6 +35,7 @@ public function test_labels_and_help_point_at_ids_holding_entities(): void { $radio = self::render( 'radio', array( + 'title' => 'Mode', 'desc' => 'Help.', 'options' => array( 'a' => 'A' ), ), @@ -82,14 +83,42 @@ public function test_each_input_type_renders_as_that_html_input_type( string $ty self::assertSame( $type, self::render( $type )->getElementById( 'acme.key' )->getAttribute( 'type' ) ); } - public function test_help_text_is_linked_from_the_control_by_aria_describedby(): void { - $document = self::render( 'text', array( 'desc' => 'Shown below.' ) ); + public function test_help_text_is_linked_from_the_control_by_aria_describedby_and_styled_as_a_description(): void { + $document = self::render( 'text', array( 'desc' => 'Shown below.' ) ); self::assertSame( 'acme.key.description', $document->getElementById( 'acme.key' )->getAttribute( 'aria-describedby' ) ); self::assertTrue( $document->getElementById( 'acme.key.description' )->matches( 'p.description' ) ); self::assertSame( 'Shown below.', self::text( $document->getElementById( 'acme.key.description' ) ) ); } + public function test_help_holding_paragraphs_and_lists_stays_inside_the_element_the_control_names(): void { + $document = self::render( 'text', array( 'desc' => '

First.

  • Second.
' ) ); + + $help = $document->getElementById( 'acme.key.description' ); + self::assertTrue( $help?->matches( 'div.description' ) ); + self::assertNotNull( $help?->querySelector( 'p' ) ); + self::assertNotNull( $help?->querySelector( 'ul > li' ) ); + self::assertSame( 'First.Second.', self::text( $help ) ); + } + + public static function paragraph_closing_help(): array { + return array( + 'fieldset' => array( '
Read this first.
' ), + 'hgroup' => array( '
Read this first.
' ), + 'main' => array( '
Read this first.
' ), + 'menu' => array( '
  • Read this first.
  • ' ), + 'search' => array( 'Read this first.' ), + ); + } + + #[DataProvider( 'paragraph_closing_help' )] + public function test_help_holding_an_element_that_ends_a_paragraph_stays_inside_the_element_the_control_names( string $desc ): void { + $help = self::render( 'text', array( 'desc' => $desc ) )->getElementById( 'acme.key.description' ); + + self::assertTrue( $help?->matches( 'div.description' ) ); + self::assertSame( 'Read this first.', self::text( $help ) ); + } + public static function non_input_controls(): array { return array( 'textarea' => array( 'textarea', array(), 'textarea' ), @@ -129,7 +158,7 @@ public function test_a_desc_tip_of_true_is_ignored(): void { ); self::assertSame( array( 'Description.' ), self::descriptions( $document, $document->getElementById( 'acme.key' ) ) ); - self::assertCount( 1, $document->querySelectorAll( 'p.description' ) ); + self::assertCount( 1, $document->querySelectorAll( '.description' ) ); } public function test_a_control_without_help_has_no_aria_describedby(): void { @@ -302,6 +331,7 @@ public function test_a_group_links_its_help_from_its_fieldset(): void { $document = self::render( 'checkboxes', array( + 'title' => 'Picks', 'desc' => 'Pick any.', 'options' => array( 'a' => 'A' ), ) @@ -311,6 +341,34 @@ public function test_a_group_links_its_help_from_its_fieldset(): void { self::assertFalse( $document->getElementById( 'acme.key.0' )->hasAttribute( 'aria-describedby' ) ); } + public static function group_fields(): array { + return array( + 'radio' => array( 'radio' ), + 'checkboxes' => array( 'checkboxes' ), + ); + } + + // A host that names the group in its own fieldset passes no title. + #[DataProvider( 'group_fields' )] + public function test_a_group_without_a_title_prints_no_fieldset_and_each_choice_links_its_help( string $type ): void { + $document = self::render( + $type, + array( + 'desc' => 'Pick.', + 'options' => array( + 'a' => 'A', + 'b' => 'B', + ), + ) + ); + + self::assertNull( $document->querySelector( 'fieldset' ) ); + self::assertCount( 2, self::choices( $document ) ); + foreach ( self::choices( $document ) as $choice ) { + self::assertSame( array( 'Pick.' ), self::descriptions( $document, $choice ) ); + } + } + public static function disabled_fields(): array { return array( 'checkbox-top-level' => array( 'checkbox', array( 'disabled' => true ) ), @@ -451,7 +509,7 @@ public function test_a_placeholder_prints_only_on_text_like_controls( string $ty ) ); - self::assertSame( $printed, null !== $document->querySelector( '[placeholder]' ) ); + self::assertSame( $printed, ! \is_null( $document->querySelector( '[placeholder]' ) ) ); } public static function sizing_classes(): array { @@ -512,7 +570,7 @@ protected static function descriptions( \Dom\HTMLDocument $document, \Dom\Elemen } protected static function choices( \Dom\HTMLDocument $document ): array { - return \iterator_to_array( $document->querySelectorAll( 'fieldset input:not([type="hidden"])' ) ); + return \iterator_to_array( $document->querySelectorAll( 'input:not([type="hidden"])' ) ); } protected static function post_pair( \Dom\Element $input ): array { diff --git a/tests/Integration/Support/CreatesSitesTrait.php b/tests/Integration/Support/CreatesSitesTrait.php index a1869371..83861a01 100644 --- a/tests/Integration/Support/CreatesSitesTrait.php +++ b/tests/Integration/Support/CreatesSitesTrait.php @@ -2,6 +2,8 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Support; +use PHPUnit\Framework\Attributes\After; + trait CreatesSitesTrait { protected function make_site( string $slug ): int { $existing = get_sites( @@ -24,4 +26,11 @@ protected function make_site( string $slug ): int { return $site_id; } + + #[After] + protected function restore_switched_sites(): void { + while ( is_multisite() && ms_is_switched() ) { + restore_current_blog(); + } + } } diff --git a/tests/Integration/Support/DispatchesScheduledHooksTrait.php b/tests/Integration/Support/DispatchesScheduledHooksTrait.php new file mode 100644 index 00000000..b3e81395 --- /dev/null +++ b/tests/Integration/Support/DispatchesScheduledHooksTrait.php @@ -0,0 +1,39 @@ +stake_claim( 10, \is_null( $at ) ? null : new \DateTime( "@$at" ), array( $hook ) ); + foreach ( $claim->get_actions() as $action_id ) { + \ActionScheduler::runner()->process_action( (int) $action_id, 'Tests' ); + } + + $store->release_claim( $claim ); + + return \count( $claim->get_actions() ); + } + + // Mirrors one wp-cron.php run, which reschedules and runs the hook's events due by the given time or now, and returns each reschedule's result. + protected static function run_due_cron_events( string $hook, ?int $at = null ): array { + $rescheduled = array(); + foreach ( _get_cron_array() as $timestamp => $hooks ) { + if ( $timestamp > ( $at ?? \time() ) ) { + break; + } + + foreach ( $hooks[ $hook ] ?? array() as $event ) { + if ( false !== $event['schedule'] ) { + $rescheduled[] = wp_reschedule_event( $timestamp, $event['schedule'], $hook, $event['args'], true ); + } + + wp_unschedule_event( $timestamp, $hook, $event['args'], true ); + do_action_ref_array( $hook, $event['args'] ); + } + } + + return $rescheduled; + } +} diff --git a/tests/Integration/Support/InspectsObjectFormsTrait.php b/tests/Integration/Support/InspectsObjectFormsTrait.php new file mode 100644 index 00000000..8e9f41dc --- /dev/null +++ b/tests/Integration/Support/InspectsObjectFormsTrait.php @@ -0,0 +1,72 @@ +id to the form's id and $this->admin to the user its requests run as. +trait InspectsObjectFormsTrait { + use RunsAdminRequestsTrait; + use SerializesFormsTrait; + + protected const string FIXTURE = __DIR__ . '/../../Fixtures/Settings/acme-object-forms.php'; + + protected const array COLORS = array( + 'red' => 'Red', + 'blue' => 'Blue', + 'teal' => 'Teal', + ); + + protected function field( string $type, string $name, array $keys = array() ): array { + return $keys + array( + 'type' => $type, + 'id' => "_{$this->prefix}_$name", + 'title' => \ucfirst( $name ), + ); + } + + protected function control_name( string $name ): string { + return "{$this->id}[_{$this->prefix}_$name]"; + } + + protected function follow( array $spec, array $saved ): array { + self::assertIsString( $saved['location'] ); + + return $this->admin_request( 'GET', \explode( '/wp-admin/', $saved['location'], 2 )[1], $this->admin, self::FIXTURE, $spec ); + } + + protected function records( array $request, string $kind ): array { + return \array_values( \array_filter( $request['records'], static fn ( array $record ): bool => $kind === $record[0] ) ); + } + + protected static function rejections( string $html ): array { + $messages = array(); + foreach ( self::document( $html )->querySelectorAll( 'div.notice-error.is-dismissible > p' ) as $message ) { + $messages[] = \trim( $message->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + } + + return $messages; + } + + protected function box( \Dom\HTMLDocument $screen ): \Dom\Element { + $box = $screen->querySelector( "div.postbox[id=\"$this->id\"] div.inside" ); + self::assertNotNull( $box, "The screen printed no $this->id box." ); + + return $box; + } + + // The scope is the screen's element that holds the form, such as a meta box's body or the profile's form. + protected function control( \Dom\ParentNode $scope, string $name ): \Dom\Element { + $control = $scope->querySelector( "[id=\"$this->id._{$this->prefix}_$name\"]" ); + self::assertNotNull( $control, "The form printed no _{$this->prefix}_$name." ); + + return $control; + } + + protected function selected( \Dom\ParentNode $scope, string $name ): array { + $values = array(); + foreach ( $this->control( $scope, $name )->querySelectorAll( 'option[selected]' ) as $option ) { + $values[] = (string) $option->getAttribute( 'value' ); + } + + return $values; + } +} diff --git a/tests/Integration/Support/InstallsConsumersTrait.php b/tests/Integration/Support/InstallsConsumersTrait.php index 470b707c..c79ccec0 100644 --- a/tests/Integration/Support/InstallsConsumersTrait.php +++ b/tests/Integration/Support/InstallsConsumersTrait.php @@ -7,6 +7,7 @@ trait InstallsConsumersTrait { use CopiesFixturesTrait; + use IsolatesStorageTrait; use RunsAdminRequestsTrait; protected array $consumer_copies = array(); @@ -49,10 +50,8 @@ protected function remove_consumers(): void { } $this->plugin_options = array(); - foreach ( is_multisite() ? get_sites( array( 'fields' => 'ids' ) ) : array( 1 ) as $site_id ) { - foreach ( array( 'acme\_ca\_', 'acme\_cb\_', 'acme\_pc\_', 'acme\_ps\_', 'acme\_pw\_' ) as $prefix ) { - $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s', $wpdb->get_blog_prefix( $site_id ) . 'options', $prefix . '%', '\_transient\_' . $prefix . '%', '\_transient\_timeout\_' . $prefix . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows the consumers stored. - } + foreach ( array( 'acme_ca_', 'acme_cb_', 'acme_pc_', 'acme_ps_', 'acme_pw_' ) as $prefix ) { + self::delete_prefixed_options( $prefix ); } $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE meta_key LIKE %s OR meta_key LIKE %s', $wpdb->postmeta, '\_acme\_ca\_%', '\_acme\_pw\_%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows consumer-a and the woocommerce persona stored. wp_clean_plugins_cache(); diff --git a/tests/Integration/Support/IsolatesStorageTrait.php b/tests/Integration/Support/IsolatesStorageTrait.php index d9adc2cd..7721952f 100644 --- a/tests/Integration/Support/IsolatesStorageTrait.php +++ b/tests/Integration/Support/IsolatesStorageTrait.php @@ -15,15 +15,21 @@ protected function use_a_fresh_prefix(): void { #[After] protected function delete_stored_rows(): void { + self::delete_prefixed_options( $this->prefix ); + wp_cache_flush(); + } + + // Deletes every site's options and transients, the user options and the network options whose names hold the prefix. + protected static function delete_prefixed_options( string $prefix ): void { global $wpdb; foreach ( is_multisite() ? get_sites( array( 'fields' => 'ids' ) ) : array( 1 ) as $site_id ) { - $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s', $wpdb->get_blog_prefix( $site_id ) . 'options', $wpdb->esc_like( $this->prefix ) . '%', $wpdb->esc_like( "_transient_$this->prefix" ) . '%', $wpdb->esc_like( "_transient_timeout_$this->prefix" ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows the test created. + $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s', $wpdb->get_blog_prefix( $site_id ) . 'options', $wpdb->esc_like( $prefix ) . '%', $wpdb->esc_like( "_transient_$prefix" ) . '%', $wpdb->esc_like( "_transient_timeout_$prefix" ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows the test created. } - $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE meta_key LIKE %s', $wpdb->usermeta, '%' . $wpdb->esc_like( $this->prefix ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows the test created. + + $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE meta_key LIKE %s', $wpdb->usermeta, '%' . $wpdb->esc_like( $prefix ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows the test created. if ( is_multisite() ) { - $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE meta_key LIKE %s', $wpdb->sitemeta, $wpdb->esc_like( $this->prefix ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows the test created. + $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE meta_key LIKE %s', $wpdb->sitemeta, $wpdb->esc_like( $prefix ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows the test created. } - wp_cache_flush(); } } diff --git a/tests/Integration/WooCommerce/ProductData/ProductFixturesTrait.php b/tests/Integration/Support/ProductFixturesTrait.php similarity index 90% rename from tests/Integration/WooCommerce/ProductData/ProductFixturesTrait.php rename to tests/Integration/Support/ProductFixturesTrait.php index 7f506a14..c991c99a 100644 --- a/tests/Integration/WooCommerce/ProductData/ProductFixturesTrait.php +++ b/tests/Integration/Support/ProductFixturesTrait.php @@ -1,6 +1,6 @@ $value ) { $product->update_meta_data( $key, $value ); } + $product->save(); $this->product_ids[] = $product->get_id(); diff --git a/tests/Integration/Support/ProvidesLogMessagesTrait.php b/tests/Integration/Support/ProvidesLogMessagesTrait.php index 10681d93..d7fd6118 100644 --- a/tests/Integration/Support/ProvidesLogMessagesTrait.php +++ b/tests/Integration/Support/ProvidesLogMessagesTrait.php @@ -5,16 +5,19 @@ trait ProvidesLogMessagesTrait { public static function log_messages(): array { $stringable = new class() implements \Stringable { + #[\Override] public function __toString(): string { return 'stringable'; } }; $unprintable = new class() implements \Stringable { + #[\Override] public function __toString(): string { throw new \LogicException( 'Not printable.' ); } }; $erroring = new class() implements \Stringable { + #[\Override] public function __toString(): string { throw new \TypeError( 'Not a string.' ); } diff --git a/tests/Integration/Support/RecordedDefinition.php b/tests/Integration/Support/RecordedDefinition.php index 041667d5..e1726d53 100644 --- a/tests/Integration/Support/RecordedDefinition.php +++ b/tests/Integration/Support/RecordedDefinition.php @@ -4,6 +4,7 @@ use DeepWebSolutions\Framework\Settings\AdminPage\OptionsPageDefinitionInterface; use DeepWebSolutions\Framework\Settings\EditScreen\ObjectFormDefinitionInterface; +use DeepWebSolutions\Framework\Settings\Forms; use DeepWebSolutions\Framework\WooCommerce\ProductData\ProductDataTabDefinitionInterface; use DeepWebSolutions\Framework\WooCommerce\Setting\SectionFieldsDefinitionInterface; @@ -34,6 +35,37 @@ public function get_product_data_tab( string $tab_id, \WC_Product $product ): ?a return $this->record( $tab_id, $product ); } + // Replaces each field's acme: validate and render names with callables that record what they receive in the request's outcome. + public static function with_behaviors( array $fields ): array { + foreach ( $fields as &$field ) { + $field_id = $field['id'] ?? ''; + if ( 'acme:render' === ( $field['render'] ?? null ) ) { + $field['render'] = static function ( array $field, mixed $value, string $name, string $id ) use ( $field_id ): string { + $GLOBALS['acme_outcome']['records'][] = array( 'render', $field_id, $value, $name, $id ); + + return ''; + }; + } + + $behavior = $field['validate'] ?? null; + if ( \is_string( $behavior ) && \str_starts_with( $behavior, 'acme:' ) ) { + $field['validate'] = static function ( mixed $raw ) use ( $behavior, $field_id ): mixed { + $GLOBALS['acme_outcome']['records'][] = array( 'validate', $field_id, $raw ); + + return match ( $behavior ) { + 'acme:append' => $raw . '!', + 'acme:uppercase' => \strtoupper( $raw ), + 'acme:reject' => new \WP_Error( 'acme_rejected', 'Use at most four characters.' ), + 'acme:reject-silently' => new \WP_Error( 'acme_rejected' ), + 'acme:record' => $raw, + }; + }; + } + } + + return $fields; + } + protected function record( mixed ...$arguments ): mixed { $this->calls[] = $arguments; diff --git a/tests/Integration/Support/RecordingLogger.php b/tests/Integration/Support/RecordingLogger.php new file mode 100644 index 00000000..077e26df --- /dev/null +++ b/tests/Integration/Support/RecordingLogger.php @@ -0,0 +1,14 @@ +records[] = array( $level, (string) $message, $context ); + } +} diff --git a/tests/Integration/Support/RecordsDoingItWrongTrait.php b/tests/Integration/Support/RecordsDoingItWrongTrait.php new file mode 100644 index 00000000..ab057baf --- /dev/null +++ b/tests/Integration/Support/RecordsDoingItWrongTrait.php @@ -0,0 +1,18 @@ +doing_it_wrong[] = $function_name; + } + ); + } +} diff --git a/tests/Integration/Support/RendersAdminScreensTrait.php b/tests/Integration/Support/RendersAdminScreensTrait.php index 8a183a29..ec02f4b2 100644 --- a/tests/Integration/Support/RendersAdminScreensTrait.php +++ b/tests/Integration/Support/RendersAdminScreensTrait.php @@ -14,6 +14,14 @@ protected function view_as( int $user_id, string $screen_id = 'dashboard', bool get_current_screen()?->is_block_editor( $block_editor ); } + protected function render_all_admin_notices( int $user_id ): string { + wp_set_current_user( $user_id ); + \ob_start(); + do_action( 'all_admin_notices' ); + + return (string) \ob_get_clean(); + } + protected static function notices_in( string $html ): array { $notices = array(); $tags = new \WP_HTML_Tag_Processor( $html ); diff --git a/tests/Integration/Support/ReportsActionSchedulerVersionsTrait.php b/tests/Integration/Support/ReportsActionSchedulerVersionsTrait.php index 7d631572..79305af9 100644 --- a/tests/Integration/Support/ReportsActionSchedulerVersionsTrait.php +++ b/tests/Integration/Support/ReportsActionSchedulerVersionsTrait.php @@ -17,7 +17,7 @@ protected function report_action_scheduler_versions( array $versions ): void { #[After] protected function restore_action_scheduler_versions(): void { if ( ! \is_null( $this->registered_versions ) ) { - ( new \ReflectionProperty( \ActionScheduler_Versions::class, 'versions' ) )->setValue( \ActionScheduler_Versions::instance(), $this->registered_versions ); + new \ReflectionProperty( \ActionScheduler_Versions::class, 'versions' )->setValue( \ActionScheduler_Versions::instance(), $this->registered_versions ); $this->registered_versions = null; } } diff --git a/tests/Integration/Support/RunsAdminRequestsTrait.php b/tests/Integration/Support/RunsAdminRequestsTrait.php index e5e84761..64c92937 100644 --- a/tests/Integration/Support/RunsAdminRequestsTrait.php +++ b/tests/Integration/Support/RunsAdminRequestsTrait.php @@ -2,9 +2,12 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Support; +use DeepWebSolutions\Framework\Tests\Support\RunsProcessesTrait; use PHPUnit\Framework\Attributes\After; trait RunsAdminRequestsTrait { + use RunsProcessesTrait; + protected array $session_cookies = array(); protected array $session_tokens = array(); @@ -26,11 +29,10 @@ protected function admin_request( string $method, string $path, int $user_id, st '-dopcache.record_warnings=1', __DIR__ . '/admin-request.php', ); - $process = \proc_open( $command, array( array( 'pipe', 'r' ), array( 'pipe', 'w' ), array( 'pipe', 'w' ) ), $pipes ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_proc_open -- Runs the request in its own process. - self::assertIsResource( $process ); - \fwrite( - $pipes[0], - \serialize( // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- The request process reads it. + + list( , $body, $stderr ) = self::run_process( + $command, + input: \serialize( // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- The request process reads it. array( 'method' => $method, 'host' => (string) \parse_url( home_url(), \PHP_URL_HOST ) . ( \is_int( \parse_url( home_url(), \PHP_URL_PORT ) ) ? ':' . \parse_url( home_url(), \PHP_URL_PORT ) : '' ), @@ -45,12 +47,6 @@ protected function admin_request( string $method, string $path, int $user_id, st ) ) ); - \fclose( $pipes[0] ); - $body = (string) \stream_get_contents( $pipes[1] ); - $stderr = (string) \stream_get_contents( $pipes[2] ); - \fclose( $pipes[1] ); - \fclose( $pipes[2] ); - \proc_close( $process ); $outcome = \unserialize( (string) \file_get_contents( $result ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- The request process wrote it. \unlink( $result ); diff --git a/tests/Integration/Support/ThrowingLogger.php b/tests/Integration/Support/ThrowingLogger.php new file mode 100644 index 00000000..0104f6a2 --- /dev/null +++ b/tests/Integration/Support/ThrowingLogger.php @@ -0,0 +1,16 @@ +failure; + } +} diff --git a/tests/Integration/Support/WritesPluginFilesTrait.php b/tests/Integration/Support/WritesPluginFilesTrait.php new file mode 100644 index 00000000..e8c15e05 --- /dev/null +++ b/tests/Integration/Support/WritesPluginFilesTrait.php @@ -0,0 +1,40 @@ + $value ) { + if ( '' !== $value ) { + $lines[] = " * $name: $value"; + } + } + + $plugin_file = "$root/$slug/$slug.php"; + wp_mkdir_p( "$root/$slug" ); + \file_put_contents( $plugin_file, "plugin_files, true ) ) { + $this->plugin_files[] = $plugin_file; + } + + return $plugin_file; + } + + #[After] + protected function remove_plugin_files(): void { + foreach ( $this->plugin_files as $plugin_file ) { + self::remove_tree( \dirname( $plugin_file ) ); + } + + $this->plugin_files = array(); + } +} diff --git a/tests/Integration/Support/admin-request.php b/tests/Integration/Support/admin-request.php index 09c2c27f..cca55925 100644 --- a/tests/Integration/Support/admin-request.php +++ b/tests/Integration/Support/admin-request.php @@ -18,7 +18,7 @@ register_shutdown_function( static function () use ( &$acme_outcome, $acme_request ): void { $fatal = error_get_last(); - if ( null !== $fatal && 0 !== ( $fatal['type'] & ( E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR ) ) ) { + if ( ! is_null( $fatal ) && 0 !== ( $fatal['type'] & ( E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR ) ) ) { $acme_outcome['errors'][] = "{$fatal['message']} in {$fatal['file']}:{$fatal['line']}"; } file_put_contents( $acme_request['result'], serialize( $acme_outcome ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- WordPress may not have loaded. diff --git a/tests/Integration/Support/consumer-request.php b/tests/Integration/Support/consumer-request.php index 6aa941ad..dcba6a50 100644 --- a/tests/Integration/Support/consumer-request.php +++ b/tests/Integration/Support/consumer-request.php @@ -60,11 +60,11 @@ static function ( int $level, string $message, string $file, int $line ) use ( $ $plugin = null; } $GLOBALS['acme_outcome']['records'][ $key ] = array( - 'status' => $plugin?->boot->status, + 'status' => $plugin?->boundary->status, 'skipped' => $plugin?->kernel?->skipped, - 'boot_succeeded' => did_action( "{$prefix}/boot/succeeded" ), + 'boot_succeeded' => did_action( "$prefix/boot/succeeded" ), 'functions_loaded' => function_exists( "{$prefix}_plugin" ), - 'migrations' => \is_object( $plugin ) && property_exists( $plugin, 'migrations' ) && \is_object( $plugin->migrations ) ? \get_class( $plugin->migrations ) : null, + 'migrations' => is_object( $plugin ) && property_exists( $plugin, 'migrations' ) && is_object( $plugin->migrations ) ? get_class( $plugin->migrations ) : null, ); } }; @@ -76,6 +76,7 @@ static function ( int $level, string $message, string $file, int $line ) use ( $ $handlers, array( new class() implements WC_Log_Handler_Interface { + #[\Override] public function handle( $timestamp, $level, $message, $context ) { $GLOBALS['acme_outcome']['records']['logs'][] = array( $level, $message, $context['source'] ?? null ); diff --git a/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php b/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php index c729b7bd..af32e739 100644 --- a/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php +++ b/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php @@ -6,11 +6,10 @@ use DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger; use DeepWebSolutions\Framework\Settings\EditScreen\ObjectForm; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\InspectsObjectFormsTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\RecordedDefinition; -use DeepWebSolutions\Framework\Tests\Integration\Support\RunsAdminRequestsTrait; -use DeepWebSolutions\Framework\Tests\Integration\Support\SerializesFormsTrait; use DeepWebSolutions\Framework\Tests\Integration\WooCommerce\EditScreen\OrderFixturesTrait; use DeepWebSolutions\Framework\WooCommerce\EditScreen\Hosts\OrderMetaBox; use PHPUnit\Framework\Attributes\CoversClass; @@ -20,19 +19,10 @@ #[CoversClass( OrderMetaBox::class )] final class OrderMetaBoxTest extends TestCase { use CreatesUsersTrait; + use InspectsObjectFormsTrait; use IsolatesHooksTrait; use IsolatesStorageTrait; use OrderFixturesTrait; - use RunsAdminRequestsTrait; - use SerializesFormsTrait; - - protected const string FIXTURE = __DIR__ . '/../../../../Fixtures/Settings/acme-object-forms.php'; - - protected const array COLORS = array( - 'red' => 'Red', - 'blue' => 'Blue', - 'teal' => 'Teal', - ); protected int $admin; protected string $id; @@ -65,7 +55,7 @@ public static function silent_and_worded_rejections(): array { public function test_the_box_is_added_on_add_meta_boxes(): void { $host = new OrderMetaBox(); - ( new ObjectForm( $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) ) )->register_hooks(); + new ObjectForm( $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks(); self::assertSame( 10, has_action( 'add_meta_boxes', array( $host, 'add_meta_box' ) ) ); } @@ -89,10 +79,10 @@ public function test_the_box_shows_on_the_edit_screens_of_its_order_types_only() $screen = self::document( $this->edit_screen( $spec, $order->get_id() )['body'] ); $elsewhere = self::document( $this->edit_screen( $other, $order->get_id() )['body'] ); - self::assertSame( 'Acme details', \trim( (string) $screen->querySelector( "form#order div.postbox[id=\"{$this->id}\"] h2.hndle" )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertSame( 'Acme details', \trim( (string) $screen->querySelector( "form#order div.postbox[id=\"$this->id\"] h2.hndle" )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertNotNull( $this->box( $screen )->querySelector( "input[type=hidden][name=\"{$this->id}_nonce\"]" ) ); self::assertNotNull( $elsewhere->querySelector( 'form#order' ) ); - self::assertNull( $elsewhere->querySelector( "div.postbox[id=\"{$this->id}\"]" ) ); + self::assertNull( $elsewhere->querySelector( "div.postbox[id=\"$this->id\"]" ) ); } public function test_the_box_sits_in_its_context(): void { @@ -110,7 +100,7 @@ public function test_the_box_sits_in_its_context(): void { $screen = self::document( $this->edit_screen( $spec, $order->get_id() )['body'] ); - self::assertNotNull( $screen->querySelector( "#side-sortables div.postbox[id=\"{$this->id}\"]" ) ); + self::assertNotNull( $screen->querySelector( "#side-sortables div.postbox[id=\"$this->id\"]" ) ); } public function test_a_user_lacking_the_forms_capability_gets_no_box(): void { @@ -121,7 +111,7 @@ public function test_a_user_lacking_the_forms_capability_gets_no_box(): void { $screen = self::document( $this->edit_screen( $spec, $order->get_id(), $manager )['body'] ); self::assertNotNull( $screen->querySelector( 'form#order' ) ); - self::assertNull( $screen->querySelector( "div.postbox[id=\"{$this->id}\"]" ) ); + self::assertNull( $screen->querySelector( "div.postbox[id=\"$this->id\"]" ) ); } public function test_a_single_control_is_labelled_by_its_row(): void { @@ -130,10 +120,10 @@ public function test_a_single_control_is_labelled_by_its_row(): void { $box = $this->box( self::document( $this->edit_screen( $spec, $order->get_id() )['body'] ) ); - $label = $box->querySelector( "div.form-field > p.post-attributes-label-wrapper > label.post-attributes-label[for=\"{$this->id}._{$this->prefix}_name\"]" ); + $label = $box->querySelector( "div.form-field > p.post-attributes-label-wrapper > label.post-attributes-label[for=\"$this->id._{$this->prefix}_name\"]" ); self::assertNotNull( $label ); self::assertSame( 'Name', \trim( $label->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - self::assertNotNull( $box->querySelector( "div.form-field > input[type=text][id=\"{$this->id}._{$this->prefix}_name\"][name=\"{$this->id}[_{$this->prefix}_name]\"]" ) ); + self::assertNotNull( $box->querySelector( "div.form-field > input[type=text][id=\"$this->id._{$this->prefix}_name\"][name=\"{$this->id}[_{$this->prefix}_name]\"]" ) ); } #[DataProvider( 'group_fields' )] @@ -148,7 +138,7 @@ public function test_a_group_is_one_fieldset_named_by_its_own_legend_under_its_r self::assertCount( 1, $box->querySelectorAll( 'fieldset' ) ); self::assertSame( 'Color', \trim( (string) $fieldset->querySelector( 'legend.screen-reader-text' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertSame( 'Color', \trim( (string) $fieldset->parentElement?->querySelector( 'p.post-attributes-label-wrapper > span.post-attributes-label' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - self::assertNull( $box->querySelector( "label.post-attributes-label[for=\"{$this->id}._{$this->prefix}_color\"]" ) ); + self::assertNull( $box->querySelector( "label.post-attributes-label[for=\"$this->id._{$this->prefix}_color\"]" ) ); self::assertNotNull( $fieldset->querySelector( "input[name^=\"{$this->id}[_{$this->prefix}_color]\"]" ) ); } public function test_controls_carry_the_stored_value_or_else_the_default(): void { @@ -212,7 +202,7 @@ public function test_per_object_fields_receive_the_order_and_contributed_fields_ public function test_the_box_saves_before_woocommerces_order_data(): void { $host = new OrderMetaBox(); - ( new ObjectForm( $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) ) )->register_hooks(); + new ObjectForm( $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) )->register_hooks(); self::assertSame( 35, has_action( 'woocommerce_process_shop_order_meta', array( $host, 'save_meta_box' ) ) ); } @@ -374,7 +364,7 @@ public function test_on_the_posts_table_the_box_shows_the_stored_values(): void $screen = self::document( $this->posts_edit_screen( $spec, $order->get_id() )['body'] ); self::assertSame( 'shop_order', get_post_type( $order->get_id() ) ); - self::assertNotNull( $screen->querySelector( "form#post div.postbox[id=\"{$this->id}\"] input[type=hidden][name=\"{$this->id}_nonce\"]" ) ); + self::assertNotNull( $screen->querySelector( "form#post div.postbox[id=\"$this->id\"] input[type=hidden][name=\"{$this->id}_nonce\"]" ) ); self::assertSame( 'stored', $this->control( $this->box( $screen ), 'name' )->getAttribute( 'value' ) ); } @@ -420,18 +410,6 @@ protected function spec( array $fields, array $form = array(), array $extra = ar ); } - protected function field( string $type, string $name, array $keys = array() ): array { - return $keys + array( - 'type' => $type, - 'id' => "_{$this->prefix}_$name", - 'title' => \ucfirst( $name ), - ); - } - - protected function control_name( string $name ): string { - return "{$this->id}[_{$this->prefix}_$name]"; - } - // The form posts as WooCommerce's Update button submits it, after the user set the named controls; tampering replaces posted keys and removes posted keys or a key of the form's array. protected function save( array $spec, int $order_id, array $changes = array(), array $tampered = array(), array $removed = array() ): array { $data = \array_replace_recursive( self::serialize_form( $this->edit_screen( $spec, $order_id )['body'], 'form#order', $changes ), $tampered ); @@ -453,29 +431,10 @@ protected function posts_save( array $spec, int $order_id, array $changes ): arr return $this->admin_request( 'POST', 'post.php', $this->admin, self::FIXTURE, $spec, $data ); } - protected function follow( array $spec, array $saved ): array { - self::assertIsString( $saved['location'] ); - - return $this->admin_request( 'GET', \explode( '/wp-admin/', $saved['location'], 2 )[1], $this->admin, self::FIXTURE, $spec ); - } - protected function meta( int $order_id, string $name ): array { return $this->order_meta( $order_id, "_{$this->prefix}_$name" ); } - protected static function rejections( string $html ): array { - $messages = array(); - foreach ( self::document( $html )->querySelectorAll( 'div.notice-error.is-dismissible > p' ) as $message ) { - $messages[] = \trim( $message->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - } - - return $messages; - } - - protected function records( array $request, string $kind ): array { - return \array_values( \array_filter( $request['records'], static fn ( array $record ): bool => $kind === $record[0] ) ); - } - protected function edit_screen( array $spec, int $order_id, ?int $user_id = null ): array { return $this->admin_request( 'GET', "admin.php?page=wc-orders&action=edit&id=$order_id", $user_id ?? $this->admin, self::FIXTURE, $spec ); } @@ -483,27 +442,4 @@ protected function edit_screen( array $spec, int $order_id, ?int $user_id = null protected function posts_edit_screen( array $spec, int $order_id ): array { return $this->admin_request( 'GET', "post.php?post=$order_id&action=edit", $this->admin, self::FIXTURE, $spec ); } - - protected function box( \Dom\HTMLDocument $screen ): \Dom\Element { - $box = $screen->querySelector( "div.postbox[id=\"{$this->id}\"] div.inside" ); - self::assertNotNull( $box, "The screen printed no {$this->id} box." ); - - return $box; - } - - protected function control( \Dom\Element $box, string $name ): \Dom\Element { - $control = $box->querySelector( "[id=\"{$this->id}._{$this->prefix}_$name\"]" ); - self::assertNotNull( $control, "The box printed no _{$this->prefix}_$name." ); - - return $control; - } - - protected function selected( \Dom\Element $box, string $name ): array { - $values = array(); - foreach ( $this->control( $box, $name )->querySelectorAll( 'option[selected]' ) as $option ) { - $values[] = (string) $option->getAttribute( 'value' ); - } - - return $values; - } } diff --git a/tests/Integration/WooCommerce/EditScreen/Stores/OrderMetaStoreTest.php b/tests/Integration/WooCommerce/EditScreen/Stores/OrderMetaStoreTest.php index a7a332d6..97fc7ed9 100644 --- a/tests/Integration/WooCommerce/EditScreen/Stores/OrderMetaStoreTest.php +++ b/tests/Integration/WooCommerce/EditScreen/Stores/OrderMetaStoreTest.php @@ -28,7 +28,7 @@ public function test_only_the_keys_the_order_has_are_returned_with_their_stored_ ) ); - $values = ( new OrderMetaStore() )->get_values( $order->get_id(), array( "_{$this->prefix}_text", "_{$this->prefix}_empty", "_{$this->prefix}_list", "_{$this->prefix}_absent" ) ); + $values = new OrderMetaStore()->get_values( $order->get_id(), array( "_{$this->prefix}_text", "_{$this->prefix}_empty", "_{$this->prefix}_list", "_{$this->prefix}_absent" ) ); self::assertSame( array( @@ -44,7 +44,7 @@ public function test_the_stored_value_is_read_past_woocommerces_view_filter(): v $order = $this->make_order( array( "_{$this->prefix}_text" => 'stored' ) ); add_filter( "woocommerce_order_get__{$this->prefix}_text", static fn (): string => 'filtered' ); - self::assertSame( array( "_{$this->prefix}_text" => 'stored' ), ( new OrderMetaStore() )->get_values( $order->get_id(), array( "_{$this->prefix}_text" ) ) ); + self::assertSame( array( "_{$this->prefix}_text" => 'stored' ), new OrderMetaStore()->get_values( $order->get_id(), array( "_{$this->prefix}_text" ) ) ); } #[DataProvider( 'order_storages' )] @@ -52,7 +52,7 @@ public function test_values_are_stored_as_given_text_backslashes_included( strin $this->use_order_storage( $storage ); $order = $this->make_order( array( "_{$this->prefix}_text" => 'old' ) ); - ( new OrderMetaStore() )->update_values( + new OrderMetaStore()->update_values( $order->get_id(), array( "_{$this->prefix}_text" => 'O\'Brien C:\\dir', @@ -81,7 +81,7 @@ public function test_a_value_holding_anything_but_scalars_null_and_arrays_throws $order = $this->make_order(); try { - ( new OrderMetaStore() )->update_values( + new OrderMetaStore()->update_values( $order->get_id(), array( "_{$this->prefix}_first" => 'Written first.', @@ -158,7 +158,7 @@ function ( int $order_id ) use ( &$seen ): void { } ); - ( new OrderMetaStore() )->update_values( + new OrderMetaStore()->update_values( $order->get_id(), array( "_{$this->prefix}_a" => 'one', diff --git a/tests/Integration/WooCommerce/Logging/WooCommerceLoggerTest.php b/tests/Integration/WooCommerce/Logging/WooCommerceLoggerTest.php index 754f08a5..63d373bc 100644 --- a/tests/Integration/WooCommerce/Logging/WooCommerceLoggerTest.php +++ b/tests/Integration/WooCommerce/Logging/WooCommerceLoggerTest.php @@ -28,6 +28,7 @@ protected function setUp(): void { $this->handler = new class() implements \WC_Log_Handler_Interface { public array $records = array(); + #[\Override] public function handle( $timestamp, $level, $message, $context ) { $this->records[] = array( $level, $message, $context ); @@ -126,6 +127,7 @@ public function test_a_record_casts_only_the_context_values_its_message_uses(): $unused = new class() implements \Stringable { public int $casts = 0; + #[\Override] public function __toString(): string { ++$this->casts; @@ -169,6 +171,7 @@ public function test_a_context_value_that_cannot_be_encoded_reaches_woocommerces $this->handler = new class() extends LogHandlerFileV2 { public array $entries = array(); + #[\Override] public function handle( $timestamp, $level, $message, $context ) { $this->entries[] = self::format_entry( $timestamp, $level, $message, $context ); @@ -176,6 +179,7 @@ public function handle( $timestamp, $level, $message, $context ) { } }; $unencodable = new class() implements \JsonSerializable { + #[\Override] public function jsonSerialize(): mixed { throw new \LogicException( 'Not encodable.' ); } diff --git a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php index 87f6f7e5..76f73d51 100644 --- a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php +++ b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php @@ -6,6 +6,7 @@ use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\ProductFixturesTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\ProvidesHelperIdsTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\RecordedDefinition; use DeepWebSolutions\Framework\Tests\Integration\Support\RunsAdminRequestsTrait; @@ -70,10 +71,9 @@ public function test_an_invalid_tab_id_is_rejected( string $id ): void { #[DataProvider( 'v1_helper_ids' )] public function test_every_v1_key_is_a_valid_tab_id( string $id ): void { - $tab = new ProductDataTab( $id, new RecordedDefinition( static fn (): ?array => null ) ); - $tab->register_hooks(); + $this->expectNotToPerformAssertions(); - self::assertSame( 10, has_filter( 'woocommerce_product_data_tabs', array( $tab, 'add_tab' ) ) ); + new ProductDataTab( $id, new RecordedDefinition( static fn (): ?array => null ) ); } public function test_the_definition_is_evaluated_only_when_the_edit_screen_needs_it(): void { @@ -206,6 +206,16 @@ public function test_a_custom_type_field_renders_its_control_in_woocommerces_row self::assertSame( array( array( 'render', "_{$this->prefix}_widget", 'stored', "_{$this->prefix}_widget", "_{$this->prefix}_widget" ) ), $request['records'] ); } + public function test_a_custom_type_field_label_points_at_its_control_when_the_field_id_holds_an_entity(): void { + $product = $this->make_product(); + $spec = $this->spec( array( $this->group( array( $this->field( 'acme_widget', 'r&d', array( 'render' => 'acme:render' ) ) ) ) ) ); + + $row = self::document( $this->edit_screen( $spec, $product->get_id() )['body'] )->querySelector( '#woocommerce-product-data input.acme-widget' )?->closest( 'p' ); + + self::assertSame( "_{$this->prefix}_r&d", $row?->querySelector( 'input.acme-widget' )?->getAttribute( 'id' ) ); + self::assertSame( "_{$this->prefix}_r&d", $row?->querySelector( 'label' )?->getAttribute( 'for' ) ); + } + public static function group_types(): array { return array( 'radio' => array( 'radio' ), @@ -251,7 +261,47 @@ public function test_a_checkboxes_group_renders_in_a_fieldset_named_by_its_label self::assertCount( 1, $fieldset?->querySelectorAll( 'legend' ) ?? array() ); self::assertSame( '', $fieldset?->querySelector( "input[type=hidden][name=\"_{$this->prefix}_colors\"]" )?->getAttribute( 'value' ) ); self::assertSame( array( 'blue' ), $this->checked( $fieldset, "_{$this->prefix}_colors[]" ) ); - self::assertSame( 'Pick any.', \trim( (string) $fieldset?->querySelector( 'p.description' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertSame( 'Pick any.', \trim( (string) $fieldset?->querySelector( '.description' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertCount( 0, $fieldset?->querySelectorAll( 'fieldset' ) ?? array( 'missing' ) ); + $this->assert_each_choice_is_described( $fieldset, 'checkbox', "_{$this->prefix}_colors.description" ); + } + + public function test_a_radio_group_renders_in_one_fieldset_named_by_its_label(): void { + $product = $this->make_product( array( "_{$this->prefix}_tone" => 'blue' ) ); + $spec = $this->spec( + array( + $this->group( + array( + $this->field( + 'radio', + 'tone', + array( + 'options' => self::COLORS, + 'description' => 'Pick one.', + ) + ), + ) + ), + ) + ); + + $fieldset = self::document( $this->edit_screen( $spec, $product->get_id() )['body'] )->querySelector( "fieldset.form-field._{$this->prefix}_tone_field" ); + + self::assertSame( 'Tone', \trim( (string) $fieldset?->querySelector( 'legend' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertCount( 1, $fieldset?->querySelectorAll( 'legend' ) ?? array() ); + self::assertCount( 0, $fieldset?->querySelectorAll( 'fieldset' ) ?? array( 'missing' ) ); + self::assertSame( 'blue', $fieldset?->querySelector( 'input[type=radio][checked]' )?->getAttribute( 'value' ) ); + $this->assert_each_choice_is_described( $fieldset, 'radio', "_{$this->prefix}_tone.description" ); + } + + protected function assert_each_choice_is_described( ?\Dom\Element $fieldset, string $type, string $help_id ): void { + $choices = $fieldset?->querySelectorAll( "input[type=$type]" ) ?? array(); + self::assertNotCount( 0, $choices ); + foreach ( $choices as $choice ) { + self::assertSame( $help_id, $choice->getAttribute( 'aria-describedby' ) ); + } + + self::assertSame( $help_id, $fieldset?->querySelector( '.description' )?->getAttribute( 'id' ) ); } public function test_a_multiselect_posts_an_empty_value_before_its_choices(): void { @@ -756,7 +806,7 @@ public function test_an_unsupported_product_never_saves_the_tabs_fields(): void } public function test_a_v1_meta_key_round_trips_through_the_panel(): void { - $key = "_{$this->prefix}-wrwc_warranty-type"; + $key = "_$this->prefix-wrwc_warranty-type"; $product = $this->make_product( array( $key => 'limited' ) ); $spec = $this->spec( array( diff --git a/tests/Integration/WooCommerce/ProductData/ProductMetaTest.php b/tests/Integration/WooCommerce/ProductData/ProductMetaTest.php index e9e86c76..6fccd707 100644 --- a/tests/Integration/WooCommerce/ProductData/ProductMetaTest.php +++ b/tests/Integration/WooCommerce/ProductData/ProductMetaTest.php @@ -4,6 +4,7 @@ use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\ProductFixturesTrait; use DeepWebSolutions\Framework\WooCommerce\ProductData\ProductMeta; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; diff --git a/tests/Integration/WooCommerce/RequirementFixturesTrait.php b/tests/Integration/WooCommerce/RequirementFixturesTrait.php index 2eba97cf..49f8950e 100644 --- a/tests/Integration/WooCommerce/RequirementFixturesTrait.php +++ b/tests/Integration/WooCommerce/RequirementFixturesTrait.php @@ -3,40 +3,23 @@ namespace DeepWebSolutions\Framework\Tests\Integration\WooCommerce; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; -use PHPUnit\Framework\Attributes\After; +use DeepWebSolutions\Framework\Tests\Integration\Support\RendersAdminScreensTrait; +use DeepWebSolutions\Framework\Tests\Integration\Support\WritesPluginFilesTrait; use PHPUnit\Framework\Attributes\Before; trait RequirementFixturesTrait { use IsolatesHooksTrait; - - protected string $directory = ''; + use RendersAdminScreensTrait; + use WritesPluginFilesTrait; #[Before] protected function clear_admin_notices(): void { remove_all_actions( 'all_admin_notices' ); } - #[After] - protected function remove_plugin(): void { - wp_set_current_user( 0 ); - if ( '' !== $this->directory ) { - \array_map( 'unlink', \glob( "$this->directory/*" ) ); - \rmdir( $this->directory ); - } - } - // WordPress core reads a plugin's headers and dependencies from the plugins directory, so the fixture lives there. protected function write_plugin( array $headers ): string { - $slug = 'acme-shop-' . \bin2hex( \random_bytes( 3 ) ); - $this->directory = WP_PLUGIN_DIR . "/$slug"; - \mkdir( $this->directory ); - - $lines = array( ' 'Acme Shop' ) as $name => $value ) { - $lines[] = " * $name: $value"; - } - $lines[] = ' */'; - \file_put_contents( "$this->directory/$slug.php", \implode( "\n", $lines ) . "\n" ); + $plugin_file = $this->write_plugin_file( WP_PLUGIN_DIR, 'acme-shop-' . \bin2hex( \random_bytes( 3 ) ), $headers + array( 'Plugin Name' => 'Acme Shop' ) ); // WordPress reads every plugin's headers and dependencies once per request. wp_clean_plugins_cache( false ); @@ -49,14 +32,6 @@ static function (): void { \WP_Plugin_Dependencies::class )(); - return "$this->directory/$slug.php"; - } - - protected function render_notices_as( int $user_id ): string { - wp_set_current_user( $user_id ); - \ob_start(); - do_action( 'all_admin_notices' ); - - return (string) \ob_get_clean(); + return $plugin_file; } } diff --git a/tests/Integration/WooCommerce/Setting/SettingsTest.php b/tests/Integration/WooCommerce/Setting/SettingsTest.php index 6e62e77b..9a801101 100644 --- a/tests/Integration/WooCommerce/Setting/SettingsTest.php +++ b/tests/Integration/WooCommerce/Setting/SettingsTest.php @@ -9,6 +9,7 @@ use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\ProvidesHelperIdsTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\RecordedDefinition; +use DeepWebSolutions\Framework\Tests\Integration\Support\RecordsDoingItWrongTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\RunsAdminRequestsTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\SerializesFormsTrait; use DeepWebSolutions\Framework\WooCommerce\Setting\Fields; @@ -26,6 +27,7 @@ final class SettingsTest extends TestCase { use IsolatesHooksTrait; use IsolatesStorageTrait; use ProvidesHelperIdsTrait; + use RecordsDoingItWrongTrait; use RunsAdminRequestsTrait; use SerializesFormsTrait; @@ -107,8 +109,8 @@ public function test_an_id_woocommerce_would_rewrite_is_rejected_as_a_tab_id_a_s #[DataProvider( 'v1_helper_ids' )] public function test_every_v1_key_is_a_valid_tab_and_section_id( string $id ): void { - self::assertSame( $id, ( new SettingsTab( $id, $this->tab( array() ), $this->fields_definition() ) )->get_id() ); - self::assertSame( $id, ( new SettingsSection( $id, $this->registered_section( 'products' ), $this->fields_definition() ) )->get_id() ); + self::assertSame( $id, new SettingsTab( $id, $this->tab( array() ), $this->fields_definition() )->get_id() ); + self::assertSame( $id, new SettingsSection( $id, $this->registered_section( 'products' ), $this->fields_definition() )->get_id() ); } public function test_a_section_builds_its_fields_only_when_woocommerce_asks_for_them(): void { @@ -178,8 +180,8 @@ public function test_saving_a_section_stores_its_values_and_leaves_the_other_sec public function test_a_tab_field_round_trips_through_woocommerces_rest_api(): void { $this->add_tab_in_process( array( '' => $this->in_process_section( array( $this->field( 'text', 'name' ) ) ) ) ); - $updated = $this->rest( 'PUT', "/wc/v3/settings/{$this->prefix}/{$this->prefix}_name", array( 'value' => 'via rest' ) ); - $listed = $this->rest( 'GET', "/wc/v3/settings/{$this->prefix}" ); + $updated = $this->rest( 'PUT', "/wc/v3/settings/$this->prefix/{$this->prefix}_name", array( 'value' => 'via rest' ) ); + $listed = $this->rest( 'GET', "/wc/v3/settings/$this->prefix" ); self::assertSame( 200, $updated->get_status() ); self::assertSame( 'via rest', get_option( "{$this->prefix}_name" ) ); @@ -218,19 +220,12 @@ public function test_a_registered_section_builds_its_fields_once_from_its_tab_an } public function test_a_section_whose_id_is_taken_is_rejected_by_the_registry(): void { - $wrong = array(); $registry = SettingsSectionRegistry::get_instance(); - add_filter( 'doing_it_wrong_trigger_error', '__return_false' ); - add_action( - 'doing_it_wrong_run', - static function ( string $function_name ) use ( &$wrong ): void { - $wrong[] = $function_name; - } - ); + $this->record_doing_it_wrong(); self::assertTrue( $registry->register( new SettingsSection( $this->prefix, $this->registered_section( 'products' ), $this->fields_definition() ) ) ); self::assertFalse( $registry->register( new SettingsSection( $this->prefix, $this->registered_section( 'products' ), $this->fields_definition() ) ) ); - self::assertSame( array( SettingsSectionRegistry::class . '::register' ), $wrong ); + self::assertSame( array( SettingsSectionRegistry::class . '::register' ), $this->doing_it_wrong ); } public function test_a_disabled_field_is_never_saved_even_when_its_value_is_posted(): void { @@ -388,7 +383,7 @@ public function test_over_rest_validate_receives_woocommerces_normalized_value() ) ); - $this->rest( 'PUT', "/wc/v3/settings/{$this->prefix}/{$this->prefix}_name", array( 'value' => ' bold ' ) ); + $this->rest( 'PUT', "/wc/v3/settings/$this->prefix/{$this->prefix}_name", array( 'value' => ' bold ' ) ); $normalized = wp_kses_post( 'bold' ); @@ -466,6 +461,27 @@ public function test_a_custom_type_field_renders_in_woocommerces_row_through_its self::assertSame( array( array( 'render', "{$this->prefix}_widget", 'stored', "{$this->prefix}_widget", "{$this->prefix}_widget" ) ), $opened['records'] ); } + public function test_a_custom_type_field_label_points_at_its_control_when_the_field_id_holds_an_entity(): void { + $fields = array( + array( + 'type' => 'title', + 'id' => "{$this->prefix}_options", + 'title' => 'Options', + ), + $this->field( 'widget', 'r&d', array( 'render' => 'acme:render' ) ), + array( + 'type' => 'sectionend', + 'id' => "{$this->prefix}_options", + ), + ); + $spec = $this->tab_spec( array( '' => self::section( 'General', $fields ) ) ); + + $row = self::document( $this->open( $spec )['body'] )->querySelector( 'form#mainform input.acme-widget' )?->closest( 'tr' ); + + self::assertSame( "{$this->prefix}_r&d", $row?->querySelector( 'input.acme-widget' )?->getAttribute( 'id' ) ); + self::assertSame( "{$this->prefix}_r&d", $row?->querySelector( 'th.titledesc label' )?->getAttribute( 'for' ) ); + } + public function test_a_custom_type_field_saves_through_its_validate(): void { $spec = $this->tab_spec( array( '' => self::section( 'General', array( $this->custom_field( array( 'validate' => 'acme:append' ) ) ) ) ) ); @@ -625,7 +641,7 @@ protected function rest( string $method, string $route, array $params = array() add_action( 'rest_api_init', static function (): void { - ( new \WC_REST_Setting_Options_Controller() )->register_routes(); + new \WC_REST_Setting_Options_Controller()->register_routes(); } ); $GLOBALS['wp_rest_server'] = null; diff --git a/tests/Integration/WooCommerce/Uninstall/MetaTypesTest.php b/tests/Integration/WooCommerce/Uninstall/MetaTypesTest.php index bb4faaca..da0d179f 100644 --- a/tests/Integration/WooCommerce/Uninstall/MetaTypesTest.php +++ b/tests/Integration/WooCommerce/Uninstall/MetaTypesTest.php @@ -80,7 +80,7 @@ public function test_with_woocommerce_deactivated_a_consenting_uninstall_still_d ) ); - self::assertSame( array( 'woocommerce loaded' => false ), $request['records'] ); + self::assertSame( array( 'woocommerce_loaded' => false ), $request['records'] ); self::assertSame( $this->kept_order_meta_keys(), $this->meta_keys_in( 'postmeta' ) ); self::assertSame( $this->kept_order_meta_keys(), $this->meta_keys_in( 'wc_orders_meta' ) ); self::assertSame( $this->kept_order_item_meta_keys(), $this->meta_keys_in( 'woocommerce_order_itemmeta' ) ); diff --git a/tests/Integration/WooCommerce/WooCommerceRequirementTest.php b/tests/Integration/WooCommerce/WooCommerceRequirementTest.php index 0c0a0d3c..634e1d5c 100644 --- a/tests/Integration/WooCommerce/WooCommerceRequirementTest.php +++ b/tests/Integration/WooCommerce/WooCommerceRequirementTest.php @@ -24,43 +24,38 @@ public static function met_floors(): array { #[DataProvider( 'met_floors' )] public function test_woocommerce_meeting_the_floor_passes_and_nobody_sees_a_notice( ?string $floor ): void { - $headers = \is_null( $floor ) ? array() : array( 'WC requires at least' => 'installed' === $floor ? WC_VERSION : $floor ); - $requirement = new WooCommerceRequirement( $this->write_plugin( $headers + array( 'Requires Plugins' => 'woocommerce' ) ) ); + $headers = \is_null( $floor ) ? array() : array( 'WC requires at least' => 'installed' === $floor ? WC_VERSION : $floor ); - self::assertTrue( $requirement->check() ); - self::assertSame( '', $this->render_notices_as( $this->make_user( 'acme_requirement_admin', 'administrator' ) ) ); + self::assertTrue( WooCommerceRequirement::check( $this->write_plugin( $headers + array( 'Requires Plugins' => 'woocommerce' ) ) ) ); + self::assertSame( '', $this->render_all_admin_notices( $this->make_user( 'acme_requirement_admin', 'administrator' ) ) ); } public function test_woocommerce_below_the_floor_fails_and_only_users_who_can_activate_plugins_are_told_to_update_it(): void { - $requirement = new WooCommerceRequirement( - $this->write_plugin( - array( - 'WC requires at least' => '99.1', - 'Requires Plugins' => 'woocommerce', - ) + $plugin_file = $this->write_plugin( + array( + 'WC requires at least' => '99.1', + 'Requires Plugins' => 'woocommerce', ) ); - self::assertFalse( $requirement->check() ); + self::assertFalse( WooCommerceRequirement::check( $plugin_file ) ); - $notice = \Dom\HTMLDocument::createFromString( $this->render_notices_as( $this->make_user( 'acme_requirement_admin', 'administrator' ) ), \LIBXML_NOERROR )->querySelector( '.notice.notice-error' ); + $notice = \Dom\HTMLDocument::createFromString( $this->render_all_admin_notices( $this->make_user( 'acme_requirement_admin', 'administrator' ) ), \LIBXML_NOERROR )->querySelector( '.notice.notice-error' ); self::assertNotNull( $notice ); self::assertSame( 'Acme Shop needs WooCommerce 99.1 or later, so its features stay off until WooCommerce is updated.', \trim( $notice->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - self::assertSame( '', $this->render_notices_as( $this->make_user( 'acme_requirement_editor', 'editor' ) ) ); + self::assertSame( '', $this->render_all_admin_notices( $this->make_user( 'acme_requirement_editor', 'editor' ) ) ); } public function test_the_notice_prints_the_plugins_name_and_floor_as_text(): void { - $requirement = new WooCommerceRequirement( - $this->write_plugin( - array( - 'Plugin Name' => 'Acme Shop', - 'WC requires at least' => '99.1', - ) + $plugin_file = $this->write_plugin( + array( + 'Plugin Name' => 'Acme Shop', + 'WC requires at least' => '99.1', ) ); - $requirement->check(); + WooCommerceRequirement::check( $plugin_file ); - $notice = \Dom\HTMLDocument::createFromString( $this->render_notices_as( $this->make_user( 'acme_requirement_admin', 'administrator' ) ), \LIBXML_NOERROR )->querySelector( '.notice.notice-error' ); + $notice = \Dom\HTMLDocument::createFromString( $this->render_all_admin_notices( $this->make_user( 'acme_requirement_admin', 'administrator' ) ), \LIBXML_NOERROR )->querySelector( '.notice.notice-error' ); // WordPress core strips tags from the plugin's name, while the header's version reaches the notice as written. self::assertNotNull( $notice ); @@ -69,7 +64,7 @@ public function test_the_notice_prints_the_plugins_name_and_floor_as_text(): voi } public function test_a_failed_check_reads_translations_only_when_the_notice_renders(): void { - $requirement = new WooCommerceRequirement( $this->write_plugin( array( 'WC requires at least' => '99.1' ) ) ); + $plugin_file = $this->write_plugin( array( 'WC requires at least' => '99.1' ) ); $translations = 0; add_filter( 'gettext_wp-framework', @@ -80,10 +75,10 @@ static function ( string $translation ) use ( &$translations ): string { } ); - $requirement->check(); + WooCommerceRequirement::check( $plugin_file ); self::assertSame( 0, $translations ); - $this->render_notices_as( $this->make_user( 'acme_requirement_admin', 'administrator' ) ); + $this->render_all_admin_notices( $this->make_user( 'acme_requirement_admin', 'administrator' ) ); self::assertGreaterThan( 0, $translations ); } } diff --git a/tests/Integration/WooCommerce/WooCommerceRequirementWithoutWooCommerceTest.php b/tests/Integration/WooCommerce/WooCommerceRequirementWithoutWooCommerceTest.php index 6de9b57a..9c808b64 100644 --- a/tests/Integration/WooCommerce/WooCommerceRequirementWithoutWooCommerceTest.php +++ b/tests/Integration/WooCommerce/WooCommerceRequirementWithoutWooCommerceTest.php @@ -22,14 +22,13 @@ public function test_a_missing_woocommerce_fails_and_only_users_who_can_activate 'Requires Plugins' => 'woocommerce', ) ); - $requirement = new WooCommerceRequirement( $plugin_file ); - self::assertFalse( $requirement->check() ); + self::assertFalse( WooCommerceRequirement::check( $plugin_file ) ); // On a network, only a super admin can activate plugins unless the network enables the plugins menu. $super_admin = $this->make_user( 'acme_requirement_super_admin', 'administrator' ); grant_super_admin( $super_admin ); - $html = $this->render_notices_as( $super_admin ); + $html = $this->render_all_admin_notices( $super_admin ); $error = validate_plugin_requirements( plugin_basename( $plugin_file ) ); revoke_super_admin( $super_admin ); @@ -43,6 +42,6 @@ public function test_a_missing_woocommerce_fails_and_only_users_who_can_activate ) ); self::assertSame( wp_kses_post( $expected ), $html ); - self::assertSame( '', $this->render_notices_as( $this->make_user( 'acme_requirement_admin', 'administrator' ) ) ); + self::assertSame( '', $this->render_all_admin_notices( $this->make_user( 'acme_requirement_admin', 'administrator' ) ) ); } } diff --git a/tests/Php74/Support/RunsPhp74SiteTrait.php b/tests/Php74/Support/RunsPhp74SiteTrait.php index 9643177d..dce57eb0 100644 --- a/tests/Php74/Support/RunsPhp74SiteTrait.php +++ b/tests/Php74/Support/RunsPhp74SiteTrait.php @@ -3,17 +3,18 @@ namespace DeepWebSolutions\Framework\Tests\Php74\Support; use DeepWebSolutions\Framework\Tests\Support\CopiesFixturesTrait; +use DeepWebSolutions\Framework\Tests\Support\RunsProcessesTrait; use PHPUnit\Framework\Attributes\After; trait RunsPhp74SiteTrait { use CopiesFixturesTrait; + use RunsProcessesTrait; protected const string ROOT = __DIR__ . '/../../..'; // .wp-env.php74.json points WP_PLUGIN_DIR here, inside the repository the environment maps. protected const string PLUGINS = self::ROOT . '/tests/.cache/php74-plugins'; - // Copies a built fixture plugin into the PHP 7.4 site's plugins directory and returns its plugin file. protected function install_on_php74( string $fixture, string $dir, array $headers = array() ): string { return "$dir/" . self::copy_fixture( $fixture, self::PLUGINS . "/$dir", array(), $headers ); } @@ -28,17 +29,8 @@ protected function php74_request( array $plugins ): array { } protected function wp_cli( string ...$args ): string { - $descriptors = array( - 1 => array( 'pipe', 'w' ), - 2 => array( 'pipe', 'w' ), - ); - $process = \proc_open( array( self::ROOT . '/node_modules/.bin/wp-env', '--config', '.wp-env.php74.json', 'run', 'cli', 'wp', ...$args ), $descriptors, $pipes, self::ROOT, array( 'CI' => '1' ) + \getenv() ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_proc_open -- Runs WP-CLI in the PHP 7.4 environment. - self::assertIsResource( $process ); - $output = (string) \stream_get_contents( $pipes[1] ); - $errors = (string) \stream_get_contents( $pipes[2] ); - \fclose( $pipes[1] ); - \fclose( $pipes[2] ); - self::assertSame( 0, \proc_close( $process ), "Start the PHP 7.4 environment with `composer test:integration:php74`.\n$errors$output" ); + list( $exit_code, $output, $errors ) = self::run_process( array( self::ROOT . '/node_modules/.bin/wp-env', '--config', '.wp-env.php74.json', 'run', 'cli', 'wp', ...$args ), self::ROOT, array( 'CI' => '1' ) + \getenv() ); + self::assertSame( 0, $exit_code, "Start the PHP 7.4 environment with `composer test:integration:php74`.\n$errors$output" ); return $output; } diff --git a/tests/Support/RunsProcessesTrait.php b/tests/Support/RunsProcessesTrait.php new file mode 100644 index 00000000..50427d90 --- /dev/null +++ b/tests/Support/RunsProcessesTrait.php @@ -0,0 +1,30 @@ + array( 'pipe', 'w' ), + 2 => array( 'pipe', 'w' ), + ); + if ( ! \is_null( $input ) ) { + $descriptors[0] = array( 'pipe', 'r' ); + } + + $process = \proc_open( $command, $descriptors, $pipes, $cwd, $env ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_proc_open -- Runs the command directly, without a shell. + self::assertIsResource( $process ); + if ( ! \is_null( $input ) ) { + \fwrite( $pipes[0], $input ); + \fclose( $pipes[0] ); + } + + $output = (string) \stream_get_contents( $pipes[1] ); + $errors = (string) \stream_get_contents( $pipes[2] ); + \fclose( $pipes[1] ); + \fclose( $pipes[2] ); + + return array( \proc_close( $process ), $output, $errors ); + } +} diff --git a/tests/Unit/AutoloadTest.php b/tests/Unit/AutoloadTest.php index 18c389f6..589d971d 100644 --- a/tests/Unit/AutoloadTest.php +++ b/tests/Unit/AutoloadTest.php @@ -2,19 +2,17 @@ namespace DeepWebSolutions\Framework\Tests\Unit; +use DeepWebSolutions\Framework\Tests\Support\RunsProcessesTrait; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; #[CoversNothing] final class AutoloadTest extends TestCase { - public function test_every_class_follows_the_psr_4_mapping_of_its_file(): void { - $command = \implode( - ' ', - \array_map( 'escapeshellarg', array( 'composer', 'dump-autoload', '--optimize', '--strict-psr', '--dry-run', '--no-interaction', '--working-dir', \dirname( __DIR__, 2 ) ) ) - ); + use RunsProcessesTrait; - \exec( "$command 2>&1", $output, $exit_code ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- The test runs the real Composer. + public function test_every_class_follows_the_psr_4_mapping_of_its_file(): void { + list( $exit_code, $output, $errors ) = self::run_process( array( 'composer', 'dump-autoload', '--optimize', '--strict-psr', '--dry-run', '--no-interaction', '--working-dir', \dirname( __DIR__, 2 ) ) ); - self::assertSame( 0, $exit_code, \implode( "\n", $output ) ); + self::assertSame( 0, $exit_code, $output . $errors ); } } diff --git a/tests/Unit/Core/AdminNotice/NoticeTest.php b/tests/Unit/Core/AdminNotice/NoticeTest.php index 03e004e3..bf5621b2 100644 --- a/tests/Unit/Core/AdminNotice/NoticeTest.php +++ b/tests/Unit/Core/AdminNotice/NoticeTest.php @@ -98,7 +98,7 @@ public function test_absent_optional_fields_take_their_defaults_and_unknown_keys } public static function rejected_rows(): array { - $row = ( new Notice( 'a', 'Hi', scope: NoticeScope::Site, capability: 'manage_options' ) )->to_array(); + $row = new Notice( 'a', 'Hi', scope: NoticeScope::Site, capability: 'manage_options' )->to_array(); return array( 'version-2' => array( array( 'v' => 2 ) + $row ), diff --git a/tests/Unit/Core/Clock/SystemClockTest.php b/tests/Unit/Core/Clock/SystemClockTest.php index ac541496..ff1fbf5f 100644 --- a/tests/Unit/Core/Clock/SystemClockTest.php +++ b/tests/Unit/Core/Clock/SystemClockTest.php @@ -10,7 +10,7 @@ final class SystemClockTest extends TestCase { public function test_now_is_the_current_time(): void { $before = \microtime( true ); - $now = ( new SystemClock() )->now(); + $now = new SystemClock()->now(); $after = \microtime( true ); self::assertGreaterThanOrEqual( $before, (float) $now->format( 'U.u' ) ); diff --git a/tests/Unit/Core/Logging/ErrorLogLoggerTest.php b/tests/Unit/Core/Logging/ErrorLogLoggerTest.php index a5aa4510..d91f99ae 100644 --- a/tests/Unit/Core/Logging/ErrorLogLoggerTest.php +++ b/tests/Unit/Core/Logging/ErrorLogLoggerTest.php @@ -14,7 +14,7 @@ public function test_without_wp_debug_a_record_below_warning_is_dropped(): void $previous = \ini_set( 'error_log', $log_file ); try { - ( new ErrorLogLogger( 'acme' ) )->info( 'Cache is cold.' ); + new ErrorLogLogger( 'acme' )->info( 'Cache is cold.' ); $log = \file_get_contents( $log_file ); } finally { \ini_set( 'error_log', (string) $previous ); diff --git a/tests/Unit/Core/PluginKernelTest.php b/tests/Unit/Core/PluginKernelTest.php index cf8fad6a..929430c6 100644 --- a/tests/Unit/Core/PluginKernelTest.php +++ b/tests/Unit/Core/PluginKernelTest.php @@ -48,7 +48,7 @@ public function register_hooks(): void { } protected function record( string $event ): void { - $this->journal->entries[] = "$event " . ( new \ReflectionClass( $this ) )->getShortName(); + $this->journal->entries[] = "$event " . new \ReflectionClass( $this )->getShortName(); } } @@ -89,9 +89,18 @@ public static function should_load(): bool { final class UngatedL3 extends RecordingComponent {} final class ConditionalOnly implements ConditionalComponentInterface { + public function __construct( + protected Journal $journal + ) {} + #[\Override] public static function should_load(): bool { - throw new \RuntimeException( 'should_load() ran.' ); + return true; + } + + #[\Override] + public function register_hooks(): void { + $this->journal->entries[] = 'register ConditionalOnly'; } } @@ -125,6 +134,7 @@ public static function invalid_trees(): array { 'repeated-child' => array( array( CompositeA::class, ComponentB::class ) ), 'gated-out-repeat' => array( array( GatedOutL2::class, GatedOutL2::class ) ), 'cycle' => array( array( CycleStart::class ) ), + 'plain-class' => array( array( ComponentD::class, Journal::class ) ), ); } @@ -195,10 +205,12 @@ public function test_a_gated_out_child_is_skipped_while_its_siblings_load(): voi self::assertSame( array( GatedOutL2::class ), $kernel->skipped ); } - public function test_a_class_that_is_only_conditional_throws_before_its_gate_runs(): void { - $thrown = self::boot_and_catch( new PluginKernel( new FakeContainer( new Journal() ) ), array( ConditionalOnly::class ) ); + public function test_a_class_that_implements_only_the_conditional_interface_boots_as_a_component(): void { + $journal = new Journal(); - self::assertInstanceOf( LogicException::class, $thrown ); + new PluginKernel( new FakeContainer( $journal ) )->boot( array( ConditionalOnly::class ) ); + + self::assertSame( array( 'register ConditionalOnly' ), $journal->entries ); } #[DataProvider( 'invalid_trees' )] diff --git a/tests/Unit/Core/Scheduling/Backends/ActionSchedulerBackendTest.php b/tests/Unit/Core/Scheduling/Backends/ActionSchedulerBackendTest.php index 26da0f07..6823fa68 100644 --- a/tests/Unit/Core/Scheduling/Backends/ActionSchedulerBackendTest.php +++ b/tests/Unit/Core/Scheduling/Backends/ActionSchedulerBackendTest.php @@ -10,6 +10,6 @@ #[CoversClass( ActionSchedulerBackend::class )] final class ActionSchedulerBackendTest extends TestCase { public function test_action_scheduler_is_absent_when_its_functions_are_not_loaded(): void { - self::assertSame( BackendState::Absent, ( new ActionSchedulerBackend( 'acme' ) )->get_state() ); + self::assertSame( BackendState::Absent, new ActionSchedulerBackend( 'acme' )->get_state() ); } } diff --git a/tests/Unit/PackageManifestsTest.php b/tests/Unit/PackageManifestsTest.php index dde984f7..b1a27a55 100644 --- a/tests/Unit/PackageManifestsTest.php +++ b/tests/Unit/PackageManifestsTest.php @@ -4,6 +4,7 @@ use Composer\Autoload\ClassLoader; use Composer\InstalledVersions; +use DeepWebSolutions\Framework\Tests\Unit\Support\ProvidesPackagesTrait; use PhpParser\Node; use PhpParser\Node\Name; use PhpParser\Node\Stmt\ClassLike; @@ -21,28 +22,7 @@ #[CoversNothing] final class PackageManifestsTest extends TestCase { - protected const array REQUIRES = array( - 'bootstrap' => array( 'php' => '>=7.4' ), - 'shared' => array( 'php' => '>=8.5' ), - 'core' => array( - 'ahegyes/wp-framework-bootstrap' => '^2.0', - 'ahegyes/wp-framework-shared' => '^2.0', - 'ext-filter' => '*', - 'php' => '>=8.5', - 'psr/clock' => '^1', - 'psr/container' => '^2.0.2', - 'psr/log' => '^3.0.2', - 'psr/simple-cache' => '^3', - ), - 'settings' => array( - 'ahegyes/wp-framework-core' => '^2.0', - 'php' => '>=8.5', - ), - 'woocommerce' => array( - 'ahegyes/wp-framework-settings' => '^2.0', - 'php' => '>=8.5', - ), - ); + use ProvidesPackagesTrait; protected const array EXTRAS = array( 'bootstrap' => array( @@ -63,6 +43,7 @@ final class PackageManifestsTest extends TestCase { 'woocommerce' => array( 'branch-alias' => array( 'dev-trunk' => '2.x-dev' ), 'requires-plugins' => array( 'woocommerce' ), + 'requires-wc' => '11.2', 'requires-wp' => '7.1', ), ); @@ -79,19 +60,6 @@ final class PackageManifestsTest extends TestCase { 'Psr\\SimpleCache\\' => 'psr/simple-cache', ); - public static function packages(): array { - $packages = array(); - foreach ( self::REQUIRES as $package => $requires ) { - $packages[ $package ] = array( $package, $requires ); - } - - return $packages; - } - - public static function package_names(): array { - return \array_map( static fn ( array $package ): array => array( $package[0] ), self::packages() ); - } - public function test_the_monorepo_holds_exactly_the_mapped_packages(): void { $directories = \array_map( 'basename', \glob( self::packages_directory() . '/*', \GLOB_ONLYDIR ) ); @@ -108,7 +76,7 @@ public function test_a_package_requires_exactly_its_mapped_edges( string $packag #[DataProvider( 'package_names' )] public function test_a_packages_extra_declares_exactly_its_floors_and_branch_alias( string $package ): void { - $extra = \array_intersect_key( self::read_manifest( $package )['extra'] ?? array(), \array_flip( array( 'branch-alias', 'requires-plugins', 'requires-wp' ) ) ); + $extra = \array_intersect_key( self::read_manifest( $package )['extra'] ?? array(), \array_flip( array( 'branch-alias', 'requires-plugins', 'requires-wc', 'requires-wp' ) ) ); \ksort( $extra ); self::assertSame( self::EXTRAS[ $package ], $extra ); @@ -154,13 +122,20 @@ public function test_the_bootstrap_package_is_never_autoloaded(): void { self::assertArrayNotHasKey( 'autoload', self::read_manifest( 'bootstrap' ) ); } + public static function package_namespaces(): array { + $namespaces = array(); + foreach ( self::OWNERS as $prefix => $owner ) { + if ( \str_starts_with( $owner, 'ahegyes/wp-framework-' ) ) { + $package = \substr( $owner, \strlen( 'ahegyes/wp-framework-' ) ); + $namespaces[ $package ] = array( $package, $prefix ); + } + } + + return $namespaces; + } + public static function autoloaded_packages(): array { - return array( - 'shared' => array( 'shared', 'DeepWebSolutions\\Framework\\Shared\\' ), - 'core' => array( 'core', 'DeepWebSolutions\\Framework\\Core\\' ), - 'settings' => array( 'settings', 'DeepWebSolutions\\Framework\\Settings\\' ), - 'woocommerce' => array( 'woocommerce', 'DeepWebSolutions\\Framework\\WooCommerce\\' ), - ); + return \array_diff_key( self::package_namespaces(), array( 'bootstrap' => true ) ); } // Composer includes a files entry on every request. @@ -171,14 +146,15 @@ public function test_an_autoloaded_package_maps_only_its_namespace_to_src( strin public function test_every_file_outside_the_shared_package_exits_without_wordpress_right_after_its_use_block(): void { $unguarded = array(); - foreach ( array( 'bootstrap', 'core', 'settings', 'woocommerce' ) as $package ) { + foreach ( \array_diff( \array_keys( self::REQUIRES ), array( 'shared' ) ) as $package ) { $files = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( self::packages_directory() . "/$package", \FilesystemIterator::SKIP_DOTS ) ); foreach ( new \RegexIterator( $files, '#(?getPathname() ); - $nodes = ( new ParserFactory() )->createForHostVersion()->parse( $code ); - $namespace = ( new NodeFinder() )->findFirstInstanceOf( $nodes, Namespace_::class ); + $nodes = new ParserFactory()->createForHostVersion()->parse( $code ); + $namespace = new NodeFinder()->findFirstInstanceOf( $nodes, Namespace_::class ); $statement = \current( \array_filter( $namespace?->stmts ?? $nodes, static fn ( Node $node ): bool => ! $node instanceof Declare_ && ! $node instanceof Use_ && ! $node instanceof GroupUse ) ); - if ( false === $statement || "\\defined( 'ABSPATH' ) || exit;" !== \substr( $code, $statement->getStartFilePos(), $statement->getEndFilePos() - $statement->getStartFilePos() + 1 ) ) { + $guard = \is_null( $namespace ) ? "defined( 'ABSPATH' ) || exit;" : "\\defined( 'ABSPATH' ) || exit;"; + if ( false === $statement || \substr( $code, $statement->getStartFilePos(), $statement->getEndFilePos() - $statement->getStartFilePos() + 1 ) !== $guard ) { $unguarded[] = $file->getPathname(); } } @@ -198,20 +174,10 @@ public function test_every_package_directory_carries_an_index_stub(): void { self::assertSame( array(), $missing ); } - public static function source_directories(): array { - return array( - 'bootstrap' => array( 'bootstrap/src', 'DeepWebSolutions\\Framework\\Bootstrap\\' ), - 'shared' => array( 'shared/src', 'DeepWebSolutions\\Framework\\Shared\\' ), - 'core' => array( 'core/src', 'DeepWebSolutions\\Framework\\Core\\' ), - 'settings' => array( 'settings/src', 'DeepWebSolutions\\Framework\\Settings\\' ), - 'woocommerce' => array( 'woocommerce/src', 'DeepWebSolutions\\Framework\\WooCommerce\\' ), - ); - } - // Composer drops PSR-4 violations under vendor/ without a word, so a misplaced package class would autoload in no consumer. - #[DataProvider( 'source_directories' )] - public function test_every_package_class_sits_at_the_path_its_name_implies( string $directory, string $prefix ): void { - self::assertSame( array(), self::misplaced_classes( self::packages_directory() . "/$directory", $prefix ) ); + #[DataProvider( 'package_namespaces' )] + public function test_every_package_class_sits_at_the_path_its_name_implies( string $package, string $prefix ): void { + self::assertSame( array(), self::misplaced_classes( self::packages_directory() . "/$package/src", $prefix ) ); } public function test_a_class_away_from_the_path_its_name_implies_is_misplaced(): void { @@ -234,31 +200,22 @@ public function test_a_class_away_from_the_path_its_name_implies_is_misplaced(): } public function test_a_namespace_relative_name_resolves_against_its_namespace(): void { - $file = \dirname( __DIR__ ) . '/Fixtures/package-manifests/relative-name.php'; + $file = \dirname( __DIR__ ) . '/Fixtures/PackageManifests/relative-name.php'; self::assertContains( 'DeepWebSolutions\\Framework\\Settings\\AdminPage\\OptionsPage', self::referenced_names( $file ) ); } - protected static function closure( string $package ): array { - $closure = array( "ahegyes/wp-framework-$package" ); - foreach ( \array_keys( self::REQUIRES[ $package ] ) as $name ) { - $closure = \array_merge( $closure, \str_starts_with( $name, 'ahegyes/wp-framework-' ) ? self::closure( \substr( $name, \strlen( 'ahegyes/wp-framework-' ) ) ) : array( $name ) ); - } - - return \array_values( \array_unique( $closure ) ); - } - protected static function misplaced_classes( string $directory, string $prefix ): array { $misplaced = array(); $files = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $directory, \FilesystemIterator::SKIP_DOTS ) ); foreach ( new \RegexIterator( $files, '/\.php$/' ) as $file ) { $path = \substr( $file->getPathname(), \strlen( $directory ) + 1 ); $expected = $prefix . \str_replace( '/', '\\', \substr( $path, 0, -4 ) ); - $nodes = ( new ParserFactory() )->createForHostVersion()->parse( \file_get_contents( $file->getPathname() ) ); + $nodes = new ParserFactory()->createForHostVersion()->parse( \file_get_contents( $file->getPathname() ) ); // A file without a namespace declaration holds its classes in the global namespace. - $namespaces = ( new NodeFinder() )->findInstanceOf( $nodes, Namespace_::class ) ?: array( new Namespace_( null, $nodes ) ); + $namespaces = new NodeFinder()->findInstanceOf( $nodes, Namespace_::class ) ?: array( new Namespace_( null, $nodes ) ); foreach ( $namespaces as $namespace ) { - foreach ( ( new NodeFinder() )->findInstanceOf( $namespace->stmts, ClassLike::class ) as $class ) { + foreach ( new NodeFinder()->findInstanceOf( $namespace->stmts, ClassLike::class ) as $class ) { $name = \ltrim( $namespace->name?->toString() . '\\' . $class->name?->toString(), '\\' ); if ( ! \is_null( $class->name ) && $expected !== $name ) { $misplaced[] = "$path: $name"; @@ -285,9 +242,9 @@ protected static function read_manifest( string $package ): array { protected static function referenced_names( string $file ): array { // Names built from strings at runtime and types named only in docblocks are not seen. - $nodes = ( new NodeTraverser( new NameResolver() ) )->traverse( ( new ParserFactory() )->createForHostVersion()->parse( \file_get_contents( $file ) ) ); + $nodes = new NodeTraverser( new NameResolver() )->traverse( new ParserFactory()->createForHostVersion()->parse( \file_get_contents( $file ) ) ); - return \array_map( static fn ( Name $name ): string => $name->toString(), ( new NodeFinder() )->findInstanceOf( $nodes, Name::class ) ); + return \array_map( static fn ( Name $name ): string => $name->toString(), new NodeFinder()->findInstanceOf( $nodes, Name::class ) ); } protected static function is_foreign( string $name, array $allowed ): bool { diff --git a/tests/Unit/PackageReadmesTest.php b/tests/Unit/PackageReadmesTest.php index e84b11a9..061fc425 100644 --- a/tests/Unit/PackageReadmesTest.php +++ b/tests/Unit/PackageReadmesTest.php @@ -2,25 +2,18 @@ namespace DeepWebSolutions\Framework\Tests\Unit; +use DeepWebSolutions\Framework\Tests\Unit\Support\ProvidesPackagesTrait; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversNothing] final class PackageReadmesTest extends TestCase { - protected const string ROOT = __DIR__ . '/../..'; + use ProvidesPackagesTrait; - public static function packages(): array { - return array( - 'bootstrap' => array( 'bootstrap' ), - 'shared' => array( 'shared' ), - 'core' => array( 'core' ), - 'settings' => array( 'settings' ), - 'woocommerce' => array( 'woocommerce' ), - ); - } + protected const string ROOT = __DIR__ . '/../..'; - #[DataProvider( 'packages' )] + #[DataProvider( 'package_names' )] public function test_every_php_example_is_copied_from_the_package_s_persona( string $package ): void { $examples = self::fences( $package, 'php' ); self::assertNotEmpty( $examples, "The $package README shows no PHP example." ); @@ -40,7 +33,7 @@ public function test_every_php_example_is_copied_from_the_package_s_persona( str } } - #[DataProvider( 'packages' )] + #[DataProvider( 'package_names' )] public function test_every_json_example_is_part_of_the_package_s_persona_manifest( string $package ): void { $examples = self::fences( $package, 'json' ); self::assertNotEmpty( $examples, "The $package README shows no JSON example." ); @@ -51,7 +44,7 @@ public function test_every_json_example_is_part_of_the_package_s_persona_manifes } } - #[DataProvider( 'packages' )] + #[DataProvider( 'package_names' )] public function test_the_readme_lists_one_mirror_for_each_package_of_its_stack( string $package ): void { $mirrors = array(); foreach ( self::fences( $package, 'json' ) as $example ) { @@ -62,10 +55,11 @@ public function test_the_readme_lists_one_mirror_for_each_package_of_its_stack( } } - self::assertSame( \array_map( static fn ( string $name ): string => "https://github.com/ahegyes/wp-framework-$name", self::stack( $package ) ), $mirrors ); + $stack = \array_filter( self::closure( $package ), static fn ( string $name ): bool => \str_starts_with( $name, 'ahegyes/wp-framework-' ) ); + self::assertSame( \array_map( static fn ( string $name ): string => "https://github.com/$name", \array_values( $stack ) ), $mirrors ); } - #[DataProvider( 'packages' )] + #[DataProvider( 'package_names' )] public function test_the_readme_states_the_contract_and_its_versioning_as_agents_md_does( string $package ): void { \preg_match( '/^## Contract\n\n(.*?)\n\n## /ms', (string) \file_get_contents( self::ROOT . '/AGENTS.md' ), $section ); $paragraphs = \explode( "\n\n", $section[1] ?? '' ); @@ -139,17 +133,6 @@ protected static function is_part_of( mixed $part, mixed $whole ): bool { return true; } - protected static function stack( string $package ): array { - $stack = array(); - foreach ( \array_keys( self::read_json( "packages/$package/composer.json" )['require'] ) as $name ) { - if ( \str_starts_with( $name, 'ahegyes/wp-framework-' ) ) { - $stack = \array_merge( $stack, self::stack( \substr( $name, \strlen( 'ahegyes/wp-framework-' ) ) ) ); - } - } - - return \array_values( \array_unique( \array_merge( $stack, array( $package ) ) ) ); - } - protected static function read_json( string $path ): array { return \json_decode( (string) \file_get_contents( self::ROOT . "/$path" ), true, flags: \JSON_THROW_ON_ERROR ); } diff --git a/tests/Unit/Shared/Result/ResultTest.php b/tests/Unit/Shared/Result/ResultTest.php index b7402c1a..1362af77 100644 --- a/tests/Unit/Shared/Result/ResultTest.php +++ b/tests/Unit/Shared/Result/ResultTest.php @@ -44,8 +44,12 @@ static function ( mixed $error ) use ( &$calls ): string { self::assertSame( array( array( $outcome, $payload ) ), $calls ); } - #[DataProvider( 'outcomes' )] - public function test_the_predicates_agree_with_the_outcome( AbstractResult $result, string $outcome, mixed $payload ): void { + public static function results(): array { + return \array_map( static fn ( array $outcome ): array => \array_slice( $outcome, 0, 2 ), self::outcomes() ); + } + + #[DataProvider( 'results' )] + public function test_the_predicates_agree_with_the_outcome( AbstractResult $result, string $outcome ): void { self::assertSame( 'success' === $outcome, $result->is_success() ); self::assertSame( 'failure' === $outcome, $result->is_failure() ); } diff --git a/tests/Unit/Shared/WordPressIsolationTest.php b/tests/Unit/Shared/WordPressIsolationTest.php index 31a9d566..7a308f3f 100644 --- a/tests/Unit/Shared/WordPressIsolationTest.php +++ b/tests/Unit/Shared/WordPressIsolationTest.php @@ -2,21 +2,20 @@ namespace DeepWebSolutions\Framework\Tests\Unit\Shared; +use DeepWebSolutions\Framework\Tests\Support\RunsProcessesTrait; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; #[CoversNothing] final class WordPressIsolationTest extends TestCase { + use RunsProcessesTrait; + public function test_a_wordpress_call_in_shared_code_fails_the_shared_phpstan_config(): void { $root = \dirname( __DIR__, 3 ); $fixture = "$root/tests/Fixtures/Shared/wordpress-call.php"; - $command = \implode( - ' ', - \array_map( 'escapeshellarg', array( \PHP_BINARY, "$root/vendor/bin/phpstan", 'analyse', '--configuration', "$root/phpstan.shared.neon", '--error-format', 'json', '--no-progress', $fixture ) ) - ); - \exec( "$command 2>/dev/null", $output ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- The test runs the real PHPStan. - $report = \json_decode( \implode( "\n", $output ), true, flags: \JSON_THROW_ON_ERROR ); + list( , $output ) = self::run_process( array( \PHP_BINARY, "$root/vendor/bin/phpstan", 'analyse', '--configuration', "$root/phpstan.shared.neon", '--error-format', 'json', '--no-progress', $fixture ) ); + $report = \json_decode( $output, true, flags: \JSON_THROW_ON_ERROR ); self::assertSame( array( 'function.notFound' ), \array_column( $report['files'][ $fixture ]['messages'] ?? array(), 'identifier' ) ); } diff --git a/tests/Unit/Support/ProvidesPackagesTrait.php b/tests/Unit/Support/ProvidesPackagesTrait.php new file mode 100644 index 00000000..09d2924a --- /dev/null +++ b/tests/Unit/Support/ProvidesPackagesTrait.php @@ -0,0 +1,51 @@ + array( 'php' => '>=7.4' ), + 'shared' => array( 'php' => '>=8.5' ), + 'core' => array( + 'ahegyes/wp-framework-bootstrap' => '^2.0', + 'ahegyes/wp-framework-shared' => '^2.0', + 'ext-filter' => '*', + 'php' => '>=8.5', + 'psr/clock' => '^1', + 'psr/container' => '^2.0.2', + 'psr/log' => '^3.0.2', + 'psr/simple-cache' => '^3', + ), + 'settings' => array( + 'ahegyes/wp-framework-core' => '^2.0', + 'php' => '>=8.5', + ), + 'woocommerce' => array( + 'ahegyes/wp-framework-settings' => '^2.0', + 'php' => '>=8.5', + ), + ); + + public static function packages(): array { + $packages = array(); + foreach ( self::REQUIRES as $package => $requires ) { + $packages[ $package ] = array( $package, $requires ); + } + + return $packages; + } + + public static function package_names(): array { + return \array_map( static fn ( array $package ): array => array( $package[0] ), self::packages() ); + } + + // Lists every package below the given one before it, as the packages' READMEs list their mirrors. + protected static function closure( string $package ): array { + $closure = array(); + foreach ( \array_keys( self::REQUIRES[ $package ] ) as $name ) { + $closure = \array_merge( $closure, \str_starts_with( $name, 'ahegyes/wp-framework-' ) ? self::closure( \substr( $name, \strlen( 'ahegyes/wp-framework-' ) ) ) : array( $name ) ); + } + + return \array_values( \array_unique( \array_merge( $closure, array( "ahegyes/wp-framework-$package" ) ) ) ); + } +}