diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index e2f48822..a70c5f16 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -92,4 +92,4 @@ jobs: with: artifact: consumer-a php-versions: '["7.4"]' - paths: '["vendor-prefixed/ahegyes/wp-framework-bootstrap", "acme-consumer-a.php", "uninstall.php", "includes"]' + paths: '["vendor-prefixed/ahegyes/wp-framework-bootstrap", "acme-consumer-a.php", "uninstall.php", "Safeguards.php"]' diff --git a/AGENTS.md b/AGENTS.md index 20bdf642..8b02c525 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,8 +14,9 @@ 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 (is_development_environment (@internal)), ComponentInterface, CompositeComponentInterface, ConditionalComponentInterface +│ └── src/ psr-4 DeepWebSolutions\Framework\Core\ · PluginKernel, PluginBoot (is_development_environment (@internal)), PluginIdentity, ComponentInterface, CompositeComponentInterface, ConditionalComponentInterface │ ├── AdminNotice/{AdminNotices, Notice, NoticeType, NoticeScope} +│ ├── Asset/Assets │ ├── Caching/{TransientCache, ObjectCache, CacheEntries (@internal), Exceptions/InvalidCacheKeyException} │ ├── Clock/SystemClock · Logging/{AbstractLogger, ErrorLogLogger, GuardedLogger (@internal)} │ ├── Migration/{MigrationRunner, BatchedMigrationStepInterface} @@ -76,13 +77,13 @@ actionlint && zizmor .github/ ## Conventions 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. +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 framework class that needs the plugin's entry file, slug or prefix takes its `PluginIdentity`, which the composition root builds once from its constants and defines in the container; only bootstrap's by-path classes take the entry file. 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 and the plugin's identity 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 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. +5. **Safeguards.** A plugin's safeguards are one final class in `Safeguards.php` at the plugin root, beside the entry, which no autoload root maps; everything a safeguard calls lives in it or files it requires. The entry requires it by path before `load.php` and the floor gate and calls its one public static entry point, so a safeguard runs on any runtime that parses the plugin: it parses on PHP 7.4 and calls only what its hook hands it, WordPress APIs present at the oldest WordPress the bootstrap package runs on, which `phpstan.bootstrap.neon` sets, and, in a plugin that requires WooCommerce, WooCommerce APIs present since 3.6, guarding anything newer. A safeguard that calls WooCommerce is hooked from the class's one `woocommerce_loaded` callback, since WordPress loads a plugin while WooCommerce is inactive. Each safeguard is a static closure, which no code can remove by name, so relaxation happens only through the safeguard's own filter, after `{root}/boot/succeeded`, on an explicit `false`; helpers are protected, and only what other classes call is public. +6. **Shared logic.** The global namespace holds only a plugin's contract. `functions.php` declares its `@api` functions, the accessor among them, which returns the composition root while the root's members stay internal unless tagged `@api`, and registers its hooks as static closures; `includes/`, which `functions.php` loads, holds more `@api` functions and constants, grouped by topic. Every other symbol is a member of a final class in `src/` named for its concept: the composition root, which also holds the footprint, a component, or a class that registers no hooks, such as `Options`, whose constants name the stored settings and their defaults and whose public statics read them, and `Migrations`, whose one public method lists its protected static steps as first-class callables, while a batched step is a final class of its own and a meta key stays on the class that stores it. Built scripts and styles register through core's `Assets`, which the container defines. No namespace or folder is named for being internal. 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 `''`. +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 build every option, transient and meta name they store from the identity's prefix, as `{prefix}_`, so the plugin's footprint covers it: the runner's options, which start with `{prefix}_revision`, the grants' `{prefix}_capabilities` ledger, a `Lock`'s `{prefix}_{name}`, and a `TransientCache` region's generation option, `{prefix}_{generation}_cache_generation`, whose entries, transients that all carry a timeout, expire on their own. An `ObjectCache` region's group, `{prefix}_{region}`, keeps it apart from core's and other plugins' groups. The runner's scheduled hook, `{hook_root}/migration/run`, and the scheduler's Action Scheduler group, the bare `{prefix}`, fall outside it, so the footprint names them under `cron_hooks` and `as_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/`. @@ -100,7 +101,7 @@ Releases follow SemVer on this contract from the first tag: a break of it ships - No package ships free functions or a Composer `files` entry. A stateless function family is a final class with public static entry points, protected static helpers and a protected constructor. An operation that carries state across its steps is an object, and constant data is a constant. On a final class, a protected helper with a single call site is inlined, unless it is a `match` target, a hook callback or an override, or carries a `@phpstan-assert*` tag. Logic several classes share has one implementation: when its callers are in one package, an `@internal` final collaborator or an `@internal` public static on the class that owns the concept, and when they span packages, the smallest public API in the lowest package that needs it. A protected copy needs the owner's ruling. A lone call to a WordPress, WooCommerce or PHP API is not a copy, and neither is an idiom WPCS needs to see at each call site; the framework knowledge wrapped around such a call is. A copy also stays where no single home loads for every caller, as between PHP 7.4 by-path code and autoloaded code. The by-path classes register hooks with static closures, which bind each registration without static state, and a failed boot or boot check hooks its notice as a static closure; every other hook callback is a public named method. A framework method takes a callable only for consumer code it runs inside its own envelope, as the boot composition, migration steps and the uninstall cleanup, or for per-field logic in field arrays, and spells it `callable`; a helper's definition comes from a definition object, and an envelope inside the framework is written inline, with the rule it applies in one home. - A name imported from `Psr\` takes a `Psr` alias, as in `use Psr\Log\LoggerInterface as PsrLoggerInterface;`, so every use shows that the type is a PSR interface or class. Only PHP's own functions and constants take a leading `\`, which lets opcache compile the functions it special-cases; WordPress, WooCommerce and Action Scheduler functions and constants are unqualified, and classes are imported or fully qualified. - Every element outside `tests/` has a docblock: one third-person sentence naming the role, such as "Returns …", plus a second paragraph only for a non-obvious why. Property and constant docblocks open with "The …", constructors read "Constructor.", and an override reads `{@inheritDoc}`. Every other function and method docblock ends with `@return`, `@return void` included. Tag values align at column 10, a file docblock's `@package` sits in its own group after `@since` and `@version`, a function or method that declares a `global` documents it with `@global` in its own group between `@version` and `@param`, each `@param` stays on one line, `@throws` reads "Thrown when …", `@since` names the release that added the element, and `@version` the last release that changed its behavior or signature. -- A hook name is `{prefix}/area/name`, with `/` between levels and `_` between words, and its last level says what kind of hook it is, because WordPress tells actions from filters by no name form: a past participle for an event (`{prefix}/boot/succeeded`), `before_` or `after_` for a position, an imperative for a scheduled or command hook (`run`), a predicate (`is_`, `has_`, `can_`, `should_`) for a boolean filter (`{prefix}/admin_notice/should_show`), and the noun of the value for any other filter, plural for a list. A hook built from an id or option name the consumer passes takes it whole as its first level, `{id}/{surface}/{leaf}` (`{id}/object_form/fields`, `{option}/migration/run`), because the id also names a nonce, an option or a DOM id, and any other dynamic part goes last (`dws_github_updater/config/{slug}`). +- A hook name is `{root}/area/name`, its root the plugin's slug with underscores for its dashes, with `/` between levels and `_` between words, and its last level says what kind of hook it is, because WordPress tells actions from filters by no name form: a past participle for an event (`{root}/boot/succeeded`), `before_` or `after_` for a position, an imperative for a scheduled or command hook (`run`), a predicate (`is_`, `has_`, `can_`, `should_`) for a boolean filter (`{root}/admin_notice/should_show`), and the noun of the value for any other filter, plural for a list. A hook that a surface fires for each of its instances passes the instance's id as its last argument (`{root}/object_form/fields`), and any other dynamic part of a name goes last. - Every `do_action()` and `apply_filters()` with a public hook name carries a WordPress hook docblock directly above the call: "Fires …" or "Filters …", `@since`, `@version` and one `@param` per argument, which PHPStan checks against the call's arguments. - Classes and traits outside `tests/` group members in `// region NAME` blocks named, in order, TRAITS, FIELDS AND CONSTANTS, MAGIC METHODS, GETTERS, SETTERS, INHERITED METHODS, METHODS, FACTORY METHODS, HOOKS and HELPERS; exception classes, interfaces and enums carry none. - Inline comments state a non-obvious why in one capitalized sentence, and every file describes the present, never the change. A `phpcs:ignore` trails its line, names the sniff and gives a one-clause reason; a `@phpstan-ignore` names its identifier. @@ -111,11 +112,11 @@ Releases follow SemVer on this contract from the first tag: a break of it ships ## Limitations -1. A throw in `register_hooks()` leaves earlier hooks live, and side effects are never undone. Safeguards still ignore relaxations, because `{prefix}/boot/succeeded` never fires. Gates evaluate once, at `plugins_loaded`. Components gated on the data revision switch on one request after the migrating request. The request that activates the plugin does not boot it, because WordPress includes the plugin's file after `plugins_loaded`, so its post types and endpoints first register on the next request. -2. A request killed mid-migration holds the lock until 10 minutes after the step or batch it was running began, then the step re-runs and the error row names the step that did not finish. One primary database is assumed. While a plain step's failure persists in production, each request issues one `INSERT IGNORE` plus one `SELECT`, one more `SELECT` until a first step succeeds, and admin screens read the error row; in local and development environments the lock is released, so every request re-runs the failing step. While a batched step is pending, a request outside the runner's hook takes no lock and only looks the hook up in the scheduler, and while its failure persists, each queue run fires the runner's hook to no effect and the failure is logged every 10 minutes. A WP-CLI process is one request, so once it has spent the runner's time budget, a long `wp action-scheduler run` stops running the migration, and the next request or process continues it. After a rollback, older code runs on newer data until the plugin is upgraded again; the written-back revision then re-runs the newer steps, and `{option}_high_water` keeps the highest revision reached. Mixed-version rolling deploys may re-run steps, which converge. +1. A throw in `register_hooks()` leaves earlier hooks live, and side effects are never undone. Safeguards still ignore relaxations, because `{root}/boot/succeeded` never fires. Gates evaluate once, at `plugins_loaded`. Components gated on the data revision switch on one request after the migrating request. The request that activates the plugin does not boot it, because WordPress includes the plugin's file after `plugins_loaded`, so its post types and endpoints first register on the next request. +2. A request killed mid-migration holds the lock until 10 minutes after the step or batch it was running began, then the step re-runs and the error row names the step that did not finish. One primary database is assumed. While a plain step's failure persists in production, each request issues one `INSERT IGNORE` plus one `SELECT`, one more `SELECT` until a first step succeeds, and admin screens read the error row; in local and development environments the lock is released, so every request re-runs the failing step. While a batched step is pending, a request outside the runner's hook takes no lock and only looks the hook up in the scheduler, and while its failure persists, each queue run fires the runner's hook to no effect and the failure is logged every 10 minutes. A WP-CLI process is one request, so once it has spent the runner's time budget, a long `wp action-scheduler run` stops running the migration, and the next request or process continues it. After a rollback, older code runs on newer data until the plugin is upgraded again; the written-back revision then re-runs the newer steps, and `{prefix}_revision_high_water` keeps the highest revision reached. Mixed-version rolling deploys may re-run steps, which converge. 3. Uninstall visits every site of a network and cleans each one that consents. The engine needs the plugin's PHP and WordPress floors, so where a site fell below them a web request deletes the plugin's files and leaves its data and cron events until the plugin is installed and deleted again on a compliant runtime, while WP-CLI fails and keeps the files. A throw from another plugin's hook ends that site's pass, and the rest of its footprint and its consent row wait for the next uninstall. A web request on a large network refuses and names `wp plugin uninstall`; on a network of thousands of sites, or on a single site whose footprint holds many rows, a web request can still exceed the server's time limit, and a retry or `wp plugin uninstall` completes it. Where the object cache cannot flush its runtime cache, every visited site's options stay in memory until the request ends. Network options and network-global user meta go only on a single site or on the consent constant. Exact `options`, `transients` and `network_options` names match under the database collation, as WordPress's option API matches them. An `option_prefixes` or `user_meta_global_prefixes` entry that the table prefix starts with, such as `wp_` on a default install, also deletes the site's roles and every user's capabilities, which WordPress names after the table prefix; WordPress reserves `wp_` for itself. On an installation of several networks, the large-network bound counts the current network's sites, and the network pass deletes every network's options but clears only the current network's cached values. Action Scheduler actions stay queued when it is not loaded at uninstall, and fail once it runs them, since no callback is registered; on a site without its tables, unscheduling logs a database error and does nothing else; a cancel Action Scheduler's default store cannot write repeats until the request's time limit; a job that cannot be unscheduled is logged and left. 4. Over WC REST, `validate` receives WooCommerce's normalized value: unknown multiselect choices are already dropped and text already sanitized. A rejected value is not stored, but the response echoes it. Grouped `option[key]` ids would bypass the bridge, hence flat ids. WC REST lists only WooCommerce's own field types, so custom-type and `checkboxes` fields are not reachable there. A product field's `validate` runs only on the classic product edit form; WC REST `meta_data`, CSV import and CRUD writes store what they are given. An object form field's `validate` runs only on its form; REST, imports, meta and order CRUD writes store what they are given, and so does a Custom Fields box for a key without a leading underscore: core's on a post type that supports `custom-fields`, orders in the posts table included, and WooCommerce's on the order tables' edit screen. WC-rendered controls carry WooCommerce's markup, and the row printed around a custom-type or `checkboxes` field on a WooCommerce settings screen or the product data panel imitates it. On a WooCommerce settings screen a field's `render` callable needs a type WooCommerce does not draw: on one of its own types WooCommerce draws its control and the callable never runs. The framework guarantees markup only for `Forms::render_control()`. `OptionsPage` and `ProductDataTab` report a rejected field in the page's notices, not beside the field, which after the redirect shows its stored value rather than the rejected input. `ObjectForm` reports it as a one-shot user notice through the plugin's `AdminNotices`, shown in the post editor after the save, or else on the user's next admin screen of the site it was saved on, the network and user admin counting as the main site's; a newer rejection of the same field, on any object, replaces it, and a rejection whose notice fails to save goes unreported. On the profile and in the block editor, which never validate the form, a number the browser cannot parse posts as empty and clears the stored value. With WooCommerce's beta `settings-ui` feature on, the sections a framework tab defines stay classic, and a framework section registered on any tab renders in its Settings UI, which saves through the same form post. A field the Settings UI's schema refuses, such as a `checkboxes` or custom-type field holding an array or a radio listing disabled choices, renders that section classic, with a `_doing_it_wrong` notice under `WP_DEBUG`; a field it has no control for, such as one of those types holding no array or WooCommerce's `color`, makes the page fail to render and offer WooCommerce's "Use classic settings" link, under which every field saves. On a WooCommerce settings screen a cleared checkbox or multiselect posts nothing, so one the section does not render at save is stored cleared. -5. Partial bootstrap package: if `load.php` exists but a file it requires is missing, the `Error` is thrown before the template's catch and the plugin fatals at include; its uninstall shim catches it, except on PHP 7.4, where a failed `require` is fatal. A missing `vendor-prefixed/` unregisters the updater. Core and woocommerce print their boot and migration notices through bootstrap's `Requirements`, so a plugin that boots them without requiring `load.php` fatals when one of those notices renders. Update offers come only from WordPress's own plugin update check. While its wordpress.org request fails, the check offers no Update URI release, so a new release waits for a later check that succeeds. A plugin active only on sites other than the main one is never offered a release, because WordPress checks only on the main site and in the network admin. +5. Partial bootstrap package: if `load.php` exists but a file it requires is missing, consumer-a's entry, which requires it inside its one `try`, shows the build notice, or rethrows in local and development environments, while an entry that requires it outside a `try` fatals at include; the uninstall shim keeps the data on a web request, while WP-CLI fails and keeps the files. On PHP 7.4 a failed `require` is fatal at include either way. A missing `vendor-prefixed/` unregisters the updater. Core and woocommerce print their boot and migration notices through bootstrap's `Requirements`, so a plugin that boots them without requiring `load.php` fatals when one of those notices renders. Update offers come only from WordPress's own plugin update check. While its wordpress.org request fails, the check offers no Update URI release, so a new release waits for a later check that succeeds. A plugin active only on sites other than the main one is never offered a release, because WordPress checks only on the main site and in the network admin. 6. Deactivation and recovery mode unload safeguards, as for any plugin. Plugins whose data would leak (IC) hide it at the data level. 7. Scheduling and one-shot notices are best effort. Either scheduler can run an invocation twice or not at all, and work advances only with site traffic, Action Scheduler's admin dispatch, or a system cron or WP-CLI runner. A database write that keeps failing keeps Action Scheduler's bulk cancel looping until the request's time limit. On hosts that replace WP-Cron's store, the WP-Cron backend still reads only the `cron` option, so it may not see the host's events. Action Scheduler compares hook names and arguments under the database collation, so identities that differ only in letter case are one there. Action Scheduler stores a string argument holding invalid UTF-8 with those bytes replaced, which its callbacks then receive, while WP-Cron's write of it fails, because WordPress refuses invalid UTF-8 in the options table. Two concurrent renders can both show a one-shot notice, a request that dies after output shows it again, and a block-editor delivery whose response the editor never receives, as when the user leaves during it, loses its one-shots. Block-editor screens hide classic notices: the post editor fetches the site, network and user one-shots that name no screens as it loads and after each save that is not an autosave, while persistent and screen-limited notices wait for a classic screen, as every notice does on the other block editors. The post editor shows a notice's markup through core/notices' `__unstableHTML` option, which a WordPress release may change or remove. It reads each notice aloud through `wp.a11y.speak()`, which drops text between angle brackets, so a literal angle-bracketed placeholder in a message goes unspoken. A dismissal, or a one-shot's showing, applies to the notice's id rather than to the copy the page showed, so a notice saved again under that id after the page showed it is deleted or hidden too. Two writes to one scope's notices, or to one viewer's dismissals, at the same moment can lose one of them: a notice added then never shows, a dismissal is undone, or a consumed one-shot shows once more. A notice whose write fails is lost, and logged only when the write throws. 8. Capability grants act on `wp_loaded` after the declaration changes or a declared role appears or is deleted, and the current user sees a grant or revocation from the next request. A granted capability removed or denied by hand stays so; a declared role deleted and created again is granted again, unless it was deleted while the plugin was inactive, which then reads as a hand removal. WP_Roles writes the whole roles row from the request's copy, so another request's role write in between is lost. A change the row does not keep is logged and made again on every request, which never ends on a site whose roles are defined in code (`$wp_user_roles`) or locked. diff --git a/composer.json b/composer.json index 00830f40..f4ba15f1 100644 --- a/composer.json +++ b/composer.json @@ -41,10 +41,10 @@ "automattic/jetpack-changelogger": "^7", "johnpbloch/wordpress-core": "^7.1", "nikic/php-parser": "^5", - "php-stubs/woocommerce-stubs": "^11.2", + "php-stubs/woocommerce-stubs": "^11.3", "phpunit/phpunit": "^13", "wp-plugin/plugin-check": "^2.2", - "wp-plugin/woocommerce": "^11.2" + "wp-plugin/woocommerce": "^11.3" }, "minimum-stability": "dev", diff --git a/composer.lock b/composer.lock index 8e4cbc5d..0475c730 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fb3874ebd4d6f43289e9e2c869f18d23", + "content-hash": "3ec2376675c0d84fa7ec6bb5cbb887b5", "packages": [ { "name": "ahegyes/wp-framework-bootstrap", @@ -148,7 +148,7 @@ "dist": { "type": "path", "url": "packages/woocommerce", - "reference": "390b60d89ae0ec911501d74ebd77e7255cafc462" + "reference": "824d11020a708f10f57e37ebe7b944bcdcba5c86" }, "require": { "ahegyes/wp-framework-settings": "^2.0", @@ -160,6 +160,7 @@ "dev-trunk": "2.x-dev" }, "requires-wp": "7.1", + "requires-wc": "11.3", "requires-plugins": [ "woocommerce" ], @@ -1357,16 +1358,16 @@ }, { "name": "php-stubs/woocommerce-stubs", - "version": "v11.2.2", + "version": "v11.3.0", "source": { "type": "git", "url": "https://github.com/php-stubs/woocommerce-stubs.git", - "reference": "d5c232c183c09689d75c177b01bef60e6dd53639" + "reference": "a897f9ad0552482e9d50d3937daab20dfb97265c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/d5c232c183c09689d75c177b01bef60e6dd53639", - "reference": "d5c232c183c09689d75c177b01bef60e6dd53639", + "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/a897f9ad0552482e9d50d3937daab20dfb97265c", + "reference": "a897f9ad0552482e9d50d3937daab20dfb97265c", "shasum": "" }, "require": { @@ -1395,10 +1396,10 @@ ], "support": { "issues": "https://github.com/php-stubs/woocommerce-stubs/issues", - "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.2.2" + "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.3.0" }, - "time": "2026-11-04T21:47:17+00:00", - "published-time": "2026-11-04T21:48:23+00:00" + "time": "2026-11-12T00:45:07+00:00", + "published-time": "2026-11-12T00:47:40+00:00" }, { "name": "php-stubs/wordpress-stubs", @@ -2566,12 +2567,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4" + "reference": "9fb1049dda127f1732cac541e68f8e490db77825" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4", - "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", + "reference": "9fb1049dda127f1732cac541e68f8e490db77825", "shasum": "" }, "conflict": { @@ -2653,6 +2654,7 @@ "barzahlen/barzahlen-php": "<2.0.1", "baserproject/basercms": "<=5.2.2", "bassjobsen/bootstrap-3-typeahead": ">4.0.2", + "bazaarvoice/bazaarvoice-magento2-ext": ">=8,<9.1.11", "bbpress/bbpress": "<2.6.5", "bcit-ci/codeigniter": "<3.1.3", "bcosca/fatfree": "<3.7.2", @@ -2724,9 +2726,9 @@ "contao-components/colorbox": ">=1,<1.6.4.3-dev", "contao-components/mediaelement": ">=2.14.2,<2.21.1", "contao/comments-bundle": ">=2,<5.3.50|>=5.4,<5.7.12", - "contao/contao": ">=3,<3.5.37|>=4,<5.3.50|>=5.4,<5.7.12", + "contao/contao": ">=3,<3.5.37|>=4,<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6", "contao/core": "<3.5.39", - "contao/core-bundle": "<5.3.50|>=5.4,<5.7.12", + "contao/core-bundle": "<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6", "contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8", "contao/managed-edition": "<=1.5", "contao/newsletter-bundle": ">=5,<5.3.50|>=5.4,<5.7.12", @@ -2936,6 +2938,7 @@ "gp247/core": "<1.1.24", "gree/jose": "<2.2.1", "gregwar/rst": "<1.0.3", + "grpc/grpc": "<1.84.1", "grumpydictator/firefly-iii": "<=6.6.2", "gugoan/economizzer": "<=0.9.0.0-beta1", "guzzlehttp/guzzle": "<7.15.2|>=8,<8.0.1", @@ -3106,7 +3109,7 @@ "mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7", "maximebf/debugbar": "<1.19", "mckenziearts/livewire-markdown-editor": "<1.3", - "mcp/sdk": ">=0.5,<0.7.1", + "mcp/sdk": ">=0.5,<0.7.1|>=0.8,<0.9", "mdanter/ecc": "<2", "mediaessenz/mail": "<3.12.5|>=4,<4.2", "mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2", @@ -3257,7 +3260,7 @@ "phpoffice/phpexcel": "<=1.8.2", "phpoffice/phpspreadsheet": "<=1.30.6|>=2,<=2.1.18|>=2.2,<=2.4.7|>=3,<=3.10.7|>=4,<=5.9", "phppgadmin/phppgadmin": "<=7.13", - "phpseclib/phpseclib": "<3.0.57|>=4,<4.0.1", + "phpseclib/phpseclib": "<3.0.58|>=4,<4.0.3", "phpservermon/phpservermon": "<3.6", "phpsysinfo/phpsysinfo": "<=3.4.5", "phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6|>=13.4,<13.4.4", @@ -3734,8 +3737,8 @@ "type": "tidelift" } ], - "time": "2026-11-10T03:11:40+00:00", - "published-time": "2026-11-10T03:11:44+00:00" + "time": "2026-11-12T14:42:16+00:00", + "published-time": "2026-11-12T14:42:22+00:00" }, { "name": "sebastian/cli-parser", @@ -6236,15 +6239,15 @@ }, { "name": "wp-plugin/woocommerce", - "version": "11.2.2", + "version": "11.3.0", "source": { "type": "svn", "url": "https://plugins.svn.wordpress.org/woocommerce/", - "reference": "tags/11.2.2" + "reference": "tags/11.3.0" }, "dist": { "type": "zip", - "url": "https://downloads.wordpress.org/plugin/woocommerce.11.2.2.zip" + "url": "https://downloads.wordpress.org/plugin/woocommerce.11.3.0.zip" }, "require": { "composer/installers": "~1.0|~2.0" @@ -6263,8 +6266,8 @@ "issues": "https://wordpress.org/support/plugin/woocommerce", "source": "https://plugins.svn.wordpress.org/woocommerce" }, - "time": "2026-11-04T21:34:49+00:00", - "published-time": "2026-11-04T21:34:49+00:00" + "time": "2026-11-11T16:56:56+00:00", + "published-time": "2026-11-11T16:56:56+00:00" } ], "aliases": [], diff --git a/package-lock.json b/package-lock.json index 1ff8ad7a..857b3203 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,21 +65,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.13.tgz", + "integrity": "sha512-j1nvOQCZYSgp7HLmisnCXFTpVvFODiijQ9QHkORfGKWH6YDtskTEeCJS7eV545GZ9UBzrq0e2xsOd5QQ5PjIqw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/generator": "^7.29.13", + "@babel/helper-compilation-targets": "^7.29.13", + "@babel/helper-module-transforms": "^7.29.13", + "@babel/helpers": "^7.29.13", + "@babel/parser": "^7.29.13", "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/traverse": "^7.29.13", + "@babel/types": "^7.29.11", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -106,13 +106,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.12", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.12.tgz", - "integrity": "sha512-2W76mhjrpQFO1lK5e8IvGYB2D0YZ7S2R1Z2BjO2ZUYuW+amcpK/z60U6ZZmzskwoa7sJ6XohP/Wg8H5KxXI/IA==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.13.tgz", + "integrity": "sha512-T5FRo+RzVHvGjWOuF1Ljc44b+NXAOazodHkGUHp4I3zCkQ4/sNVJm56h1S278LMR0bY5xBhztGziSbli8zObIw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.12", + "@babel/parser": "^7.29.13", "@babel/types": "^7.29.11", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", @@ -136,9 +136,9 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.13.tgz", + "integrity": "sha512-8WxJ4/Vmes3ghacj8VOJy3NW8eyHiFY/4thU/LSBuBE470+igjR/j1ZTNn8fV5VdNrLx6iXUwig526UdOvfP0w==", "dev": true, "license": "MIT", "dependencies": { @@ -163,9 +163,9 @@ } }, "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", - "integrity": "sha512-ACxGoWMV866q/QlZHeCOaQZAR5QclKsIvkAmlDemqjm6+6Bynijs1mH068BzH2N2e+wSuDmBWY87fqKfvhi1Lw==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.13.tgz", + "integrity": "sha512-fo8/Fr44/DMV71FQjk/HA0Npmu3bo5DFoWTCW1dhUR8Vx3NFfAVeEy0TbcBpt4e+hd2QhdmWgmspHo/9iWj8RA==", "dev": true, "license": "MIT", "dependencies": { @@ -173,8 +173,8 @@ "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", - "@babel/traverse": "^7.29.12", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.13", + "@babel/traverse": "^7.29.13", "semver": "^6.3.1" }, "engines": { @@ -278,15 +278,15 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.13.tgz", + "integrity": "sha512-/U96wOz+SCoZ7Da/8kjDGltcNY280JPQcRNPK0Hk3PTbdpaiBDQImtC6JsbVwb1HGMr3usZQU2WsWVjhYNmRzw==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" + "@babel/traverse": "^7.29.13" }, "engines": { "node": ">=6.9.0" @@ -355,23 +355,23 @@ } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", - "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.13.tgz", + "integrity": "sha512-Abm4rKIH0tmCayJLwcoeOkdzpYUphTbO52fHhyEBHn7S0R+ni4epNLI/8nq1L0DN2Bl0u5qmgWm2Og/z4fBb+Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" + "@babel/traverse": "^7.29.13", + "@babel/types": "^7.29.11" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.29.11", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", - "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.13.tgz", + "integrity": "sha512-fWegr+WsEsiMVW4KUNdIIzzMTZSzNiqhy0//6+2MRi2d5NFNsL/AYwBQW8eBnRbTBVRnDVzjaJEUqKaCWjMMdQ==", "dev": true, "license": "MIT", "engines": { @@ -414,23 +414,23 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.10", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", - "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.13.tgz", + "integrity": "sha512-laoRpD/kO/iA4CDlgJbixHekg7oPvWALK0PQyK3Ob2I2njZRvVAD77RfWCIImtqWCq6KijoBoZK7bp/HlTWASQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8" + "@babel/types": "^7.29.11" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.12", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz", - "integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.13.tgz", + "integrity": "sha512-fJM50YYjlTiBhgdkGmC1tBpv+FMAyo4annf2X6Aks+0yRlaiVkYW/3f07xAfVVkYIoZ9fFZp+mEThUFnuybhkQ==", "dev": true, "license": "MIT", "dependencies": { @@ -493,14 +493,14 @@ } }, "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz", - "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.13.tgz", + "integrity": "sha512-7RpEgFZq9xNRHsWwPLFQpjPOi0ZECE2QZWMBes+vgK7E02krjOfPiYtnHTIUF2nCDyeCJPvSAj0AP4NTVf4hsw==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.13" }, "engines": { "node": ">=6.9.0" @@ -707,9 +707,9 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.29.12", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.12.tgz", - "integrity": "sha512-nEreZBofjfd6FG621xe9Rk3+tMGBXf1nAuFrR6NUGF67fkQbSQf3NrbRuhgWRn+zb0qcy9XVok1GVFI/t9wWVQ==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.13.tgz", + "integrity": "sha512-q7f5zYLtDLqO11ydQK1w2RqZ1m5kmcAlmgZlfXssKgqVyL8+HgDvWXMxvuEz8TuJfq1UNsY9sB6ABjjtQkN6wA==", "dev": true, "license": "MIT", "dependencies": { @@ -795,14 +795,14 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", - "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.13.tgz", + "integrity": "sha512-X2meXB39NOPPmmshSRJzyd50hX9uCzbscNQwVCjLCtq363PZIJFFxJIeZPpgAewC570vpdeGIHTcltwd0QUlJg==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", - "@babel/traverse": "^7.29.7" + "@babel/traverse": "^7.29.13" }, "engines": { "node": ">=6.9.0" @@ -927,14 +927,14 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", - "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.13.tgz", + "integrity": "sha512-dFjiIw+xBYpgLhYDc1QFQOZmMX44se0YCmXUjPsm9SQEj9HaCJAjvVnK6/yK9iE4E1LaKU5fIAKVJPZWCbKwXw==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.13" }, "engines": { "node": ">=6.9.0" @@ -1812,16 +1812,16 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.12", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.12.tgz", - "integrity": "sha512-MscXe/NAZcOkYEsRajHZFt3ynsb22PUi6CTrp5U7+RYycR9By84mn9mFN54YgfITiqK4hFx3Ju3tZSyzfpS5ew==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.13.tgz", + "integrity": "sha512-0ve0xMlFA73dgY1oyJRY6RoW+/bncdcOQqg6jjYcVfXBK90OdLXjm9R6lmRetdZVrPjXkKIoMVBqYFPjcAlZ4Q==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.12", + "@babel/generator": "^7.29.13", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.12", + "@babel/parser": "^7.29.13", "@babel/template": "^7.29.7", "@babel/types": "^7.29.11", "debug": "^4.3.1" @@ -3009,6 +3009,13 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@isaacs/nonrecursive-glob-match": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz", + "integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -5659,15 +5666,15 @@ "license": "MIT" }, "node_modules/@sentry/bundler-plugins": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-11.10.0.tgz", - "integrity": "sha512-+ngJ485rIYll5uOhWSvSO+Vx4S24MGe5KcLKlUZghr+BXK+ib32uBOZNG294wGyeauLTDvdjncLEYwiaBnP/5w==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-11.11.0.tgz", + "integrity": "sha512-et1v8V74ARNOSQ+k4VG7PRiNJl2APGAzlRCiUt42izw8yD+X+E4oCe7wwHjohmv3zufvD1VntHDptSC0/9ChQw==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/core": "11.10.0", + "@sentry/core": "11.11.0", "dotenv": "^17.4.2", - "glob": "^13.0.6", + "glob": "^14.0.3", "magic-string": "~0.30.8 || ^1.0.0", "oxc-parser": "^0.152.0", "sentry": "^0.45.0", @@ -5706,9 +5713,9 @@ } }, "node_modules/@sentry/conventions": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.29.0.tgz", - "integrity": "sha512-kYq9/HJ/l6MYP/srIc20dy1W4mWyJfCF0OWFF+RhBGZwyr/wCmtabRFKyw7kqM+DTwZXQOjSTSZUcJhDowcInQ==", + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.30.0.tgz", + "integrity": "sha512-WTumd5A2iFmHTRubi6mc6gLHU8LswPQqoNaZu6T1cxiTUmy3WdEw0i8FAnVbcgxTlsumX5oh63/b4B9lY5wAPQ==", "dev": true, "license": "MIT", "engines": { @@ -5716,46 +5723,46 @@ } }, "node_modules/@sentry/core": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-11.10.0.tgz", - "integrity": "sha512-l/OtTo1v+TQgZtHG2Q5N4IX+gFdAE1tZ27K/P21UCzJglWk54TPEm+d9T2lulPqMK06kZcLwDgRAhE2GO4YS5w==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-11.11.0.tgz", + "integrity": "sha512-PRrcEdPrTYuHcbDa6DhJZR9Qg2d+2gjKVXiv2QeiTgxvhvXDxYiaVSXH0lldKTR099Er5TAEChiX7aEu4yNlUw==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/conventions": "^0.29.0" + "@sentry/conventions": "^0.30.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" } }, "node_modules/@sentry/node": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-11.10.0.tgz", - "integrity": "sha512-ZsndrSn1NjL9AslpUdxz/kYPcv/i+plVZoyPe1ivlr5XPUpocB8pUL5noYbIUl96Wd/yGvPBw6zqO8d69rRKQQ==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-11.11.0.tgz", + "integrity": "sha512-6hbFui4AwP/1KGQD7wgvG0rjve4vgb9x+cBAaHV2N7YwZDZUoKdVaJr/S4PYByuI6UPR8xtwGluFlMFXOScz+Q==", "dev": true, "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/bundler-plugins": "11.10.0", - "@sentry/conventions": "^0.29.0", - "@sentry/core": "11.10.0", - "@sentry/opentelemetry": "11.10.0", - "@sentry/server-runtime-injection": "11.10.0", - "@sentry/server-utils": "11.10.0" + "@sentry/bundler-plugins": "11.11.0", + "@sentry/conventions": "^0.30.0", + "@sentry/core": "11.11.0", + "@sentry/opentelemetry": "11.11.0", + "@sentry/server-runtime-injection": "11.11.0", + "@sentry/server-utils": "11.11.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" } }, "node_modules/@sentry/opentelemetry": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-11.10.0.tgz", - "integrity": "sha512-YMV4ZgaRlW7QPCcMScVpSBvYOdvDlY+alqRT3KOTcmPKFZtf8O2hXHWFhl/M33kKDEfqOk2XtEVeHs3mZFOCaw==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-11.11.0.tgz", + "integrity": "sha512-L1lXAF1hNUSOTLG8NLkBaopvWWpXqlYgQ++cfWr8Gw4wdmReNp9ec533bEj4r6ZJ6PvIYNdIVNI22GdvmbP6xg==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/conventions": "^0.29.0", - "@sentry/core": "11.10.0" + "@sentry/conventions": "^0.30.0", + "@sentry/core": "11.11.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" @@ -5765,29 +5772,29 @@ } }, "node_modules/@sentry/server-runtime-injection": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/server-runtime-injection/-/server-runtime-injection-11.10.0.tgz", - "integrity": "sha512-1nc+HNPldrPEov16sL5m2cOLVgNJ+FsR7QtaWGOOv8qYFJkSp95lyc2qzLnaG2L72UzSMOv10peGRioAKrrZQQ==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/server-runtime-injection/-/server-runtime-injection-11.11.0.tgz", + "integrity": "sha512-nHHWfOYi2sB7db9Hjr3i/H66e1/YK9WMlBx3MSI6p/Ha66BGHF3vea/+gILjPz21viQclg1xq37mj8k9KLkGJw==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/core": "11.10.0", - "@sentry/server-utils": "11.10.0" + "@sentry/core": "11.11.0", + "@sentry/server-utils": "11.11.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" } }, "node_modules/@sentry/server-utils": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-11.10.0.tgz", - "integrity": "sha512-m/MVCvPzQJJZZCTFlVPCrTuoncT7SR80+QKg1K7ul25AsL0byABr5KsHCbXVpOYXLVz04Omk+pOOR5Cv2IAlrA==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-11.11.0.tgz", + "integrity": "sha512-t1lx9Zw+zhRre0ko+F3CsucG2BqB/7KOZmG1W+Wv/fSvYSnT06vg7WhIvmvH+df7BSFO+cFGBpkxwQWGq3zj+w==", "dev": true, "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/conventions": "^0.29.0", - "@sentry/core": "11.10.0" + "@sentry/conventions": "^0.30.0", + "@sentry/core": "11.11.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" @@ -7406,9 +7413,9 @@ } }, "node_modules/@wordpress/babel-preset-default": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-10.0.0.tgz", - "integrity": "sha512-mXxwcIfEuj10SnofavV13/0NshtkrI+d1S/vSWrtgU5ibj+ejcvAXBiqIPBsb3RZDZwipTbRJV+VbyBjaf4IrQ==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-10.1.0.tgz", + "integrity": "sha512-D59dkRTnhgAoJekTXlXp2oPbUwmiVOC/kiyu6aEh/IJUwK1G9WEpbsvvgsVIWZTJTxGsdGKy3MdcZPW6nKS/FA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -7418,8 +7425,8 @@ "@babel/plugin-transform-runtime": "^7.25.7", "@babel/preset-env": "^7.25.7", "@babel/preset-typescript": "^7.25.7", - "@wordpress/browserslist-config": "^7.0.0", - "@wordpress/warning": "^4.0.0", + "@wordpress/browserslist-config": "^7.1.0", + "@wordpress/warning": "^4.1.0", "browserslist": "^4.28.7", "core-js": "^3.31.0", "react": "^19.3.0" @@ -7429,9 +7436,9 @@ } }, "node_modules/@wordpress/browserslist-config": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-7.0.0.tgz", - "integrity": "sha512-eZ2yaOBprRNppFNN/qubJ7C2nOX6llR+IeQ5jQC1665sFcOkZes+hLwpbKsZNcmL26ge+77vdQ0f/Ur67rgkgw==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-7.1.0.tgz", + "integrity": "sha512-4vPRj28I2wTiGhUoVU9Kirjed+1eurphEjGpf626FlIU0f9sExLMuTWYLjqgU9aVvxkmm1M+CKdrNL0GnSnv9g==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -7439,21 +7446,21 @@ } }, "node_modules/@wordpress/compose": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-9.0.0.tgz", - "integrity": "sha512-xP7EDOTyJrc5fC48Zh/u9Hvu+eHvKlU+qn5OyDQi1ahmg5GqNq3UAaODnsgeLyggGgWtmJhbzgN0qIJvhn52Sw==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-9.1.0.tgz", + "integrity": "sha512-L3YoJWyIgQs50Y4A9qdptjDWdfWjFr9V3TqZIzR/yehBA86UwlmP5iBhM8epQTylDf/RsTttJBm8f37/wQvStw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@types/mousetrap": "^1.6.8", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/dom": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/is-shallow-equal": "^6.0.0", - "@wordpress/keycodes": "^5.0.0", - "@wordpress/priority-queue": "^4.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/undo-manager": "^2.0.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/dom": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/is-shallow-equal": "^6.1.0", + "@wordpress/keycodes": "^5.1.0", + "@wordpress/priority-queue": "^4.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/undo-manager": "^2.1.0", "change-case": "^4.1.2", "mousetrap": "^1.6.5" }, @@ -7476,9 +7483,9 @@ } }, "node_modules/@wordpress/dependency-extraction-webpack-plugin": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/dependency-extraction-webpack-plugin/-/dependency-extraction-webpack-plugin-7.0.0.tgz", - "integrity": "sha512-SYzb/CWt8VfGGMwGb7UVjBV8qGBkY6PqybUWFtn581lx/KMJANwswLPTWA/QOhog0NSDynscpQuOPibQ5ULceQ==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/dependency-extraction-webpack-plugin/-/dependency-extraction-webpack-plugin-7.1.0.tgz", + "integrity": "sha512-9oFBFJJnJLQl6izhyXDQfFpHUrCNNZ3YUC83AtmwcMpi0AjesuEZ5OYX7SdMof6vRSOG+6UXIvs61Z8S5NswjA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -7492,35 +7499,35 @@ } }, "node_modules/@wordpress/deprecated": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/deprecated/-/deprecated-5.0.0.tgz", - "integrity": "sha512-PZjrzeX15ZNIDvcBzOXnXF/FpKNwZgz23hQ+xpcmRBjZKCyT6YoxC45O3jFIGyZZtBwxoxCYQPBkCjRuKbVcpg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/deprecated/-/deprecated-5.1.0.tgz", + "integrity": "sha512-y56h8AeOVDxXylvVqZRtDjEPO5Z3Kp2ku6XuCnb0j+LeJfTnPhkPN/G1dh07S87b1BzwvI4AYeJa8ZpH/N1eRw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/hooks": "^5.0.0" + "@wordpress/hooks": "^5.1.0" }, "engines": { "node": ">=22.13.0" } }, "node_modules/@wordpress/dom": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-5.0.0.tgz", - "integrity": "sha512-jKRFuQgNuAbFhiIU/b1xQl00rEphMdBQal6pmiN/pDVfIR1idZFaaI8K+eH/Gqn+yb/q20YIqPLM1+SXR4xy8w==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-5.1.0.tgz", + "integrity": "sha512-KAul2g72Krm3JWKloiKYdlIsk9uFQYY8FpL2dFFyniSy7kUXo8yVG70YnxSeQfOG1mMWXGog8N6h6OkTTvG8nQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/deprecated": "^5.0.0" + "@wordpress/deprecated": "^5.1.0" }, "engines": { "node": ">=22.13.0" } }, "node_modules/@wordpress/e2e-test-utils-playwright": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-3.1.0.tgz", - "integrity": "sha512-ybc60l3g1syfpVJ7L/zOculIYBsfVfwsWOAn/bm4vN4+irRT6jJ/11bbPhclb9yjDyGatn+aHw89OhhHLjz8mQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-3.2.0.tgz", + "integrity": "sha512-U7SoahoL8V3UUngjeSYHeKNnQb6EvF70hEzl9rV+Qap6exbNCywo11K0AU7IK0JWgAK5uqVCnC9TVEuYmamdrg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -7544,13 +7551,13 @@ } }, "node_modules/@wordpress/element": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-10.0.0.tgz", - "integrity": "sha512-gv3cZ3O2j4y7qUUBo/h1Ni9OxG6ak78+D0YhggMsGyIPavT4CpJtRyMbarhENJ7K1MM9JzpuecdlMID8Cfd51Q==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-10.1.0.tgz", + "integrity": "sha512-MfHroJjXMcbPZvs8xzewbRJeW4gLnwzI856PqY4SrBnEStkRwzpSbLJW0HEsHaOK1d+Wnu4Hm60amuLLe6WdsQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/escape-html": "^4.0.0", + "@wordpress/escape-html": "^4.1.0", "change-case": "^4.1.2", "is-plain-object": "^5.1.0" }, @@ -7573,9 +7580,9 @@ } }, "node_modules/@wordpress/env": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-12.0.0.tgz", - "integrity": "sha512-RoUQV3sl1qF4h/kg+vrec9PCfr2bwXSOmWoLd9NmV8rD/sCBMt7tSBfeTWZEbClMhWHce8tu4hJ+pQeHXNnu2A==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-12.1.0.tgz", + "integrity": "sha512-wLodozbP19ON/yV+wDmf8yxbq9NOiacKcaKkLM1wF5c9pO516tTHn5kPrMItyI6B6m/9oULPiywlBUjzfqvF7A==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -7601,9 +7608,9 @@ } }, "node_modules/@wordpress/escape-html": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-4.0.0.tgz", - "integrity": "sha512-fWEvTJvNyPUSAXz3pMETrt1DHKaQ9CJVirhuBqb1xuZmIdRY4AlsjLgayd+lUSTkTU+0gRIPu1TDdU5/6Ue2Nw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-4.1.0.tgz", + "integrity": "sha512-k9lDVTRi2vbTKLyQJDVxNwthTqAmsNSxTVJurDGAdZfXXvbR5QrASTNXXK/sfofqf2wWDFAgI567rhKiAKiYmw==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -7611,16 +7618,16 @@ } }, "node_modules/@wordpress/eslint-plugin": { - "version": "28.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-28.0.0.tgz", - "integrity": "sha512-VRFrez67J9HWGR4s149A3ZG1ynY9uEczmuR7SFXzlcUn3itlo5ood42pv1dgTZK3eqJo3l5tdK3hD94W+ZEq8w==", + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-28.1.0.tgz", + "integrity": "sha512-u2yK55Bx/ycVn6ZSYim73pxE2gscNVUGF22amHD6sY/vGU9SpQPg0By0WJPmPtXsMBNDhNEYPmDN95MKTxb8bg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@eslint/compat": "^2.0.0", "@vitest/eslint-plugin": "^1.6.27", - "@wordpress/prettier-config": "^5.0.0", - "@wordpress/theme": "^3.0.0", + "@wordpress/prettier-config": "^5.1.0", + "@wordpress/theme": "^4.0.0", "cosmiconfig": "^7.0.0", "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.4.4", @@ -7654,9 +7661,9 @@ } }, "node_modules/@wordpress/hooks": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-5.0.0.tgz", - "integrity": "sha512-jA2hDMNr2Ky/D14s8K+TQ/ZI125icjZfTPZYcsgEHM6mTtSJY2dhcmp9+tfSrcnY+M17svzplIxXma+bpBzcFg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-5.1.0.tgz", + "integrity": "sha512-EvHdv0beYOcINcY91jj+brsoLu87AEp2k7gzJC/Xdux3Ng0WLk2VL9A66vpsyZ61J1/Uiivmo3zR/LhC3Y0UzA==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -7664,14 +7671,14 @@ } }, "node_modules/@wordpress/i18n": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-7.0.0.tgz", - "integrity": "sha512-xk7qKLDB08c3LDUtThfmuumq//354bXLGRrPWfC1gmwPMNJPRC7ggwtSFxzH+ImdssxIKk8NaG4H3yF5WJoS5w==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-7.1.0.tgz", + "integrity": "sha512-WCwJZLUlyI5rbdCBdBDxPGgD405JjiQPzyHRcqS2FwZZDb1+dFnMuDBgeewzWKEtsXlOLJnrJQWKggNJVXnOKQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@tannin/sprintf": "^1.3.2", - "@wordpress/hooks": "^5.0.0", + "@tannin/sprintf": "^1.3.4", + "@wordpress/hooks": "^5.1.0", "gettext-parser": "^1.3.1", "tannin": "^1.2.0" }, @@ -7683,9 +7690,9 @@ } }, "node_modules/@wordpress/is-shallow-equal": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/is-shallow-equal/-/is-shallow-equal-6.0.0.tgz", - "integrity": "sha512-KMEZTCaXWp5udPNdPc8lEky3AEhKnUej0aKfjV38Ggox2e87SDXALt3DdMhydO//2xlXZXfGtYtBJ0qk9LpnLQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/is-shallow-equal/-/is-shallow-equal-6.1.0.tgz", + "integrity": "sha512-3zZw6SnVLPCfxHxHo7nlg6lRzKn7te/h4al2kdZ3GMWUAdtRsiJZKgiJcVCqNlJN7tbOyNXUepbVXyGJhrTE3Q==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -7693,13 +7700,13 @@ } }, "node_modules/@wordpress/keycodes": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-5.0.0.tgz", - "integrity": "sha512-15T931kJjj90KlDcsXKaGucK+iyqY9CftR7tXO60m8T8gDC2GVuUWMIX3Sq9+4fsahABuqoUd+QTiW4xSDlpCg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-5.1.0.tgz", + "integrity": "sha512-bqelkvytmYitRbE/3LvMw2xHHFVgq+WYHfv6CiQ+O2HwOES45fuPYhCw6r05yZO5zEg+nh0dB0X4bfeR8kwAsw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/i18n": "^7.0.0" + "@wordpress/i18n": "^7.1.0" }, "engines": { "node": ">=22.13.0" @@ -7714,9 +7721,9 @@ } }, "node_modules/@wordpress/npm-package-json-lint-config": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/npm-package-json-lint-config/-/npm-package-json-lint-config-6.0.0.tgz", - "integrity": "sha512-h0VN1RW3OqsreiFrJ13FD/jFX9b+VHerfWNED/OUUSi17pmVgik47niTfth1/xpotL+kz2RuBopb9oV+HpKcQQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/npm-package-json-lint-config/-/npm-package-json-lint-config-6.1.0.tgz", + "integrity": "sha512-72fpK1zMW3X9xSarhweHqoBOiIYJD4FWGTkjrTYxUWF3DvR0wmKNqbL2mpzL7aD7tCBVEPN8tKhhDAqdnnS26g==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -7727,13 +7734,13 @@ } }, "node_modules/@wordpress/postcss-plugins-preset": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/postcss-plugins-preset/-/postcss-plugins-preset-6.0.0.tgz", - "integrity": "sha512-Z8PLYiY32e0k7xQz0Fc7K30PcOIFvaoc9JduPMQMCfmukJKFMaRYxqyJZNdva7fPu18vUl9dFpB6ZP/+C5aZYQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/postcss-plugins-preset/-/postcss-plugins-preset-6.1.0.tgz", + "integrity": "sha512-oOGrqTX0v+M44V/nliAS0OUr1Mmtn6TfMBO9zll0DP7/+CdPyEdRcNoTjicXWhd/DnYfAsetcBBtSBaBSLJTMg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/browserslist-config": "^7.0.0", + "@wordpress/browserslist-config": "^7.1.0", "autoprefixer": "^10.4.21", "postcss-import": "^16.1.1" }, @@ -7745,9 +7752,9 @@ } }, "node_modules/@wordpress/prettier-config": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-5.0.0.tgz", - "integrity": "sha512-uQ3rtrqt8yvUHFN9Xcxuw7EfSw4ArMkwFxtz2RHZM2Sis9jzB7xk0FcK/iWE55q0WWSjmFGWoNVpOEQoSE7ocQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-5.1.0.tgz", + "integrity": "sha512-Ep67YhwkEVAO2i+7lbzNqQrvUlSFY1oecth7WehXvp2jq0X2X/aDNEMOPxbY0sf6KKmYaWrRiu5ECOqB2QgJ7w==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -7758,9 +7765,9 @@ } }, "node_modules/@wordpress/priority-queue": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/priority-queue/-/priority-queue-4.0.0.tgz", - "integrity": "sha512-YXEwn7xtr4bUts2IOPREjlWYa3n/ba7ndLCqFN3ofeRvvr2ZVCI1WP20v7a+CQZi+n9FS7fy+lYqx3wnTYtGzg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/priority-queue/-/priority-queue-4.1.0.tgz", + "integrity": "sha512-Xt9O99LLh3YT5LVXsxsGrCJDOuGCv61qK6dWZLitCIg/BxCPc/ihfV98lm4Fupye57439vqPIHKpjV3vJEmUBg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -7771,9 +7778,9 @@ } }, "node_modules/@wordpress/private-apis": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-3.0.0.tgz", - "integrity": "sha512-IDXGi2VWfjUFwBTDtWj7/FMY240JpqraxFEIaXIeD3KFumX8lQuAYvE9gU8GcNBt/NlXZCDEmNioxCbAZN+jiA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-3.1.0.tgz", + "integrity": "sha512-Zj9LAP8+Z+2tihAEPNniS6+t0oYSEEbkQ4uHtvzvZpaD+UM9EAmThpMPKR4YP9l4XMb/Y4W/fiMSNm1mDnPY1w==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -7781,24 +7788,24 @@ } }, "node_modules/@wordpress/scripts": { - "version": "37.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-37.0.0.tgz", - "integrity": "sha512-/a01kTH8lqAPzO6AhZRBiFpWcDorZs32U07YdkNhV7UOQAhWrBAWBOXPsLBcCh7NRt603C/hYOea8YcIqPpbZA==", + "version": "37.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-37.1.0.tgz", + "integrity": "sha512-75ZJTN+uAp9ESxS43hbQjYJRjHFUb0gTF1SQ53iDsX5MxjDjQRzdnNpNMw5peYz5Sv4WE7/l3Hfg3WkVm5U3sg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/core": "^7.25.7", "@pmmmwh/react-refresh-webpack-plugin": "^0.6.3", "@svgr/webpack": "^8.0.1", - "@wordpress/babel-preset-default": "^10.0.0", - "@wordpress/browserslist-config": "^7.0.0", - "@wordpress/dependency-extraction-webpack-plugin": "^7.0.0", - "@wordpress/e2e-test-utils-playwright": "^3.1.0", - "@wordpress/eslint-plugin": "^28.0.0", - "@wordpress/npm-package-json-lint-config": "^6.0.0", - "@wordpress/postcss-plugins-preset": "^6.0.0", - "@wordpress/prettier-config": "^5.0.0", - "@wordpress/stylelint-config": "^27.0.0", + "@wordpress/babel-preset-default": "^10.1.0", + "@wordpress/browserslist-config": "^7.1.0", + "@wordpress/dependency-extraction-webpack-plugin": "^7.1.0", + "@wordpress/e2e-test-utils-playwright": "^3.2.0", + "@wordpress/eslint-plugin": "^28.1.0", + "@wordpress/npm-package-json-lint-config": "^6.1.0", + "@wordpress/postcss-plugins-preset": "^6.1.0", + "@wordpress/prettier-config": "^5.1.0", + "@wordpress/stylelint-config": "^27.1.0", "adm-zip": "^0.6.1", "babel-loader": "^9.2.1", "browserslist": "^4.28.7", @@ -8078,9 +8085,9 @@ "license": "MIT" }, "node_modules/@wordpress/style-runtime": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@wordpress/style-runtime/-/style-runtime-0.14.0.tgz", - "integrity": "sha512-cWk3G7Pe6Wo9uMpaPAbrhsPJAmkShPn3LLc8vkte1ZQ7ls1M+e6m78KQxd0HYy4ty2nbGGlhnHE7ISxQzGhrsQ==", + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@wordpress/style-runtime/-/style-runtime-0.15.0.tgz", + "integrity": "sha512-Rc8D0DK17I7OfG1KbMVw3E8SC+84D4LRMnHq0eHbNwilBTogGiieo0MWx1j6Bzkf82Q5n5JCPct+zm0W0Gk84A==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8088,14 +8095,14 @@ } }, "node_modules/@wordpress/stylelint-config": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/stylelint-config/-/stylelint-config-27.0.0.tgz", - "integrity": "sha512-NvLTijt+fZR2/0T3EwH5cswLdpuo/On1B+BQlkkuQ3HsNrwWLTEvwboaOqzJUwFs/owB9+AO11Ocaayexesalw==", + "version": "27.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/stylelint-config/-/stylelint-config-27.1.0.tgz", + "integrity": "sha512-AAV9GMDyRCnHKfmntb8C40u1zSDIkV1HotHMD3CK0Om+VX1aYVjkdRJikSzvcYEcaS6zK8KaKpDFlSvvNbdcXQ==", "dev": true, "license": "MIT", "dependencies": { "@stylistic/stylelint-plugin": "^5.2.1", - "@wordpress/theme": "^3.0.0", + "@wordpress/theme-build-tools": "^1.0.0", "stylelint-config-recommended": "^18.0.0", "stylelint-config-recommended-scss": "^17.0.1" }, @@ -8103,14 +8110,55 @@ "node": ">=22.13.0" }, "peerDependencies": { + "@wordpress/theme": "^3.0.0 || ^4.0.0", "stylelint": "^17.14.1", "stylelint-scss": "^7.2.0" + }, + "peerDependenciesMeta": { + "@wordpress/theme": { + "optional": true + } } }, "node_modules/@wordpress/theme": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/theme/-/theme-3.0.0.tgz", - "integrity": "sha512-m25ORtLD77WL40vG3Vt/0WKzg/liuiM2PGMNaDv4SDO1hZ7H0t+Lp8rfTlsYnBdI+iO8+juICyK4OiVRde1I0Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/theme/-/theme-4.0.0.tgz", + "integrity": "sha512-VKlIu/ulNrJjeNvWw50HD3x2wKq/ntGQnir5ep0r5DOjpdYYEV0/oGXQSQBARcd4H6TncKX5NzaGL0aBYQq7sg==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/compose": "^9.1.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/style-runtime": "^0.15.0", + "colorjs.io": "^0.7.1", + "memize": "^2.1.1" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@wordpress/theme-build-tools": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/theme-build-tools/-/theme-build-tools-1.0.0.tgz", + "integrity": "sha512-4oy+LYzAokRTentySXjyza7RCMUJG8Ul6mLNTOC+hcwCdwo7yzR3SMhyJfe4uFPON1CNk+s2claCmiIHZnRSPQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8118,30 +8166,21 @@ "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "@jridgewell/trace-mapping": "^0.3.31", - "@wordpress/compose": "^9.0.0", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/style-runtime": "^0.14.0", - "colorjs.io": "^0.7.1", - "magic-string": "^0.30.21", - "memize": "^2.1.1" + "magic-string": "^0.30.21" }, "engines": { "node": ">=22.13.0" }, "peerDependencies": { - "@types/react": "^18 || ^19", + "@wordpress/theme": "^3.0.0 || ^4.0.0", "esbuild": ">=0.27.2 <1.0.0", "lightningcss": ">=1.33.0 <2.0.0", "postcss": "^8.0.0", - "react": "^18 || ^19", - "react-dom": "^18 || ^19", "stylelint": "^16 || ^17", "vite": "^7 || ^8" }, "peerDependenciesMeta": { - "@types/react": { + "@wordpress/theme": { "optional": true }, "esbuild": { @@ -8153,12 +8192,6 @@ "postcss": { "optional": true }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, "stylelint": { "optional": true }, @@ -8168,22 +8201,22 @@ } }, "node_modules/@wordpress/undo-manager": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/undo-manager/-/undo-manager-2.0.0.tgz", - "integrity": "sha512-OQfGPUBz79/NKkkBvF5L/k1DmxuCQGYvczA/BOef+ECCCnjpIXF6HS1txpULozI5owejQDDjhZp25bq25jF5Vg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/undo-manager/-/undo-manager-2.1.0.tgz", + "integrity": "sha512-n4c6/9V51sNzche2wJReKVUm7c/9ChZsg52939FJuITcLBp5MnR+oEeSe2RQPP4Mq1x3EQAFuM2HibaEnliqvQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/is-shallow-equal": "^6.0.0" + "@wordpress/is-shallow-equal": "^6.1.0" }, "engines": { "node": ">=22.13.0" } }, "node_modules/@wordpress/warning": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-4.0.0.tgz", - "integrity": "sha512-qhAwfjMUYT1TfyMlrkO4i1IbcqfjfjLBFepXSRvMUhNbL8CRRu3cgjdZNCf82FkvUwmuCEuMsrmGisxu4CGr1A==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-4.1.0.tgz", + "integrity": "sha512-nKzIGM+H4YIaGIQY4+KTi6pSRWC4rVm0SKz3TUbORp5WXXq18MsiUqvG4D0vq/fuzo5fhGC5/Ee/T+EPDQF9FQ==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9515,9 +9548,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001820", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001820.tgz", - "integrity": "sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w==", + "version": "1.0.30001822", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001822.tgz", + "integrity": "sha512-Y3fvWQWzc69dlCyr07tuNrEhotv+db5FpMSPD3slZnkPKwRNOKwFBz2dP966XUmWMXLTOt+YShkFoQoom3U7hg==", "dev": true, "funding": [ { @@ -11078,9 +11111,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "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==", + "version": "1.5.477", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.477.tgz", + "integrity": "sha512-PN++F4zWZoVDGWinxMnSCpVqKhqFOJfLXxdU8bYJKudAEsHL/XKVITOKWVbfMt1r276Mi9RDLU9VHx9uKJ4RMQ==", "dev": true, "license": "ISC" }, @@ -12503,9 +12536,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.11.2", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz", - "integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==", + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.12.0.tgz", + "integrity": "sha512-/pXxRBtwxY9w8z7YbgsvIPPcPvFDsTbIkWwTQJhzNJPokj+TbN6ftmcv4j/pEQDvOCNnKGDzuk5Bo2oogIOiFg==", "dev": true, "funding": [ { @@ -12515,11 +12548,11 @@ ], "license": "MIT", "dependencies": { - "@nodable/entities": "^3.0.1", - "fast-xml-builder": "^1.2.0", - "is-unsafe": "^2.0.0", + "@nodable/entities": "^3.1.0", + "fast-xml-builder": "^1.3.1", + "is-unsafe": "^2.0.3", "path-expression-matcher": "^1.6.2", - "strnum": "^2.4.2", + "strnum": "^2.5.1", "xml-naming": "^0.3.0" }, "bin": { @@ -12984,15 +13017,15 @@ } }, "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-14.0.3.tgz", + "integrity": "sha512-A1vjIwC3qpdCso45Iw127gnp7sPhqgY8lUQhDrArIs+GbmfaaVPFuoyy7WyLT9EPZCRZ7j7aLdwUhQmHWesEow==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "minimatch": "^10.2.2", + "minimatch": "^11.0.2", "minipass": "^7.1.3", - "path-scurry": "^2.0.2" + "path-scurry": "^3.0.0" }, "engines": { "node": "18 || 20 || >=22" @@ -13031,6 +13064,23 @@ "tslib": "2" } }, + "node_modules/glob/node_modules/minimatch": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.4.tgz", + "integrity": "sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/nonrecursive-glob-match": "^1.0.3", + "brace-expansion": "^5.0.12" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/global-modules": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", @@ -13991,9 +14041,9 @@ } }, "node_modules/is-core-module": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", - "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.18.0.tgz", + "integrity": "sha512-qyax9fgaKOEC4qK8WZn7Q4IcoZC9RC1O1fFxRcRTq/oMMkWT/uXpqpXa12kzWR409zAt1drYN9N9BO26UmBCmw==", "dev": true, "license": "MIT", "dependencies": { @@ -15291,13 +15341,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.truncate": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", - "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -17674,9 +17717,9 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-3.0.0.tgz", + "integrity": "sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -19268,16 +19311,16 @@ } }, "node_modules/regexpu-core": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.5.3.tgz", - "integrity": "sha512-E8VgUoSK+LMQrLboDxavm76MTBGrQhx9RiNuomRmYnZ10Uw12HrI8fkxOOncktb2hBpzIZ9SV/347euQHG5z+g==", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.5.4.tgz", + "integrity": "sha512-VpdamrZ9bw4YmvMZfTA3Yg8IHU9Guvg21bonvgjC4l3KfBkOqCPJesHakz7cQeE4Vh4zz4r4Gy6igAcnJxZv9g==", "dev": true, "license": "MIT", "dependencies": { "regenerate": "^1.5.0", "regenerate-unicode-properties": "^10.3.0", "regjsgen": "^0.8.0", - "regjsparser": "^0.13.0", + "regjsparser": "^0.14.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" }, @@ -19293,9 +19336,9 @@ "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.4.tgz", - "integrity": "sha512-EWV80YgFaVgFrjGCAsV7oybp4GOxbls5RzBg8OB0nKEVXsBJR1JlV8Di1JhCROg3MzTfe1RI8yitIm1eOs68iA==", + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.14.1.tgz", + "integrity": "sha512-IeYboCLG1KBNRksrZQIZ1SV5KVWZiR0ikFokOcA0n63vLvlaFvCsfxN+fd4aXwL0KW3YD+ByRzIgo9ZAHdbldw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -19356,14 +19399,14 @@ } }, "node_modules/resolve": { - "version": "1.22.13", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz", - "integrity": "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==", + "version": "1.22.14", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.14.tgz", + "integrity": "sha512-1ZjYvR9sbkiwC5+9QHetqW5HmA2pHRMaoupD01t2HlYnV/vydC2zPwLixhsMieSWVZt0JJRg5LVIk0Ow1D/TrA==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "is-core-module": "^2.17.0", + "is-core-module": "^2.18.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -21457,14 +21500,13 @@ } }, "node_modules/table": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", - "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/table/-/table-6.10.1.tgz", + "integrity": "sha512-f05boz6UD5S70AJiUxqKOnDuE45mBIVagX490oQmcW38+Lq/H1s7CjIpFgJjR/kpV87L4lw/AN+14D2PktQJsQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "ajv": "^8.0.1", - "lodash.truncate": "^4.4.2", "slice-ansi": "^4.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1" @@ -21699,9 +21741,9 @@ } }, "node_modules/tldts-core": { - "version": "7.4.20", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz", - "integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==", + "version": "7.4.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.19.tgz", + "integrity": "sha512-goNXV6Ho9TIOfpJZanTd6CKG5DyYgYAL+k+22T0SLh8l6+KHIfBUPFuePuSZv05UttIaaLmI9tb7TmGIdK5AkQ==", "dev": true, "license": "MIT" }, diff --git a/packages/bootstrap/README.md b/packages/bootstrap/README.md index 21b7ff8f..d8db35e2 100644 --- a/packages/bootstrap/README.md +++ b/packages/bootstrap/README.md @@ -79,7 +79,7 @@ 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`. 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`. +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 `{text_domain}/github_updater/config` filter, named after the text domain with underscores for its dashes, such as `acme_persona_bootstrap/github_updater/config`, 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: diff --git a/packages/bootstrap/src/Updater.php b/packages/bootstrap/src/Updater.php index 7e557bbe..e347ec30 100644 --- a/packages/bootstrap/src/Updater.php +++ b/packages/bootstrap/src/Updater.php @@ -150,7 +150,7 @@ protected static function get_updater_config( string $plugin_file ): ?array { ); /** - * Filters the GitHub updater's configuration for the plugin whose text domain ends the hook name. A key the filter leaves out keeps its default. + * Filters the GitHub updater's configuration for the plugin whose text domain, in snake case, roots the hook name. A key the filter leaves out keeps its default. * * @since 2.0.0 * @version 2.0.0 @@ -158,7 +158,7 @@ protected static function get_updater_config( string $plugin_file ): ?array { * @param array{enabled: bool, repository: string, asset: string, channel: 'stable'|'prerelease'} $config Whether updates are offered, the owner/name repository, the release asset's file name, and the channel: 'stable' follows the latest release, 'prerelease' the highest of the ten newest releases. * @param string $plugin_basename The plugin's basename. */ - $config = apply_filters( "dws_github_updater/config/$slug", $defaults, plugin_basename( $plugin_file ) ); // @phpstan-ignore WPCompat.parameterNotAvailable.applyfilters.args (WordPress 6.0 only formalized the arguments that older versions already passed on.) + $config = apply_filters( \str_replace( '-', '_', $slug ) . '/github_updater/config', $defaults, plugin_basename( $plugin_file ) ); // @phpstan-ignore WPCompat.parameterNotAvailable.applyfilters.args (WordPress 6.0 only formalized the arguments that older versions already passed on.) if ( ! \is_array( $config ) ) { return null; } diff --git a/packages/core/README.md b/packages/core/README.md index ceb47b32..24c4266a 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -89,10 +89,13 @@ require_once __DIR__ . '/vendor/autoload.php'; require_once __DIR__ . '/functions.php'; ``` -It boots on `plugins_loaded` at priority 15, after WooCommerce and Action Scheduler have loaded: +Its `functions.php` defines the accessor, the plugin's one `@api` function, which returns the composition root while the root's members stay internal, and boots the plugin from a static closure on `plugins_loaded` at priority 15, after WooCommerce and Action Scheduler have loaded: ```php -add_action( 'plugins_loaded', 'acme_pc_boot', 15 ); +add_action( + 'plugins_loaded', + static function (): void { + acme_pc_plugin()->boot(); // ... @@ -104,21 +107,16 @@ function acme_pc_plugin(): Plugin { return $plugin; } - -// ... - -function acme_pc_boot(): void { - acme_pc_plugin()->boot(); -} ``` -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: +One composition root builds the plugin. It builds one `PluginIdentity` from the entry file, the slug and the prefix, and hands it to every framework class it builds, in place of those strings: the slug, with underscores for its dashes, roots the plugin's hook names and names its REST namespace, and the prefix starts every option, transient and meta name the framework stores for the plugin. The container defines the identity, so a component that builds a settings page or form takes it by constructor. `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 final `Migrations` class returns as first-class callables to its protected statics, 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; use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger; use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner; use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\PluginBoot; +use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity; 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; @@ -139,9 +137,10 @@ final class Plugin { // ... public function __construct() { - $this->logger = new ErrorLogLogger( self::SLUG ); - $this->scheduler = new Scheduler( self::PREFIX ); - $this->boundary = new PluginBoot( ACME_PC_FILE, self::PREFIX, $this->logger ); + $this->identity = new PluginIdentity( ACME_PC_FILE, self::SLUG, self::PREFIX ); + $this->logger = new ErrorLogLogger( $this->identity ); + $this->scheduler = new Scheduler( $this->identity ); + $this->boundary = new PluginBoot( $this->identity, $this->logger ); } // ... @@ -153,10 +152,10 @@ final class Plugin { // ... protected function compose(): bool { - $this->migrations = new MigrationRunner( ACME_PC_FILE, self::PREFIX . '_revision', acme_pc_get_migration_steps(), $this->logger, $this->scheduler ); + $this->migrations = new MigrationRunner( $this->identity, Migrations::get_steps(), $this->logger, $this->scheduler ); $this->migrations->register_hooks(); - $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() ); + $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->identity, $this->logger, $this->scheduler ) )->build() ); $this->kernel->boot( self::COMPONENTS ); return true; @@ -164,12 +163,13 @@ final class Plugin { // ... - protected function get_definitions( PsrLoggerInterface $logger, Scheduler $scheduler ): array { + protected function get_definitions( PluginIdentity $identity, PsrLoggerInterface $logger, Scheduler $scheduler ): array { return array( + PluginIdentity::class => static fn (): PluginIdentity => $identity, 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 ), + AdminNotices::class => static fn (): AdminNotices => new AdminNotices( $identity, $logger ), ); } @@ -192,24 +192,24 @@ 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: +`Assets` registers the plugin's built scripts and styles. Built as `new Assets( $identity )` and defined once in the container, it reads the dependencies and version that wp-scripts writes beside each built file in its `.asset.php`: `register_script()` loads the script's translations from the plugin's `languages/` directory under its slug, and `register_style()` gives the stylesheet the right-to-left variant wp-scripts writes beside it. The `style-{entry}.css` chunk of a `style.scss` import takes its version from its entry's `.asset.php`. Only wp-scripts builds are supported. As a best effort, a file built outside wp-scripts, which has no `.asset.php`, registers with its modification time as its version and, for a script, no dependencies, and a stylesheet without a `-rtl.css` beside it keeps its own file on right-to-left sites. + +The plugin's `uninstall.php` hands its footprint, a constant of its composition root, 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, builds the plugin's identity from the root's constants, 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 ), + public const array FOOTPRINT = array( + 'option_prefixes' => array( self::PREFIX . '_' ), + 'user_meta_site_prefixes' => array( self::PREFIX . '_' ), + 'user_meta_global_prefixes' => array( self::PREFIX . '_' ), + 'network_options' => array( self::PREFIX . '_network_notices' ), + 'cron_hooks' => array( 'acme_persona_core/migration/run' ), + 'as_groups' => array( self::PREFIX ), ); -} ``` ```php Uninstaller::run( - $acme_pc_file, + $acme_pc_identity, $acme_pc_footprint, array( 'consent' => array( diff --git a/packages/core/src/AdminNotice/AdminNotices.php b/packages/core/src/AdminNotice/AdminNotices.php index fb1f7f83..87310a39 100644 --- a/packages/core/src/AdminNotice/AdminNotices.php +++ b/packages/core/src/AdminNotice/AdminNotices.php @@ -4,6 +4,7 @@ use DeepWebSolutions\Framework\Core\ComponentInterface; use DeepWebSolutions\Framework\Core\Logging\GuardedLogger; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; use DeepWebSolutions\Framework\Shared\Exception\LogicException; use Psr\Log\LoggerInterface as PsrLoggerInterface; @@ -13,7 +14,7 @@ /** * Keeps a plugin's admin notices, shows them in the admin, the post editor included, and lets users dismiss them through a REST endpoint. * - * The prefix names the plugin's storage keys, hooks and REST namespace. Each scope keeps its notices as one array by id: site notices in the {prefix}_notices option, network notices in the {prefix}_network_notices network option, user notices in the {prefix}_notices user option, and request notices in the instance, so components that add them must share it. A viewer's dismissals, and the one-shots shown to them that others still see, are kept in their {prefix}_hidden_notices user option, or for network notices in their {prefix}_network_hidden_notices user meta, keyed by network. + * The slug names the plugin's REST namespace and, in snake case, roots its hooks, while the prefix names its storage keys. Each scope keeps its notices as one array by id: site notices in the {prefix}_notices option, network notices in the {prefix}_network_notices network option, user notices in the {prefix}_notices user option, and request notices in the instance, so components that add them must share it, and a plugin builds one instance, since the slug names its routes. A viewer's dismissals, and the one-shots shown to them that others still see, are kept in their {prefix}_hidden_notices user option, or for network notices in their {prefix}_network_hidden_notices user meta, keyed by network. * * @since 2.0.0 * @version 2.0.0 @@ -21,16 +22,6 @@ final class AdminNotices implements ComponentInterface { // region FIELDS AND CONSTANTS - /** - * The pattern every prefix matches. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @var string - */ - protected const string PREFIX_PATTERN = '/\A[a-z][a-z0-9_]{0,31}\z/'; - /** * The segments a prefix may not contain, because the storage keys put them after a prefix, so such a prefix could name another plugin's keys. * @@ -81,20 +72,20 @@ final class AdminNotices implements ComponentInterface { * @since 2.0.0 * @version 2.0.0 * - * @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. + * @param PluginIdentity $identity The plugin's identity, whose prefix has 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. + * @throws InvalidArgumentException Thrown when the prefix has a network or hidden segment. */ public function __construct( - protected string $prefix, + protected PluginIdentity $identity, protected PsrLoggerInterface $logger ) { - if ( 1 !== \preg_match( self::PREFIX_PATTERN, $prefix ) || array() !== \array_intersect( \explode( '_', $prefix ), self::RESERVED_SEGMENTS ) ) { - throw new InvalidArgumentException( "Notices prefix '$prefix' is not a lowercase letter followed by up to 31 lowercase letters, digits and underscores, or it has a 'network' or 'hidden' segment." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + if ( array() !== \array_intersect( \explode( '_', $identity->prefix ), self::RESERVED_SEGMENTS ) ) { + throw new InvalidArgumentException( "Notices prefix '$identity->prefix' has a 'network' or 'hidden' segment." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. } - $this->logger = new GuardedLogger( $logger, $prefix ); + $this->logger = new GuardedLogger( $logger, $identity ); } // endregion @@ -212,7 +203,7 @@ public function render_notices(): void { foreach ( $this->select( false ) as $notice ) { $attributes = array( - 'data-notices' => $this->prefix, + 'data-notices' => $this->identity->prefix, 'data-notice-scope' => $notice->scope->value, 'data-notice-id' => $notice->id, ); @@ -293,10 +284,10 @@ public function enqueue_delivery_script(): void { return; } - $handle = "$this->prefix-notice-delivery"; + $handle = "{$this->identity->prefix}-notice-delivery"; $settings = wp_json_encode( array( - 'prefix' => $this->prefix, + 'prefix' => $this->identity->prefix, 'path' => "{$this->get_rest_namespace()}/notices/deliveries", 'waiting' => array() !== $this->select( true ), ) @@ -358,10 +349,10 @@ public function enqueue_dismissal_script(): void { return; } - $handle = "$this->prefix-notice-dismissal"; + $handle = "{$this->identity->prefix}-notice-dismissal"; $settings = wp_json_encode( array( - 'prefix' => $this->prefix, + 'prefix' => $this->identity->prefix, 'path' => "{$this->get_rest_namespace()}/notices/dismissals", ) ); @@ -453,7 +444,7 @@ 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->identity->hook_root}/admin_notice/dismissed", $id, $scope->value, $user_id, $owner ); } return new \WP_REST_Response( array( 'changed' => $changed ) ); @@ -550,7 +541,7 @@ protected function select( bool $block_editor ): array { * @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 ) ) { + if ( true === apply_filters( "{$this->identity->hook_root}/admin_notice/should_show", true, $notice->id, $scope->value ) ) { $selected[] = $notice; } } @@ -641,9 +632,9 @@ protected function get_owner( NoticeScope $scope, int $user_id ): int { protected function read( NoticeScope $scope, int $owner ): array { $rows = match ( $scope ) { NoticeScope::Request => $this->request_notices, - NoticeScope::User => get_user_option( "{$this->prefix}_notices", $owner ), - NoticeScope::Site => get_option( "{$this->prefix}_notices" ), - NoticeScope::Network => get_network_option( $owner, "{$this->prefix}_network_notices" ), + NoticeScope::User => get_user_option( "{$this->identity->prefix}_notices", $owner ), + NoticeScope::Site => get_option( "{$this->identity->prefix}_notices" ), + NoticeScope::Network => get_network_option( $owner, "{$this->identity->prefix}_network_notices" ), }; $notices = array(); @@ -675,9 +666,9 @@ protected function write( NoticeScope $scope, int $owner, array $notices ): void $rows = \array_map( static fn ( Notice $notice ): array => $notice->to_array(), $notices ); match ( $scope ) { NoticeScope::Request => $this->request_notices = $rows, - NoticeScope::User => update_user_option( $owner, "{$this->prefix}_notices", wp_slash( $rows ) ), - NoticeScope::Site => update_option( "{$this->prefix}_notices", $rows, false ), - NoticeScope::Network => update_network_option( $owner, "{$this->prefix}_network_notices", $rows ), + NoticeScope::User => update_user_option( $owner, "{$this->identity->prefix}_notices", wp_slash( $rows ) ), + NoticeScope::Site => update_option( "{$this->identity->prefix}_notices", $rows, false ), + NoticeScope::Network => update_network_option( $owner, "{$this->identity->prefix}_network_notices", $rows ), }; } @@ -718,7 +709,7 @@ protected function delete( NoticeScope $scope, int $owner, string $id ): bool { * @return array */ protected function get_hidden( NoticeScope $scope, int $user_id ): array { - $stored = NoticeScope::Network === $scope ? get_user_meta( $user_id, "{$this->prefix}_network_hidden_notices", true ) : get_user_option( "{$this->prefix}_hidden_notices", $user_id ); + $stored = NoticeScope::Network === $scope ? get_user_meta( $user_id, "{$this->identity->prefix}_network_hidden_notices", true ) : get_user_option( "{$this->identity->prefix}_hidden_notices", $user_id ); $hidden = array(); foreach ( \is_array( $stored ) ? $stored : array() as $key => $entry ) { @@ -773,9 +764,9 @@ protected function hide( NoticeScope $scope, string $id, int $user_id, string $s 'at' => \time(), ); if ( NoticeScope::Network === $scope ) { - update_user_meta( $user_id, "{$this->prefix}_network_hidden_notices", wp_slash( $hidden ) ); + update_user_meta( $user_id, "{$this->identity->prefix}_network_hidden_notices", wp_slash( $hidden ) ); } else { - update_user_option( $user_id, "{$this->prefix}_hidden_notices", wp_slash( $hidden ) ); + update_user_option( $user_id, "{$this->identity->prefix}_hidden_notices", wp_slash( $hidden ) ); } return true; @@ -802,7 +793,7 @@ 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->identity->hook_root}/admin_notice/message", $notice->message, $notice->id, $notice->scope->value ); return \is_string( $message ) ? $message : $notice->message; } @@ -840,7 +831,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->identity->prefix}-{$notice->scope->value}-notice-$notice->id"; } /** @@ -852,7 +843,7 @@ protected function get_dom_id( Notice $notice ): string { * @return non-falsy-string */ protected function get_rest_namespace(): string { - return \str_replace( '_', '-', $this->prefix ) . '/v1'; + return "{$this->identity->slug}/v1"; } /** @@ -867,7 +858,7 @@ protected function get_rest_namespace(): string { * @return string */ protected function get_nonce_action( NoticeScope $scope, string $id ): string { - return "{$this->prefix}_dismiss|" . get_current_blog_id() . ":$scope->value:$id"; + return "{$this->identity->prefix}_dismiss|" . get_current_blog_id() . ":$scope->value:$id"; } // endregion diff --git a/packages/core/src/Asset/Assets.php b/packages/core/src/Asset/Assets.php new file mode 100644 index 00000000..a7955225 --- /dev/null +++ b/packages/core/src/Asset/Assets.php @@ -0,0 +1,122 @@ + $args Optional. How the script loads, as wp_register_script() takes it. Default is none, which loads it in the head. + * + * @return bool + */ + public function register_script( string $handle, string $path, array $args = array() ): bool { + $asset = $this->get_asset( $path ); + + // Script modules the build imports are not script handles, so they stay out of the dependencies. + $registered = wp_register_script( $handle, plugin_dir_url( $this->identity->file ) . $path, \array_values( \array_filter( $asset['dependencies'], 'is_string' ) ), $asset['version'], $args ); + if ( $registered ) { + wp_set_script_translations( $handle, $this->identity->slug, plugin_dir_path( $this->identity->file ) . 'languages' ); + } + + return $registered; + } + + /** + * Registers a built stylesheet with its build's version, and with the right-to-left variant wp-scripts writes beside it where that file exists, and returns whether WordPress registered it. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param non-empty-string $handle The stylesheet's handle. + * @param non-empty-string $path The built stylesheet's path, relative to the plugin's directory. + * + * @return bool + */ + public function register_style( string $handle, string $path ): bool { + $registered = wp_register_style( $handle, plugin_dir_url( $this->identity->file ) . $path, array(), $this->get_asset( $path )['version'] ); + if ( $registered && \is_readable( plugin_dir_path( $this->identity->file ) . \str_replace( '.css', '-rtl.css', $path ) ) ) { + wp_style_add_data( $handle, 'rtl', 'replace' ); + } + + return $registered; + } + + // endregion + + // region HELPERS + + /** + * Returns the dependencies and version that wp-scripts writes for a built file, or no dependencies and the file's modification time where it wrote none. + * + * The style-{entry}.css chunk that wp-scripts makes of a style.scss import has no asset file of its own, since its version sits in the entry's. A file built outside wp-scripts has none at all, so it is versioned by its own time, as a best effort. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $path The built file's path, relative to the plugin's directory. + * + * @return array{dependencies: list, version: string} + */ + protected function get_asset( string $path ): array { + $file = plugin_dir_path( $this->identity->file ) . $path; + $directory = \dirname( $file ); + $name = \pathinfo( $file, \PATHINFO_FILENAME ); + $asset_file = "$directory/$name.asset.php"; + if ( ! \is_readable( $asset_file ) && \str_starts_with( $name, 'style-' ) && \str_ends_with( $file, '.css' ) ) { + $asset_file = "$directory/" . \substr( $name, \strlen( 'style-' ) ) . '.asset.php'; + } + + if ( ! \is_readable( $asset_file ) ) { + return array( + 'dependencies' => array(), + 'version' => (string) \filemtime( $file ), + ); + } + + /** + * The build's dependencies and version. + * + * @var array{dependencies: list, version: string} $asset + */ + $asset = require $asset_file; + return $asset; + } + + // endregion +} diff --git a/tests/Fixtures/personas/core/includes/index.php b/packages/core/src/Asset/index.php similarity index 100% rename from tests/Fixtures/personas/core/includes/index.php rename to packages/core/src/Asset/index.php diff --git a/packages/core/src/Caching/ObjectCache.php b/packages/core/src/Caching/ObjectCache.php index e2dbaae2..3dd876f1 100644 --- a/packages/core/src/Caching/ObjectCache.php +++ b/packages/core/src/Caching/ObjectCache.php @@ -3,13 +3,14 @@ namespace DeepWebSolutions\Framework\Core\Caching; use DeepWebSolutions\Framework\Core\Caching\Exceptions\InvalidCacheKeyException; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; use Psr\SimpleCache\CacheInterface as PsrCacheInterface; \defined( 'ABSPATH' ) || exit; /** - * Caches values in a group of the current site's object cache, and clears them all at once by changing the group's salt. + * Caches values in a group of the current site's object cache named after a region under the plugin's prefix, and clears them all at once by changing the group's salt. * * Each entry is named by its key's hash, which never equals the group's last_changed key, and holds the salt it was stored under, so that clear() leaves one entry per key for the object cache to evict. * @@ -17,6 +18,20 @@ * @version 2.0.0 */ final class ObjectCache implements PsrCacheInterface { + // region FIELDS AND CONSTANTS + + /** + * The object cache group: the plugin's prefix, an underscore and the region. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var string + */ + protected string $group; + + // endregion + // region MAGIC METHODS /** @@ -25,15 +40,18 @@ final class ObjectCache implements PsrCacheInterface { * @since 2.0.0 * @version 2.0.0 * - * @param string $group The object cache group, under the plugin's own prefix. + * @param PluginIdentity $identity The identity of the plugin that owns the cache. + * @param string $region The cache's region, which follows the plugin's prefix and an underscore in the group's name. * - * @throws InvalidArgumentException Thrown when the group is invalid. + * @throws InvalidArgumentException Thrown when the group's name is invalid. */ public function __construct( - protected string $group + PluginIdentity $identity, + string $region ) { - if ( 1 !== \preg_match( '/\A[a-z][a-z0-9_-]{0,63}\z/', $group ) ) { - throw new InvalidArgumentException( "Group '$group' is not a lowercase letter followed by up to 63 lowercase letters, digits, underscores and hyphens." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + $this->group = "{$identity->prefix}_$region"; + if ( 1 !== \preg_match( '/\A[a-z][a-z0-9_-]{0,63}\z/', $this->group ) ) { + throw new InvalidArgumentException( "Group '$this->group' is not a lowercase letter followed by up to 63 lowercase letters, digits, underscores and hyphens." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. } } diff --git a/packages/core/src/Caching/TransientCache.php b/packages/core/src/Caching/TransientCache.php index 4e1ae3a1..1d279a73 100644 --- a/packages/core/src/Caching/TransientCache.php +++ b/packages/core/src/Caching/TransientCache.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\Framework\Core\Caching; use DeepWebSolutions\Framework\Core\Caching\Exceptions\InvalidCacheKeyException; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Core\Storage\OptionRows; use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; use Psr\SimpleCache\CacheInterface as PsrCacheInterface; @@ -10,7 +11,7 @@ \defined( 'ABSPATH' ) || exit; /** - * Caches values as transients of the current site under a prefix, and clears them all at once by starting a new generation. + * Caches values as transients of the current site in a region under the plugin's 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. Each also holds the generation it was stored under, so that clear() leaves one transient per key rather than one per generation. * @@ -31,7 +32,7 @@ final class TransientCache implements PsrCacheInterface { protected const int DEFAULT_TTL = DAY_IN_SECONDS; /** - * The length of the longest prefix, which leaves room for a key's hash within the 172 characters of a transient name. + * The length of the longest prefix of the transient names, which leaves room for a key's hash within the 172 characters of a transient name. * * @since 2.0.0 * @version 2.0.0 @@ -50,6 +51,16 @@ final class TransientCache implements PsrCacheInterface { */ protected string $generation_option; + /** + * The prefix of every transient name: the plugin's prefix, the region and an underscore after each. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var string + */ + protected string $prefix; + // endregion // region MAGIC METHODS @@ -60,17 +71,20 @@ final class TransientCache implements PsrCacheInterface { * @since 2.0.0 * @version 2.0.0 * - * @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. + * @param PluginIdentity $identity The identity of the plugin that owns the cache. + * @param string $region The cache's region, which follows the plugin's prefix and an underscore in every transient name. + * @param string|null $generation Optional. The region whose generation the cache reads, which caches that clear together share, kept in the option {prefix}_{generation}_cache_generation. Default is the cache's own region. * - * @throws InvalidArgumentException Thrown when the prefix or the generation option name is invalid. + * @throws InvalidArgumentException Thrown when the region leaves no room for a key's hash in a transient name, or the generation's option name is invalid. */ public function __construct( - protected string $prefix, - ?string $generation_option = null + PluginIdentity $identity, + string $region, + ?string $generation = null ) { - OptionRows::check_name( $prefix, self::MAX_PREFIX_LENGTH ); - $this->generation_option = $generation_option ?? "{$prefix}cache_generation"; + $this->prefix = "{$identity->prefix}_{$region}_"; + OptionRows::check_name( $this->prefix, self::MAX_PREFIX_LENGTH ); + $this->generation_option = "{$identity->prefix}_" . ( $generation ?? $region ) . '_cache_generation'; OptionRows::check_name( $this->generation_option ); } diff --git a/packages/core/src/Logging/AbstractLogger.php b/packages/core/src/Logging/AbstractLogger.php index 27a78f0c..0c10d1ee 100644 --- a/packages/core/src/Logging/AbstractLogger.php +++ b/packages/core/src/Logging/AbstractLogger.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Framework\Core\Logging; +use DeepWebSolutions\Framework\Core\PluginIdentity; use Psr\Log\AbstractLogger as PsrAbstractLogger; use Psr\Log\InvalidArgumentException as PsrInvalidArgumentException; use Psr\Log\LogLevel as PsrLogLevel; @@ -107,21 +108,21 @@ protected static function interpolate( string $message, array $context ): string } /** - * Returns the record as one entry of the PHP error log: the channel and level, the message, and any Throwable under the context's exception key on the lines after. + * Returns the record as one entry of the PHP error log: the plugin's slug and the level, the message, and any Throwable under the context's exception key on the lines after. * * @since 2.0.0 * @version 2.0.0 * - * @param string $channel The name that identifies the plugin. - * @param string $level The record's level. - * @param string $message The message as it is written. - * @param array $context The record's context. + * @param PluginIdentity $identity The identity of the plugin the record belongs to. + * @param string $level The record's level. + * @param string $message The message as it is written. + * @param array $context The record's context. * * @return string */ - protected static function format_record( string $channel, string $level, string $message, array $context ): string { + protected static function format_record( PluginIdentity $identity, string $level, string $message, array $context ): string { $exception = $context['exception'] ?? null; - return "[$channel.$level] $message" . ( $exception instanceof \Throwable ? "\n$exception" : '' ); + return "[$identity->slug.$level] $message" . ( $exception instanceof \Throwable ? "\n$exception" : '' ); } // endregion diff --git a/packages/core/src/Logging/ErrorLogLogger.php b/packages/core/src/Logging/ErrorLogLogger.php index 0855606d..b486ea17 100644 --- a/packages/core/src/Logging/ErrorLogLogger.php +++ b/packages/core/src/Logging/ErrorLogLogger.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Framework\Core\Logging; +use DeepWebSolutions\Framework\Core\PluginIdentity; use Psr\Log\InvalidArgumentException as PsrInvalidArgumentException; \defined( 'ABSPATH' ) || exit; @@ -21,10 +22,10 @@ final class ErrorLogLogger extends AbstractLogger { * @since 2.0.0 * @version 2.0.0 * - * @param string $channel The name that identifies the plugin in every record. + * @param PluginIdentity $identity The identity of the plugin, whose slug starts every record. */ public function __construct( - protected string $channel + protected PluginIdentity $identity ) {} // endregion @@ -54,7 +55,7 @@ public function log( mixed $level, string|\Stringable $message, array $context = return; } - \error_log( self::format_record( $this->channel, $level, self::interpolate( (string) $message, $context ), $context ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Writing to the error log is this logger's purpose. + \error_log( self::format_record( $this->identity, $level, self::interpolate( (string) $message, $context ), $context ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Writing to the error log is this logger's purpose. do_action( "qm/$level", $message, $context ); } diff --git a/packages/core/src/Logging/GuardedLogger.php b/packages/core/src/Logging/GuardedLogger.php index f158f1f0..8d3779b8 100644 --- a/packages/core/src/Logging/GuardedLogger.php +++ b/packages/core/src/Logging/GuardedLogger.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Framework\Core\Logging; +use DeepWebSolutions\Framework\Core\PluginIdentity; use Psr\Log\InvalidArgumentException as PsrInvalidArgumentException; use Psr\Log\LoggerInterface as PsrLoggerInterface; @@ -26,12 +27,12 @@ final class GuardedLogger extends AbstractLogger { * @since 2.0.0 * @version 2.0.0 * - * @param PsrLoggerInterface $logger The logger that receives every record. - * @param string $channel The name that identifies the plugin in a record written to the PHP error log. + * @param PsrLoggerInterface $logger The logger that receives every record. + * @param PluginIdentity $identity The identity of the plugin, whose slug starts a record written to the PHP error log. */ public function __construct( protected PsrLoggerInterface $logger, - protected string $channel + protected PluginIdentity $identity ) {} // endregion @@ -61,7 +62,7 @@ public function log( mixed $level, string|\Stringable $message, array $context = } catch ( \Throwable $failure ) { // The raw message with its context as JSON keeps the values that no placeholder names. $encoded = wp_json_encode( \array_diff_key( $context, array( 'exception' => true ) ) ); - \error_log( self::format_record( $this->channel, $level, "$message $encoded", $context ) . "\nThe logger failed: $failure" ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- The wrapped logger failed. + \error_log( self::format_record( $this->identity, $level, "$message $encoded", $context ) . "\nThe logger failed: $failure" ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- The wrapped logger failed. } } diff --git a/packages/core/src/Migration/MigrationRunner.php b/packages/core/src/Migration/MigrationRunner.php index e3d25631..13db3ddf 100644 --- a/packages/core/src/Migration/MigrationRunner.php +++ b/packages/core/src/Migration/MigrationRunner.php @@ -7,6 +7,7 @@ use DeepWebSolutions\Framework\Core\ComponentInterface; use DeepWebSolutions\Framework\Core\Logging\GuardedLogger; use DeepWebSolutions\Framework\Core\PluginBoot; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Core\Scheduling\ScheduledHook; use DeepWebSolutions\Framework\Core\Scheduling\SchedulerInterface; use DeepWebSolutions\Framework\Core\Storage\Lock; @@ -22,7 +23,7 @@ /** * Brings a plugin's stored data to the revision its code expects, running the pending migration steps in order under a database lock. * - * Plain steps run on wp_loaded of any request, while batched steps run only from the runner's own scheduled hook, so no visitor waits for bulk work. + * Plain steps run on wp_loaded of any request, while batched steps run only from the runner's own scheduled hook, so no visitor waits for bulk work. A plugin runs one runner, since the plugin's slug names that hook. * * @since 2.0.0 * @version 2.0.0 @@ -42,6 +43,16 @@ final class MigrationRunner implements ComponentInterface { */ protected const int LOCK_TTL = 600; + /** + * The option that stores the data revision, which starts the name of every other option the runner owns. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var string + */ + protected string $option; + /** * The clock that the lock and the batch budget read. * @@ -63,7 +74,7 @@ final class MigrationRunner implements ComponentInterface { protected Lock $lock; /** - * The hook that runs the steps in the background, built once so that an option too long to name it fails at construction. + * The hook that runs the steps in the background, built once so that a slug too long to name it fails at construction. * * @since 2.0.0 * @version 2.0.0 @@ -102,30 +113,29 @@ final class MigrationRunner implements ComponentInterface { * @since 2.0.0 * @version 2.0.0 * - * @param string $plugin_file The path to the plugin's entry file. - * @param string $option The option that stores the data revision, and the prefix of every other option and hook the runner owns. + * @param PluginIdentity $identity The plugin's identity, whose prefix starts the option that stores the data revision, {prefix}_revision. * @param list<(callable(): void)|BatchedMigrationStepInterface> $steps The migration steps, append-only, where index 0 brings the data to revision 1. * @param PsrLoggerInterface $logger The logger that records failures and warnings. * @param SchedulerInterface $scheduler The scheduler that runs the runner's hook in the background. * @param int $batch_time_limit Optional. The seconds, from 1, that a request spends on steps and batches before it pauses. Default is 20. * @param PsrClockInterface|null $clock Optional. The clock. Default is the system clock. * - * @throws InvalidArgumentException Thrown when the option name cannot name the lock or the scheduled hook. + * @throws InvalidArgumentException Thrown when the slug cannot root the scheduled hook. */ public function __construct( - protected string $plugin_file, - protected string $option, + protected PluginIdentity $identity, protected array $steps, protected PsrLoggerInterface $logger, protected SchedulerInterface $scheduler, protected int $batch_time_limit = 20, ?PsrClockInterface $clock = null ) { + $this->option = "{$identity->prefix}_revision"; $this->clock = $clock ?? new SystemClock(); - $this->lock = new Lock( "{$option}_lock", self::LOCK_TTL, $this->clock ); + $this->lock = new Lock( $identity, 'revision_lock', self::LOCK_TTL, $this->clock ); $this->scheduled_hook = new ScheduledHook( $this->get_hook() ); $this->owner = wp_generate_uuid4(); - $this->logger = new GuardedLogger( $logger, $option ); + $this->logger = new GuardedLogger( $logger, $identity ); } // endregion @@ -334,7 +344,7 @@ public function render_notice(): void { return; } - $name = get_plugin_data( $this->plugin_file, false )['Name']; + $name = get_plugin_data( $this->identity->file, false )['Name']; $error = $this->get_error(); $batched = $this->steps[ $revision ] instanceof BatchedMigrationStepInterface; $run_now = esc_url( wp_nonce_url( admin_url( "admin-post.php?action={$this->get_hook()}_now" ), "{$this->get_hook()}_now" ) ); @@ -580,7 +590,7 @@ protected function get_time(): float { * @return non-empty-string */ protected function get_hook(): string { - return "$this->option/migration/run"; + return "{$this->identity->hook_root}/migration/run"; } // endregion diff --git a/packages/core/src/Permission/CapabilityGrants.php b/packages/core/src/Permission/CapabilityGrants.php index 3e5165ac..5f372839 100644 --- a/packages/core/src/Permission/CapabilityGrants.php +++ b/packages/core/src/Permission/CapabilityGrants.php @@ -4,7 +4,7 @@ use DeepWebSolutions\Framework\Core\ComponentInterface; use DeepWebSolutions\Framework\Core\Logging\GuardedLogger; -use DeepWebSolutions\Framework\Core\Storage\OptionRows; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; use Psr\Log\LoggerInterface as PsrLoggerInterface; @@ -21,6 +21,20 @@ * @phpstan-type Pairs array> */ final class CapabilityGrants implements ComponentInterface { + // region FIELDS AND CONSTANTS + + /** + * The option that stores, on each site, the fingerprint of the last run and the grants made, autoloaded: the plugin's prefix followed by _capabilities. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var string + */ + protected string $ledger_option; + + // endregion + // region MAGIC METHODS /** @@ -29,19 +43,19 @@ final class CapabilityGrants implements ComponentInterface { * @since 2.0.0 * @version 2.0.0 * - * @param string $ledger_option The option that stores, on each site, the fingerprint of the last run and the grants made, autoloaded. - * @param Pairs $grants The capabilities to grant, as lists keyed by role name. - * @param PsrLoggerInterface $logger The logger that records the writes that did not persist. + * @param PluginIdentity $identity The identity of the plugin whose capabilities are granted. + * @param Pairs $grants The capabilities to grant, as lists keyed by role name. + * @param PsrLoggerInterface $logger The logger that records the writes that did not persist. * - * @throws InvalidArgumentException Thrown when the option name or the grants are invalid. + * @throws InvalidArgumentException Thrown when the grants are invalid. */ public function __construct( - protected string $ledger_option, + PluginIdentity $identity, protected array $grants, protected PsrLoggerInterface $logger ) { - OptionRows::check_name( $ledger_option ); - $this->logger = new GuardedLogger( $logger, $ledger_option ); + $this->ledger_option = "{$identity->prefix}_capabilities"; + $this->logger = new GuardedLogger( $logger, $identity ); foreach ( $grants as $role => $caps ) { if ( ! \is_string( $role ) || '' === $role || ! \is_array( $caps ) || ! \array_is_list( $caps ) || ! \array_all( $caps, static fn ( mixed $cap ): bool => \is_string( $cap ) && '' !== $cap ) ) { diff --git a/packages/core/src/PluginBoot.php b/packages/core/src/PluginBoot.php index cd66d8a9..89752f01 100644 --- a/packages/core/src/PluginBoot.php +++ b/packages/core/src/PluginBoot.php @@ -37,16 +37,14 @@ final class PluginBoot { * @since 2.0.0 * @version 2.0.0 * - * @param string $plugin_file The path to the plugin's entry file. - * @param string $prefix The prefix of the plugin's hook names, transients and log channel. - * @param PsrLoggerInterface $logger The logger that records boot failures. + * @param PluginIdentity $identity The plugin's identity. + * @param PsrLoggerInterface $logger The logger that records boot failures. */ public function __construct( - protected string $plugin_file, - protected string $prefix, + protected PluginIdentity $identity, protected PsrLoggerInterface $logger ) { - $this->logger = new GuardedLogger( $logger, $prefix ); + $this->logger = new GuardedLogger( $logger, $identity ); } // endregion @@ -74,7 +72,7 @@ public function run( callable $compose ): void { $this->status = 'failed'; // A deterministic failure repeats on every request, so each distinct message is logged about once an hour, as best effort. - $key = "{$this->prefix}_boot_failure_" . \md5( $exception->getMessage() ); + $key = "{$this->identity->prefix}_boot_failure_" . \md5( $exception->getMessage() ); if ( false === get_transient( $key ) ) { set_transient( $key, 1, HOUR_IN_SECONDS ); $this->logger->error( @@ -86,7 +84,7 @@ public function run( callable $compose ): void { ); } - $plugin_file = $this->plugin_file; + $plugin_file = $this->identity->file; add_action( 'all_admin_notices', static function () use ( $plugin_file ): void { @@ -108,7 +106,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->identity->hook_root}/boot/failed", $exception ); if ( self::is_development_environment() ) { throw $exception; } @@ -125,7 +123,7 @@ static function () use ( $plugin_file ): void { * @since 2.0.0 * @version 2.0.0 */ - do_action( "$this->prefix/boot/blocked" ); + do_action( "{$this->identity->hook_root}/boot/blocked" ); return; } @@ -137,7 +135,7 @@ static function () use ( $plugin_file ): void { * @since 2.0.0 * @version 2.0.0 */ - do_action( "$this->prefix/boot/succeeded" ); + do_action( "{$this->identity->hook_root}/boot/succeeded" ); } /** diff --git a/packages/core/src/PluginIdentity.php b/packages/core/src/PluginIdentity.php new file mode 100644 index 00000000..578dc64f --- /dev/null +++ b/packages/core/src/PluginIdentity.php @@ -0,0 +1,60 @@ +hook_root = \str_replace( '-', '_', $slug ); + } + + // endregion +} diff --git a/packages/core/src/Scheduling/Scheduler.php b/packages/core/src/Scheduling/Scheduler.php index eab2dec1..9d54132f 100644 --- a/packages/core/src/Scheduling/Scheduler.php +++ b/packages/core/src/Scheduling/Scheduler.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\Framework\Core\Scheduling; use DeepWebSolutions\Framework\Core\ComponentInterface; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Core\Scheduling\Backends\ActionSchedulerBackend; use DeepWebSolutions\Framework\Core\Scheduling\Backends\BackendInterface; use DeepWebSolutions\Framework\Core\Scheduling\Backends\BackendState; @@ -64,17 +65,11 @@ final class Scheduler implements SchedulerInterface, ComponentInterface { * @since 2.0.0 * @version 2.0.0 * - * @param string $group The plugin's hook prefix, which names its Action Scheduler group and prefixes its WP-Cron schedules. - * - * @throws InvalidArgumentException Thrown when the group is invalid. + * @param PluginIdentity $identity The plugin's identity, whose prefix names its Action Scheduler group and prefixes its WP-Cron schedules. */ - public function __construct( string $group ) { - if ( 1 !== \preg_match( '/\A[a-z][a-z0-9_-]{0,189}\z/i', $group ) ) { - throw new InvalidArgumentException( "Group '$group' is not a letter followed by up to 189 letters, digits, underscores and hyphens." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. - } - - $this->wp_cron = new WpCronBackend( $group ); - $this->backends = array( new ActionSchedulerBackend( $group ), $this->wp_cron ); + public function __construct( PluginIdentity $identity ) { + $this->wp_cron = new WpCronBackend( $identity->prefix ); + $this->backends = array( new ActionSchedulerBackend( $identity->prefix ), $this->wp_cron ); $this->blog_id = get_current_blog_id(); } diff --git a/packages/core/src/Storage/Lock.php b/packages/core/src/Storage/Lock.php index c9009741..2ffbbb16 100644 --- a/packages/core/src/Storage/Lock.php +++ b/packages/core/src/Storage/Lock.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\Framework\Core\Storage; use DeepWebSolutions\Framework\Core\Clock\SystemClock; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; use DeepWebSolutions\Framework\Shared\Exception\RuntimeException; use Psr\Clock\ClockInterface as PsrClockInterface; @@ -32,6 +33,16 @@ */ protected PsrClockInterface $clock; + /** + * The option that holds the lock: the plugin's prefix, an underscore and the lock's name. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var string + */ + protected string $option; + // endregion // region MAGIC METHODS @@ -42,18 +53,21 @@ * @since 2.0.0 * @version 2.0.0 * - * @param string $name The name of the option that holds the lock, under the plugin's prefix. - * @param int $ttl The seconds that a hold lasts once it is taken or refreshed, from 1. - * @param PsrClockInterface|null $clock Optional. The clock. Default is the system clock. + * @param PluginIdentity $identity The identity of the plugin that owns the lock. + * @param string $name The lock's name, which follows the plugin's prefix and an underscore in the option that holds the lock. + * @param int $ttl The seconds that a hold lasts once it is taken or refreshed, from 1. + * @param PsrClockInterface|null $clock Optional. The clock. Default is the system clock. * - * @throws InvalidArgumentException Thrown when the name is not a valid option name or the TTL is below 1 second. + * @throws InvalidArgumentException Thrown when the option that holds the lock is not a valid option name or the TTL is below 1 second. */ public function __construct( - protected string $name, + PluginIdentity $identity, + string $name, protected int $ttl, ?PsrClockInterface $clock = null ) { - OptionRows::check_name( $name ); + $this->option = "{$identity->prefix}_$name"; + OptionRows::check_name( $this->option ); if ( $ttl < 1 ) { // A hold whose TTL is below 1 second expires as it is taken, which would end the exclusion without a sign. throw new InvalidArgumentException( "Lock TTL '$ttl' is below 1 second." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. @@ -88,11 +102,11 @@ public function acquire( string $owner ): LockClaim { $now = $this->clock->now()->getTimestamp(); $hold = $this->make_hold( $owner, $now ); - if ( ! \is_null( OptionRows::insert( $this->name, $hold ) ) ) { + if ( ! \is_null( OptionRows::insert( $this->option, $hold ) ) ) { return LockClaim::Acquired; } - $row = OptionRows::get( $this->name ); + $row = OptionRows::get( $this->option ); if ( \is_null( $row ) ) { return LockClaim::Busy; // The row was another owner's hold until its release after the insert. } @@ -120,7 +134,7 @@ public function acquire( string $owner ): LockClaim { * @return bool */ public function refresh( string $owner ): bool { - $row = OptionRows::get( $this->name ); + $row = OptionRows::get( $this->option ); if ( \is_null( $row ) || ( self::read( $row->value )['owner'] ?? null ) !== $owner ) { return false; } @@ -143,7 +157,7 @@ public function refresh( string $owner ): bool { * @return void */ public function release( string $owner ): void { - $row = OptionRows::get( $this->name ); + $row = OptionRows::get( $this->option ); if ( ! \is_null( $row ) && ( self::read( $row->value )['owner'] ?? null ) === $owner ) { OptionRows::delete( $row ); } diff --git a/packages/core/src/Uninstall/Uninstaller.php b/packages/core/src/Uninstall/Uninstaller.php index b2c83419..9f3c9b41 100644 --- a/packages/core/src/Uninstall/Uninstaller.php +++ b/packages/core/src/Uninstall/Uninstaller.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Framework\Core\Uninstall; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Shared\Exception\RuntimeException; \defined( 'ABSPATH' ) || exit; @@ -52,13 +53,13 @@ final class Uninstaller { * @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 PluginIdentity $identity The plugin's identity. * @param array $footprint The names and prefixes of every kind but the meta kinds, by kind. * @param array $meta_footprint The meta keys and meta prefixes, by meta type name. * @param array $args The consent, the meta types by name and the cleanup. */ protected function __construct( - protected readonly string $plugin_file, + protected readonly PluginIdentity $identity, protected readonly array $footprint, protected readonly array $meta_footprint, protected readonly array $args @@ -79,15 +80,15 @@ protected function __construct( * @phpstan-param Footprint $footprint * @phpstan-param Args $args * - * @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 is none, which keeps the footprint. + * @param PluginIdentity $identity The plugin's identity. + * @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 is none, which keeps the footprint. * * @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 */ - public static function run( string $plugin_file, array $footprint, array $args = array() ): void { + public static function run( PluginIdentity $identity, array $footprint, array $args = array() ): void { $args += array( 'consent' => array(), 'meta_types' => array(), @@ -119,7 +120,7 @@ public static function run( string $plugin_file, array $footprint, array $args = $args['meta_types'] = $meta_types; $uninstaller = new self( - $plugin_file, + $identity, \array_map( self::get_strings( ... ), \array_diff_key( @@ -230,7 +231,7 @@ protected function uninstall_site(): void { // A job that cannot be unscheduled only ever fires into a missing callback, so it is logged and never holds the uninstall back. $unscheduling = \array_splice( $this->failures, $failed ); if ( array() !== $unscheduling ) { - \error_log( '[' . plugin_basename( $this->plugin_file ) . '] Uninstall could not unschedule: ' . \implode( '; ', $unscheduling ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- No logger exists while a plugin is uninstalled. + \error_log( '[' . plugin_basename( $this->identity->file ) . '] Uninstall could not unschedule: ' . \implode( '; ', $unscheduling ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- No logger exists while a plugin is uninstalled. } $consent = get_option( $this->args['consent']['option'] ); @@ -438,7 +439,7 @@ protected function run_cleanup( string $scope ): bool { array( 'scope' => $scope, 'site_id' => get_current_blog_id(), - 'plugin_file' => $this->plugin_file, + 'plugin_file' => $this->identity->file, ) ); $result = null; diff --git a/packages/settings/README.md b/packages/settings/README.md index bc92082e..bdfc77e7 100644 --- a/packages/settings/README.md +++ b/packages/settings/README.md @@ -72,6 +72,8 @@ use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Bootstrap\Requirement defined( 'ABSPATH' ) || exit; +define( 'ACME_PS_FILE', __FILE__ ); + $acme_ps_bootstrap = __DIR__ . '/vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php'; if ( ! is_readable( $acme_ps_bootstrap ) ) { // The floor gate lives in the bootstrap package, so nothing built may load without it. return; @@ -86,30 +88,32 @@ 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, and a form on the post edit screen whose rejections core's `AdminNotices` reports: +The helpers need no kernel. On `plugins_loaded` at priority 15 the plugin builds its `PluginIdentity` from the entry file, its slug and its prefix, and registers its settings page and a form on the post edit screen whose rejections core's `AdminNotices` reports, each taking the identity, whose slug roots their filters: ```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\Core\PluginIdentity; 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_hooks', 15 ); - -// ... - -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(); -} +add_action( + 'plugins_loaded', + static function (): void { + $identity = new PluginIdentity( ACME_PS_FILE, Settings::SLUG, Settings::PREFIX ); + $notices = new AdminNotices( $identity, new ErrorLogLogger( $identity ) ); + $notices->register_hooks(); + + new OptionsPage( $identity, Settings::PREFIX, new Settings() )->register_hooks(); + new ObjectForm( $identity, Settings::PREFIX . '_details', new Details(), new PostMetaBox( array( 'post' ) ), $notices )->register_hooks(); + }, + 15 +); ``` A definition object declares the page and its fields as data. A field's `validate` callback returns the value to store, or a `WP_Error` whose message the page shows while the stored value stays: diff --git a/packages/settings/src/AdminPage/OptionsPage.php b/packages/settings/src/AdminPage/OptionsPage.php index 3a55504d..41a9c3bf 100644 --- a/packages/settings/src/AdminPage/OptionsPage.php +++ b/packages/settings/src/AdminPage/OptionsPage.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Framework\Settings\AdminPage; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Settings\Forms; use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; @@ -43,12 +44,14 @@ final class OptionsPage { * @since 2.0.0 * @version 2.0.0 * - * @param string $id The page's menu slug, which also prefixes its tabs filter. + * @param PluginIdentity $identity The plugin's identity, whose hook root starts the page's tabs filter. + * @param string $id The page's menu slug. * @param OptionsPageDefinitionInterface $definition The page's definition. * * @throws InvalidArgumentException Thrown when the id is not a valid key. */ public function __construct( + protected PluginIdentity $identity, protected string $id, protected OptionsPageDefinitionInterface $definition ) { @@ -242,9 +245,10 @@ protected function get_page(): array { * @since 2.0.0 * @version 2.0.0 * - * @param list $tabs The page's tabs. + * @param list $tabs The page's tabs. + * @param string $page_id The page's menu slug. */ - $page['tabs'] = apply_filters( "$this->id/options_page/tabs", $page['tabs'] ); + $page['tabs'] = apply_filters( "{$this->identity->hook_root}/options_page/tabs", $page['tabs'], $this->id ); $this->page = $page; } diff --git a/packages/settings/src/EditScreen/ObjectForm.php b/packages/settings/src/EditScreen/ObjectForm.php index 00558c45..6349d17a 100644 --- a/packages/settings/src/EditScreen/ObjectForm.php +++ b/packages/settings/src/EditScreen/ObjectForm.php @@ -4,6 +4,7 @@ use DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; use DeepWebSolutions\Framework\Core\AdminNotice\Notice; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Settings\Forms; use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; use DeepWebSolutions\Framework\Shared\Exception\LogicException; @@ -64,7 +65,8 @@ final class ObjectForm { * @since 2.0.0 * @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 PluginIdentity $identity The plugin's identity, whose hook root starts the form's fields filter. + * @param string $id The form's id, which starts with the plugin's prefix and names the form's controls and nonce. * @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. @@ -73,6 +75,7 @@ final class ObjectForm { * @throws InvalidArgumentException Thrown when the id is not a valid key. */ public function __construct( + protected PluginIdentity $identity, protected string $id, protected ObjectFormDefinitionInterface $definition, protected ObjectFormHostInterface $host, @@ -261,8 +264,9 @@ 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. + * @param string $form_id The form's id. */ - $form['fields'] = apply_filters( "$this->id/object_form/fields", $form['fields'], $object_id ); + $form['fields'] = apply_filters( "{$this->identity->hook_root}/object_form/fields", $form['fields'], $object_id, $this->id ); $this->forms[ $object_id ] = $form; } diff --git a/packages/woocommerce/README.md b/packages/woocommerce/README.md index 34883bc5..ac4db992 100644 --- a/packages/woocommerce/README.md +++ b/packages/woocommerce/README.md @@ -40,7 +40,7 @@ WordPress loads every plugin into one PHP process, so each plugin ships the fram "require-dev": { "ahegyes/wordpress-configs": "dev-trunk", "humbug/php-scoper": "^0.18", - "php-stubs/woocommerce-stubs": "^11.2" + "php-stubs/woocommerce-stubs": "^11.3" }, "repositories": [ { "type": "vcs", "url": "https://github.com/ahegyes/wordpress-configs" } @@ -92,10 +92,11 @@ require_once __DIR__ . '/vendor/autoload.php'; require_once __DIR__ . '/functions.php'; ``` -The helpers need no kernel. On `plugins_loaded` at priority 15, after WooCommerce has loaded, the plugin checks WooCommerce against its floor, registers a product data tab and adds a settings tab in WooCommerce's filter, where `WC_Settings_Page` exists: +The helpers need no kernel. On `plugins_loaded` at priority 15, after WooCommerce has loaded, the plugin builds its `PluginIdentity`, checks WooCommerce against the floor its entry file declares, registers a product data tab and adds a settings tab in WooCommerce's filter, where `WC_Settings_Page` exists: ```php use Acme\PersonaWooCommerce\Packing; +use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity; use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\WooCommerce\Logging\WooCommerceLogger; use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\WooCommerce\ProductData\ProductDataTab; use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\WooCommerce\Setting\SettingsTab; @@ -103,42 +104,40 @@ 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 ( ! WooCommerceRequirement::check( ACME_PW_FILE ) ) { - return; - } - - new ProductDataTab( Packing::PREFIX, new Packing() )->register_hooks(); - add_filter( 'woocommerce_get_settings_pages', 'acme_pw_add_settings_tab' ); - add_action( 'woocommerce_update_options_' . Packing::PREFIX, 'acme_pw_log_settings_save' ); -} - -// ... - -function acme_pw_add_settings_tab( array $pages ): array { - $pages[] = new SettingsTab( - Packing::PREFIX, - array( - 'label' => __( 'Packing', 'acme-persona-woocommerce' ), - 'sections' => array( '' => __( 'General', 'acme-persona-woocommerce' ) ), - ), - new Packing() - ); - - return $pages; -} - -// ... +add_action( + 'plugins_loaded', + static function (): void { + $identity = new PluginIdentity( ACME_PW_FILE, Packing::SLUG, Packing::PREFIX ); + if ( ! WooCommerceRequirement::check( $identity ) ) { + return; + } + + new ProductDataTab( $identity, Packing::PREFIX, new Packing() )->register_hooks(); + add_filter( + 'woocommerce_get_settings_pages', + static function ( array $pages ): array { + // WooCommerce's settings page class, which the tab extends, exists only once this filter runs. + $pages[] = new SettingsTab( + Packing::PREFIX, + array( + 'label' => __( 'Packing', 'acme-persona-woocommerce' ), + 'sections' => array( '' => __( 'General', 'acme-persona-woocommerce' ) ), + ), + new Packing() + ); -function acme_pw_log_settings_save(): void { - new WooCommerceLogger( 'acme-persona-woocommerce', 'info' )->info( 'The packing settings were saved.' ); -} + return $pages; + } + ); + add_action( + 'woocommerce_update_options_' . Packing::PREFIX, + static function () use ( $identity ): void { + new WooCommerceLogger( $identity, 'info' )->info( 'The packing settings were saved.' ); + } + ); + }, + 15 +); ``` A definition object declares both tabs' fields as data, which WooCommerce renders, validates and saves: @@ -157,7 +156,7 @@ final class Packing implements ProductDataTabDefinitionInterface, SectionFieldsD ), array( 'type' => 'checkbox', - 'id' => ACME_PW_OPTION_DELETE_DATA, + 'id' => Options::DELETE_DATA, 'title' => __( 'Data on uninstall', 'acme-persona-woocommerce' ), 'desc' => __( 'Delete the packing data when the plugin is uninstalled', 'acme-persona-woocommerce' ), ), @@ -175,7 +174,7 @@ final class Packing implements ProductDataTabDefinitionInterface, SectionFieldsD 'fields' => array( array( 'type' => 'text', - 'id' => ACME_PW_META_BOX, + 'id' => self::META_BOX, 'label' => __( 'Box', 'acme-persona-woocommerce' ), ), ), @@ -192,7 +191,7 @@ The plugin's `uninstall.php` runs core's `Uninstaller` as core's README shows, a ```php Uninstaller::run( - $acme_pw_file, + $acme_pw_identity, $acme_pw_footprint, array( 'consent' => array( 'option' => $acme_pw_consent ), diff --git a/packages/woocommerce/composer.json b/packages/woocommerce/composer.json index 5b40f0a8..57559258 100644 --- a/packages/woocommerce/composer.json +++ b/packages/woocommerce/composer.json @@ -18,7 +18,7 @@ "dev-trunk": "2.x-dev" }, "requires-wp": "7.1", - "requires-wc": "11.2", + "requires-wc": "11.3", "requires-plugins": [ "woocommerce" ], diff --git a/packages/woocommerce/src/Logging/WooCommerceLogger.php b/packages/woocommerce/src/Logging/WooCommerceLogger.php index 06378438..dfa135a1 100644 --- a/packages/woocommerce/src/Logging/WooCommerceLogger.php +++ b/packages/woocommerce/src/Logging/WooCommerceLogger.php @@ -4,6 +4,7 @@ use Automattic\WooCommerce\Utilities\LoggingUtil; use DeepWebSolutions\Framework\Core\Logging\AbstractLogger; +use DeepWebSolutions\Framework\Core\PluginIdentity; use Psr\Log\InvalidArgumentException as PsrInvalidArgumentException; use Psr\Log\LogLevel as PsrLogLevel; @@ -38,14 +39,14 @@ final class WooCommerceLogger extends AbstractLogger { * @since 2.0.0 * @version 2.0.0 * - * @param string $source The source that files every record under the plugin in WooCommerce's log. + * @param PluginIdentity $identity The identity of the plugin, whose slug is the source that files every record under it in WooCommerce's log. * @param string|null $threshold Optional. The lowest level to log, which replaces the default of warning, or of every level under WP_DEBUG. Default is null. * @param \WC_Logger_Interface|null $logger Optional. The WooCommerce logger to write to. Default is null, which resolves WooCommerce's logger on the first record, so construction never needs WooCommerce. The error log fallback follows WooCommerce's logging settings, whichever logger is given. * * @throws PsrInvalidArgumentException Thrown when the threshold is not a PSR-3 level. */ public function __construct( - protected string $source, + protected PluginIdentity $identity, ?string $threshold = null, protected ?\WC_Logger_Interface $logger = null ) { @@ -101,7 +102,7 @@ public function log( mixed $level, string|\Stringable $message, array $context = $message = self::interpolate( (string) $message, $context ); $this->logger ??= \function_exists( 'wc_get_logger' ) ? wc_get_logger() : null; - $this->logger?->log( $level, $message, array( 'source' => $this->source ) + $encoded ); + $this->logger?->log( $level, $message, array( 'source' => $this->identity->slug ) + $encoded ); if ( $severity < self::SEVERITIES[ PsrLogLevel::ERROR ] ) { return; @@ -109,7 +110,7 @@ public function log( mixed $level, string|\Stringable $message, array $context = $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. + \error_log( self::format_record( $this->identity, $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 147c8162..ecbd6edd 100644 --- a/packages/woocommerce/src/ProductData/ProductDataTab.php +++ b/packages/woocommerce/src/ProductData/ProductDataTab.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Framework\WooCommerce\ProductData; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Settings\Forms; use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; @@ -42,12 +43,14 @@ final class ProductDataTab { * @since 2.0.0 * @version 2.0.0 * - * @param string $id The tab's key among the product data tabs, which also prefixes its panel's HTML id and its groups filter. + * @param PluginIdentity $identity The plugin's identity, whose hook root starts the tab's groups filter. + * @param string $id The tab's key among the product data tabs, which also prefixes its panel's HTML id. * @param ProductDataTabDefinitionInterface $definition The definition, which the tab asks for once per product, on first use after init. * * @throws InvalidArgumentException Thrown when the id is not a valid key. */ public function __construct( + protected PluginIdentity $identity, protected string $id, protected ProductDataTabDefinitionInterface $definition ) { @@ -217,8 +220,9 @@ 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. + * @param string $tab_id The tab's key among the product data tabs. */ - return apply_filters( "$this->id/product_data_tab/groups", $tab['groups'], $product ); + return apply_filters( "{$this->identity->hook_root}/product_data_tab/groups", $tab['groups'], $product, $this->id ); } /** diff --git a/packages/woocommerce/src/WooCommerceRequirement.php b/packages/woocommerce/src/WooCommerceRequirement.php index 2480070c..12db62b3 100644 --- a/packages/woocommerce/src/WooCommerceRequirement.php +++ b/packages/woocommerce/src/WooCommerceRequirement.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\Framework\WooCommerce; use DeepWebSolutions\Framework\Bootstrap\Requirements; +use DeepWebSolutions\Framework\Core\PluginIdentity; \defined( 'ABSPATH' ) || exit; @@ -35,21 +36,21 @@ protected function __construct() {} * @since 2.0.0 * @version 2.0.0 * - * @param string $plugin_file The path to the plugin's entry file. + * @param PluginIdentity $identity The plugin's identity, whose entry file holds the WC requires at least header. * * @return bool */ - public static function check( string $plugin_file ): bool { - $floor = get_file_data( $plugin_file, array( 'floor' => 'WC requires at least' ) )['floor']; + public static function check( PluginIdentity $identity ): bool { + $floor = get_file_data( $identity->file, array( 'floor' => 'WC requires at least' ) )['floor']; if ( \defined( 'WC_VERSION' ) && ( '' === $floor || \version_compare( WC_VERSION, $floor, '>=' ) ) ) { return true; } add_action( 'all_admin_notices', - static function () use ( $plugin_file, $floor ): void { + static function () use ( $identity, $floor ): void { if ( ! \defined( 'WC_VERSION' ) ) { - $requirements = validate_plugin_requirements( plugin_basename( $plugin_file ) ); // WordPress core names a missing WooCommerce only for a plugin whose Requires Plugins header lists it. + $requirements = validate_plugin_requirements( plugin_basename( $identity->file ) ); // WordPress core names a missing WooCommerce only for a plugin whose Requires Plugins header lists it. if ( is_wp_error( $requirements ) ) { Requirements::print_admin_notice( $requirements ); } @@ -60,7 +61,7 @@ static function () use ( $plugin_file, $floor ): void { \sprintf( /* translators: 1: The plugin's name. 2: The lowest WooCommerce version the plugin runs on. */ esc_html__( '%1$s needs WooCommerce %2$s or later, so its features stay off until WooCommerce is updated.', 'wp-framework' ), - esc_html( get_plugin_data( $plugin_file, false )['Name'] ), + esc_html( get_plugin_data( $identity->file, false )['Name'] ), esc_html( $floor ) ) ); diff --git a/phpstan.bootstrap.neon b/phpstan.bootstrap.neon index c4c49229..d4296364 100644 --- a/phpstan.bootstrap.neon +++ b/phpstan.bootstrap.neon @@ -8,8 +8,8 @@ includes: parameters: paths: - packages/bootstrap - - tests/Fixtures/consumer-a/includes/safeguards.php - # The bootstrap package and consumer-a's safeguard run before any floor is checked, so they are analyzed at the oldest PHP and WordPress the bootstrap package runs on. + - tests/Fixtures/consumer-a/Safeguards.php + # The bootstrap package and consumer-a's safeguards run before any floor is checked, so they are analyzed at the oldest PHP and WordPress the bootstrap package runs on. phpVersion: 70400 WPCompat: requiresAtLeast: '5.2' diff --git a/tests/Fixtures/Core/assets/build/editor-rtl.css b/tests/Fixtures/Core/assets/build/editor-rtl.css new file mode 100644 index 00000000..aa939310 --- /dev/null +++ b/tests/Fixtures/Core/assets/build/editor-rtl.css @@ -0,0 +1 @@ +.acme-editor { margin-right: 1em; } diff --git a/tests/Fixtures/Core/assets/build/editor.asset.php b/tests/Fixtures/Core/assets/build/editor.asset.php new file mode 100644 index 00000000..72a35b33 --- /dev/null +++ b/tests/Fixtures/Core/assets/build/editor.asset.php @@ -0,0 +1,13 @@ + array( + 'wp-blocks', + array( + 'id' => '@wordpress/interactivity', + 'import' => 'static', + ), + 'wp-i18n', + ), + 'version' => 'a1b2c3', +); diff --git a/tests/Fixtures/Core/assets/build/editor.css b/tests/Fixtures/Core/assets/build/editor.css new file mode 100644 index 00000000..d0acd997 --- /dev/null +++ b/tests/Fixtures/Core/assets/build/editor.css @@ -0,0 +1 @@ +.acme-editor { margin-left: 1em; } diff --git a/tests/Fixtures/Core/assets/build/style-editor.css b/tests/Fixtures/Core/assets/build/style-editor.css new file mode 100644 index 00000000..b66b5648 --- /dev/null +++ b/tests/Fixtures/Core/assets/build/style-editor.css @@ -0,0 +1 @@ +.acme-editor { color: inherit; } diff --git a/tests/Fixtures/Core/assets/build/style-editor.js b/tests/Fixtures/Core/assets/build/style-editor.js new file mode 100644 index 00000000..7225e98e --- /dev/null +++ b/tests/Fixtures/Core/assets/build/style-editor.js @@ -0,0 +1 @@ +window.acmeStyleEditor = true; diff --git a/tests/Fixtures/Core/assets/build/vendor.css b/tests/Fixtures/Core/assets/build/vendor.css new file mode 100644 index 00000000..e55079fd --- /dev/null +++ b/tests/Fixtures/Core/assets/build/vendor.css @@ -0,0 +1 @@ +.acme-vendor { color: inherit; } diff --git a/tests/Fixtures/Core/assets/build/vendor.js b/tests/Fixtures/Core/assets/build/vendor.js new file mode 100644 index 00000000..2aa595e2 --- /dev/null +++ b/tests/Fixtures/Core/assets/build/vendor.js @@ -0,0 +1 @@ +window.acmeVendor = true; diff --git a/tests/Fixtures/Settings/acme-object-forms.php b/tests/Fixtures/Settings/acme-object-forms.php index 781a4283..00963de5 100644 --- a/tests/Fixtures/Settings/acme-object-forms.php +++ b/tests/Fixtures/Settings/acme-object-forms.php @@ -9,6 +9,7 @@ use DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; use DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Settings\EditScreen\Hosts\PostMetaBox; use DeepWebSolutions\Framework\Settings\EditScreen\Hosts\TermForm; use DeepWebSolutions\Framework\Settings\EditScreen\Hosts\UserProfile; @@ -66,22 +67,23 @@ public function update_values( int $object_id, array $values ): void { }; } - $notices = new AdminNotices( $spec['id'], new ErrorLogLogger( 'acme' ) ); + $identity = new PluginIdentity( __FILE__, $spec['slug'], $spec['id'] ); + $notices = new AdminNotices( $identity, new ErrorLogLogger( $identity ) ); $notices->register_hooks(); - new ObjectForm( $spec['id'], $definition, $host, $notices, $store )->register_hooks(); + new ObjectForm( $identity, $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 ): array { - $GLOBALS['acme_outcome']['records'][] = array( 'contributed', $object_id ); + str_replace( '-', '_', $spec['slug'] ) . '/object_form/fields', + static function ( array $fields, int $object_id, string $form_id ) use ( $spec ): array { + $GLOBALS['acme_outcome']['records'][] = array( 'contributed', $object_id, $form_id ); return array_merge( $fields, RecordedDefinition::with_behaviors( $spec['contributed'] ) ); }, 10, - 2 + 3 ); } diff --git a/tests/Fixtures/Settings/acme-options-page.php b/tests/Fixtures/Settings/acme-options-page.php index 1e0c6034..aa2c1913 100644 --- a/tests/Fixtures/Settings/acme-options-page.php +++ b/tests/Fixtures/Settings/acme-options-page.php @@ -7,6 +7,7 @@ * @package Acme */ +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Settings\AdminPage\OptionsPage; use DeepWebSolutions\Framework\Tests\Integration\Support\RecordedDefinition; @@ -24,6 +25,7 @@ 'plugins_loaded', static function () use ( $spec, $acme_with_behaviors ): void { $page = new OptionsPage( + new PluginIdentity( __FILE__, $spec['slug'], 'acme' ), $spec['id'], new RecordedDefinition( static fn (): array => array( 'tabs' => $acme_with_behaviors( $spec['page']['tabs'] ) ) + $spec['page'] ) ); @@ -33,14 +35,20 @@ static function () use ( $spec, $acme_with_behaviors ): void { if ( isset( $spec['contributed'] ) ) { add_filter( - "{$spec['id']}/options_page/tabs", - static function ( array $tabs ) use ( $spec, $acme_with_behaviors ): array { + str_replace( '-', '_', $spec['slug'] ) . '/options_page/tabs', + static function ( array $tabs, string $page_id ) use ( $spec, $acme_with_behaviors ): array { + if ( $spec['id'] !== $page_id ) { + return $tabs; + } + foreach ( $tabs as &$tab ) { $tab['fields'] = array_merge( $tab['fields'], $spec['contributed']['fields'][ $tab['slug'] ] ?? array() ); } return $acme_with_behaviors( array_merge( $tabs, $spec['contributed']['tabs'] ?? array() ) ); - } + }, + 10, + 2 ); } diff --git a/tests/Fixtures/WooCommerce/acme-product-data.php b/tests/Fixtures/WooCommerce/acme-product-data.php index 1cfbdab6..3e72a227 100644 --- a/tests/Fixtures/WooCommerce/acme-product-data.php +++ b/tests/Fixtures/WooCommerce/acme-product-data.php @@ -7,6 +7,7 @@ * @package Acme */ +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Tests\Integration\Support\RecordedDefinition; use DeepWebSolutions\Framework\WooCommerce\ProductData\ProductDataTab; @@ -43,21 +44,21 @@ static function ( string $tab_id, WC_Product $product ) use ( $spec ): ?array { } ); - new ProductDataTab( $spec['id'], $definition )->register_hooks(); + new ProductDataTab( new PluginIdentity( __FILE__, $spec['slug'], 'acme' ), $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 ): array { - $GLOBALS['acme_outcome']['records'][] = array( 'groups', $product->get_id() ); + str_replace( '-', '_', $spec['slug'] ) . '/product_data_tab/groups', + static function ( array $groups, WC_Product $product, string $tab_id ) use ( $spec ): array { + $GLOBALS['acme_outcome']['records'][] = array( 'groups', $product->get_id(), $tab_id ); $groups[] = array( 'fields' => RecordedDefinition::with_behaviors( $spec['contributed'] ) ); return $groups; }, 10, - 2 + 3 ); } diff --git a/tests/Fixtures/WooCommerce/acme-uninstall.php b/tests/Fixtures/WooCommerce/acme-uninstall.php index 2bc7f7dc..74af2c2f 100644 --- a/tests/Fixtures/WooCommerce/acme-uninstall.php +++ b/tests/Fixtures/WooCommerce/acme-uninstall.php @@ -5,6 +5,7 @@ * @package Acme */ +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Core\Uninstall\Uninstaller; use DeepWebSolutions\Framework\WooCommerce\Uninstall\MetaTypes; @@ -17,7 +18,7 @@ static function () use ( $spec ): void { $GLOBALS['acme_outcome']['records']['woocommerce_loaded'] = class_exists( 'WooCommerce', false ); Uninstaller::run( - $spec['plugin_file'], + new PluginIdentity( $spec['plugin_file'], 'acme-uninstall', 'acme' ), $spec['footprint'], array( 'consent' => $spec['consent'], diff --git a/tests/Fixtures/consumer-a/Safeguards.php b/tests/Fixtures/consumer-a/Safeguards.php new file mode 100644 index 00000000..89b267ed --- /dev/null +++ b/tests/Fixtures/consumer-a/Safeguards.php @@ -0,0 +1,80 @@ +getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- The plugin's logger is part of the build that failed. - add_action( 'all_admin_notices', 'acme_ca_render_build_notice' ); -} - -/** - * Prints the notice that the plugin's build is incomplete to users who can activate plugins. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @return void - */ -function acme_ca_render_build_notice(): void { - if ( ! current_user_can( 'activate_plugins' ) ) { - return; - } - - printf( '

%s

', esc_html__( 'Acme Consumer A is missing some of its files, so its features are off. Reinstall the plugin to restore them.', 'acme-consumer-a' ) ); + add_action( + 'all_admin_notices', + static function (): void { + if ( current_user_can( 'activate_plugins' ) ) { + printf( '

%s

', esc_html__( 'Acme Consumer A is missing some of its files, so its features are off. Reinstall the plugin to restore them.', 'acme-consumer-a' ) ); + } + } + ); } diff --git a/tests/Fixtures/consumer-a/composer.json b/tests/Fixtures/consumer-a/composer.json index a1bf3eb8..d92e68c5 100644 --- a/tests/Fixtures/consumer-a/composer.json +++ b/tests/Fixtures/consumer-a/composer.json @@ -29,7 +29,7 @@ "humbug/php-scoper": "^0.18", "laravel/serializable-closure": "^2.1", "php-di/php-di": "^7.1.1", - "php-stubs/woocommerce-stubs": "^11.2" + "php-stubs/woocommerce-stubs": "^11.3" }, "minimum-stability": "dev", diff --git a/tests/Fixtures/consumer-a/composer.lock b/tests/Fixtures/consumer-a/composer.lock index e3c74ed1..7cba8f6a 100644 --- a/tests/Fixtures/consumer-a/composer.lock +++ b/tests/Fixtures/consumer-a/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e29b2a1cf0d3437600a2a83b68489d84", + "content-hash": "5d45157ba16e981b18750714208c7cba", "packages": [], "packages-dev": [ { @@ -290,7 +290,7 @@ "dist": { "type": "path", "url": "../../../packages/woocommerce", - "reference": "79d6aea22490f658088fcea374c11a136074961f" + "reference": "7aa4faa5c982e5ba2e2ed72bdf6255e8a6cf79e9" }, "require": { "ahegyes/wp-framework-settings": "^2.0", @@ -302,6 +302,7 @@ "dev-trunk": "2.x-dev" }, "requires-wp": "7.1", + "requires-wc": "11.3", "requires-plugins": [ "woocommerce" ], @@ -812,23 +813,23 @@ }, { "name": "jetbrains/phpstorm-stubs", - "version": "v2026.2", + "version": "v2026.3", "source": { "type": "git", - "url": "https://github.com/JetBrains/phpstorm-stubs", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9" + "url": "https://github.com/JetBrains/phpstorm-stubs.git", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/709e512210784a7c0a677b3a89d35def844a59b9", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9", + "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/912ae721089fc48534d3a5795997ee8a2802b8cd", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd", "shasum": "" }, "require-dev": { "friendsofphp/php-cs-fixer": "^v3.86", "nikic/php-parser": "^v5.6", - "phpdocumentor/reflection-docblock": "^5.6", - "phpunit/phpunit": "^12.3" + "phpdocumentor/reflection-docblock": "^6.0", + "phpunit/phpunit": "^13.2" }, "type": "library", "autoload": { @@ -852,8 +853,11 @@ "stubs", "type" ], - "time": "2026-06-12T13:19:10+00:00", - "published-time": "2026-07-22T11:41:13+00:00" + "support": { + "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2026.3" + }, + "time": "2026-10-19T10:54:17+00:00", + "published-time": "2026-11-11T13:34:29+00:00" }, { "name": "johnbillion/wp-compat", @@ -933,16 +937,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.1.0", + "version": "v2.2.0", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5" + "reference": "2bfb44e8af009b553ed2c060c94b0bc3c67b4769" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5", - "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2bfb44e8af009b553ed2c060c94b0bc3c67b4769", + "reference": "2bfb44e8af009b553ed2c060c94b0bc3c67b4769", "shasum": "" }, "require": { @@ -990,8 +994,8 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-09-22T14:32:34+00:00", - "published-time": "2026-09-22T14:33:13+00:00" + "time": "2026-10-14T21:20:29+00:00", + "published-time": "2026-11-10T16:11:08+00:00" }, { "name": "nikic/php-parser", @@ -1183,16 +1187,16 @@ }, { "name": "php-stubs/woocommerce-stubs", - "version": "v11.2.2", + "version": "v11.3.0", "source": { "type": "git", "url": "https://github.com/php-stubs/woocommerce-stubs.git", - "reference": "d5c232c183c09689d75c177b01bef60e6dd53639" + "reference": "a897f9ad0552482e9d50d3937daab20dfb97265c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/d5c232c183c09689d75c177b01bef60e6dd53639", - "reference": "d5c232c183c09689d75c177b01bef60e6dd53639", + "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/a897f9ad0552482e9d50d3937daab20dfb97265c", + "reference": "a897f9ad0552482e9d50d3937daab20dfb97265c", "shasum": "" }, "require": { @@ -1221,10 +1225,10 @@ ], "support": { "issues": "https://github.com/php-stubs/woocommerce-stubs/issues", - "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.2.2" + "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.3.0" }, - "time": "2026-11-04T21:47:17+00:00", - "published-time": "2026-11-04T21:48:23+00:00" + "time": "2026-11-12T00:45:07+00:00", + "published-time": "2026-11-12T00:47:40+00:00" }, { "name": "php-stubs/wordpress-stubs", @@ -2169,12 +2173,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4" + "reference": "9fb1049dda127f1732cac541e68f8e490db77825" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4", - "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", + "reference": "9fb1049dda127f1732cac541e68f8e490db77825", "shasum": "" }, "conflict": { @@ -2256,6 +2260,7 @@ "barzahlen/barzahlen-php": "<2.0.1", "baserproject/basercms": "<=5.2.2", "bassjobsen/bootstrap-3-typeahead": ">4.0.2", + "bazaarvoice/bazaarvoice-magento2-ext": ">=8,<9.1.11", "bbpress/bbpress": "<2.6.5", "bcit-ci/codeigniter": "<3.1.3", "bcosca/fatfree": "<3.7.2", @@ -2327,9 +2332,9 @@ "contao-components/colorbox": ">=1,<1.6.4.3-dev", "contao-components/mediaelement": ">=2.14.2,<2.21.1", "contao/comments-bundle": ">=2,<5.3.50|>=5.4,<5.7.12", - "contao/contao": ">=3,<3.5.37|>=4,<5.3.50|>=5.4,<5.7.12", + "contao/contao": ">=3,<3.5.37|>=4,<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6", "contao/core": "<3.5.39", - "contao/core-bundle": "<5.3.50|>=5.4,<5.7.12", + "contao/core-bundle": "<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6", "contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8", "contao/managed-edition": "<=1.5", "contao/newsletter-bundle": ">=5,<5.3.50|>=5.4,<5.7.12", @@ -2539,6 +2544,7 @@ "gp247/core": "<1.1.24", "gree/jose": "<2.2.1", "gregwar/rst": "<1.0.3", + "grpc/grpc": "<1.84.1", "grumpydictator/firefly-iii": "<=6.6.2", "gugoan/economizzer": "<=0.9.0.0-beta1", "guzzlehttp/guzzle": "<7.15.2|>=8,<8.0.1", @@ -2709,7 +2715,7 @@ "mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7", "maximebf/debugbar": "<1.19", "mckenziearts/livewire-markdown-editor": "<1.3", - "mcp/sdk": ">=0.5,<0.7.1", + "mcp/sdk": ">=0.5,<0.7.1|>=0.8,<0.9", "mdanter/ecc": "<2", "mediaessenz/mail": "<3.12.5|>=4,<4.2", "mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2", @@ -2860,7 +2866,7 @@ "phpoffice/phpexcel": "<=1.8.2", "phpoffice/phpspreadsheet": "<=1.30.6|>=2,<=2.1.18|>=2.2,<=2.4.7|>=3,<=3.10.7|>=4,<=5.9", "phppgadmin/phppgadmin": "<=7.13", - "phpseclib/phpseclib": "<3.0.57|>=4,<4.0.1", + "phpseclib/phpseclib": "<3.0.58|>=4,<4.0.3", "phpservermon/phpservermon": "<3.6", "phpsysinfo/phpsysinfo": "<=3.4.5", "phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6|>=13.4,<13.4.4", @@ -3337,8 +3343,8 @@ "type": "tidelift" } ], - "time": "2026-11-10T03:11:40+00:00", - "published-time": "2026-11-10T03:11:44+00:00" + "time": "2026-11-12T14:42:16+00:00", + "published-time": "2026-11-12T14:42:22+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/consumer-a/functions.php b/tests/Fixtures/consumer-a/functions.php index aa503d58..e072332d 100644 --- a/tests/Fixtures/consumer-a/functions.php +++ b/tests/Fixtures/consumer-a/functions.php @@ -13,8 +13,19 @@ defined( 'ABSPATH' ) || exit; -add_action( 'plugins_loaded', 'acme_ca_boot', 15 ); -add_action( 'before_woocommerce_init', 'acme_ca_declare_compatibility' ); +add_action( + 'plugins_loaded', + static function (): void { + acme_ca_plugin()->boot(); + }, + 15 +); +add_action( + 'before_woocommerce_init', + static function (): void { + FeaturesUtil::declare_compatibility( 'custom_order_tables', ACME_CA_FILE, true ); + } +); $acme_ca_includes = glob( __DIR__ . '/includes/*.php' ); if ( false === $acme_ca_includes ) { @@ -31,6 +42,10 @@ /** * Returns the plugin's composition root, built on first use. * + * Only the function is contract: the root's members stay internal unless tagged @api. + * + * @api + * * @since 1.0.0 * @version 1.0.0 * @@ -44,31 +59,3 @@ function acme_ca_plugin(): Plugin { return $plugin; } - -/** - * Boots the plugin, reporting a composition root that cannot be built as an incomplete build. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @return void - */ -function acme_ca_boot(): void { - try { - acme_ca_plugin()->boot(); - } catch ( Throwable $error ) { - acme_ca_handle_build_error( $error ); - } -} - -/** - * Declares that the plugin works with WooCommerce's order tables. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @return void - */ -function acme_ca_declare_compatibility(): void { - FeaturesUtil::declare_compatibility( 'custom_order_tables', ACME_CA_FILE, true ); -} diff --git a/tests/Fixtures/consumer-a/includes/footprint.php b/tests/Fixtures/consumer-a/includes/footprint.php deleted file mode 100644 index bb2c845c..00000000 --- a/tests/Fixtures/consumer-a/includes/footprint.php +++ /dev/null @@ -1,34 +0,0 @@ -, meta: array{post: list}, user_meta_site_prefixes: list, user_meta_global_prefixes: list, network_options: list, cron_hooks: list, as_groups: list} - */ -function acme_ca_get_footprint(): array { - return array( - 'option_prefixes' => array( Plugin::PREFIX . '_', '_transient_' . Plugin::PREFIX . '_', '_transient_timeout_' . Plugin::PREFIX . '_' ), - 'meta' => array( 'post' => array( ProductData::NOTE_KEY ) ), - '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/consumer-a/includes/migrations.php b/tests/Fixtures/consumer-a/includes/migrations.php deleted file mode 100644 index 1a124954..00000000 --- a/tests/Fixtures/consumer-a/includes/migrations.php +++ /dev/null @@ -1,43 +0,0 @@ - - */ -function acme_ca_get_migration_steps(): array { - return array( - 'acme_ca_add_settings', - ); -} - -/** - * Stores the plugin's settings with their defaults, which keep the plugin's data on uninstall. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @throws RuntimeException Thrown when the settings could not be stored. - * - * @return void - */ -function acme_ca_add_settings(): void { - if ( false === get_option( Plugin::PREFIX . '_settings' ) && ! add_option( Plugin::PREFIX . '_settings', array( 'delete_data' => 'no' ) ) ) { - throw new RuntimeException( 'The settings could not be stored.' ); - } -} diff --git a/tests/Fixtures/consumer-a/includes/safeguards.php b/tests/Fixtures/consumer-a/includes/safeguards.php deleted file mode 100644 index afef6e34..00000000 --- a/tests/Fixtures/consumer-a/includes/safeguards.php +++ /dev/null @@ -1,54 +0,0 @@ -count_orders(); } finally { - remove_filter( Plugin::PREFIX . '/exports/should_restrict', '__return_false' ); + remove_filter( 'acme_consumer_a/exports/should_restrict', '__return_false' ); } if ( $count->is_success() ) { diff --git a/tests/Fixtures/consumer-a/src/FaultInjector.php b/tests/Fixtures/consumer-a/src/FaultInjector.php index 6eb58e1e..00c5cae1 100644 --- a/tests/Fixtures/consumer-a/src/FaultInjector.php +++ b/tests/Fixtures/consumer-a/src/FaultInjector.php @@ -56,7 +56,7 @@ public static function should_load(): bool { */ #[\Override] public function register_hooks(): void { - add_filter( Plugin::PREFIX . '/exports/should_restrict', '__return_false' ); + add_filter( 'acme_consumer_a/exports/should_restrict', '__return_false' ); if ( 'register' === \constant( 'ACME_CA_FAULT' ) ) { throw new \RuntimeException( 'The fault injector failed its hook registration.' ); } diff --git a/tests/Fixtures/consumer-a/src/Migrations.php b/tests/Fixtures/consumer-a/src/Migrations.php new file mode 100644 index 00000000..ee0a3c8b --- /dev/null +++ b/tests/Fixtures/consumer-a/src/Migrations.php @@ -0,0 +1,63 @@ + + */ + public static function get_steps(): array { + return array( + self::add_settings( ... ), + ); + } + + // endregion + + // region HELPERS + + /** + * Stores the plugin's settings with their defaults. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @throws \RuntimeException Thrown when the settings could not be stored. + * + * @return void + */ + protected static function add_settings(): void { + if ( false === get_option( Options::SETTINGS ) && ! add_option( Options::SETTINGS, Options::DEFAULTS ) ) { + throw new \RuntimeException( 'The settings could not be stored.' ); + } + } + + // endregion +} diff --git a/tests/Fixtures/consumer-a/src/Options.php b/tests/Fixtures/consumer-a/src/Options.php new file mode 100644 index 00000000..6522312e --- /dev/null +++ b/tests/Fixtures/consumer-a/src/Options.php @@ -0,0 +1,49 @@ + 'no' ); + + // endregion + + // region MAGIC METHODS + + /** + * Constructor. + * + * @since 1.0.0 + * @version 1.0.0 + */ + protected function __construct() {} + + // endregion +} diff --git a/tests/Fixtures/consumer-a/src/Plugin.php b/tests/Fixtures/consumer-a/src/Plugin.php index 3cce1e7a..94657160 100644 --- a/tests/Fixtures/consumer-a/src/Plugin.php +++ b/tests/Fixtures/consumer-a/src/Plugin.php @@ -5,6 +5,7 @@ use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner; use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\PluginBoot; +use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity; use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\PluginKernel; use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\Scheduling\Scheduler; use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\Scheduling\SchedulerInterface; @@ -25,7 +26,7 @@ final class Plugin { // region FIELDS AND CONSTANTS /** - * The plugin's slug, which is also its text domain and its log source. + * The plugin's slug, which is also its text domain, its log source and, in snake case, the root of its hook names. * * @since 1.0.0 * @version 1.0.0 @@ -35,7 +36,7 @@ final class Plugin { public const string SLUG = 'acme-consumer-a'; /** - * The prefix of the plugin's hook names, options, transients and scheduled jobs. + * The prefix of the plugin's options, transients and scheduled jobs. * * @since 1.0.0 * @version 1.0.0 @@ -61,6 +62,24 @@ final class Plugin { FaultInjector::class, ); + /** + * The plugin's stored data by kind, which covers every option, transient, meta key and scheduled job under the plugin's prefix. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var array{option_prefixes: list, meta: array{post: list}, user_meta_site_prefixes: list, user_meta_global_prefixes: list, network_options: list, cron_hooks: list, as_groups: list} + */ + public const array FOOTPRINT = array( + 'option_prefixes' => array( self::PREFIX . '_', '_transient_' . self::PREFIX . '_', '_transient_timeout_' . self::PREFIX . '_' ), + 'meta' => array( 'post' => array( ProductData::NOTE_KEY ) ), + 'user_meta_site_prefixes' => array( self::PREFIX . '_' ), + 'user_meta_global_prefixes' => array( self::PREFIX . '_' ), + 'network_options' => array( self::PREFIX . '_network_notices' ), + 'cron_hooks' => array( 'acme_consumer_a/migration/run' ), + 'as_groups' => array( self::PREFIX ), + ); + /** * The boot boundary, whose status tells whether the plugin booted. * @@ -111,6 +130,16 @@ final class Plugin { */ protected readonly PsrLoggerInterface $logger; + /** + * The plugin's identity, which every framework class the root builds takes. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var PluginIdentity + */ + protected readonly PluginIdentity $identity; + // endregion // region MAGIC METHODS @@ -122,9 +151,10 @@ final class Plugin { * @version 1.0.0 */ 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->boundary = new PluginBoot( ACME_CA_FILE, self::PREFIX, $this->logger ); + $this->identity = new PluginIdentity( ACME_CA_FILE, self::SLUG, self::PREFIX ); + $this->logger = new WooCommerceLogger( $this->identity, \defined( 'ACME_CA_DEBUG' ) && true === \constant( 'ACME_CA_DEBUG' ) ? 'debug' : null ); + $this->scheduler = new Scheduler( $this->identity ); + $this->boundary = new PluginBoot( $this->identity, $this->logger ); } // endregion @@ -157,14 +187,14 @@ 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 ( ! WooCommerceRequirement::check( ACME_CA_FILE ) ) { + if ( ! WooCommerceRequirement::check( $this->identity ) ) { return false; } - $this->migrations = new MigrationRunner( ACME_CA_FILE, self::PREFIX . '_revision', acme_ca_get_migration_steps(), $this->logger, $this->scheduler ); + $this->migrations = new MigrationRunner( $this->identity, Migrations::get_steps(), $this->logger, $this->scheduler ); $this->migrations->register_hooks(); - $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() ); + $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->identity, $this->logger, $this->scheduler ) )->build() ); $this->kernel->boot( self::COMPONENTS ); return true; @@ -176,17 +206,19 @@ protected function compose(): bool { * @since 1.0.0 * @version 1.0.0 * + * @param PluginIdentity $identity The plugin's identity. * @param PsrLoggerInterface $logger The plugin's logger. * @param Scheduler $scheduler The plugin's scheduler. * * @return array */ - protected function get_definitions( PsrLoggerInterface $logger, Scheduler $scheduler ): array { + protected function get_definitions( PluginIdentity $identity, PsrLoggerInterface $logger, Scheduler $scheduler ): array { return array( + PluginIdentity::class => static fn (): PluginIdentity => $identity, 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 ), + AdminNotices::class => static fn (): AdminNotices => new AdminNotices( $identity, $logger ), ); } diff --git a/tests/Fixtures/consumer-a/src/ProductData.php b/tests/Fixtures/consumer-a/src/ProductData.php index eff375f4..85b8ec1e 100644 --- a/tests/Fixtures/consumer-a/src/ProductData.php +++ b/tests/Fixtures/consumer-a/src/ProductData.php @@ -3,6 +3,7 @@ namespace Acme\ConsumerA; use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\ComponentInterface; +use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity; use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\WooCommerce\ProductData\ProductDataTab; use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\WooCommerce\ProductData\ProductDataTabDefinitionInterface; @@ -29,6 +30,22 @@ final class ProductData implements ComponentInterface, ProductDataTabDefinitionI // endregion + // region MAGIC METHODS + + /** + * Constructor. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @param PluginIdentity $identity The plugin's identity, which the tab takes. + */ + public function __construct( + protected PluginIdentity $identity + ) {} + + // endregion + // region INHERITED METHODS /** @@ -39,7 +56,7 @@ final class ProductData implements ComponentInterface, ProductDataTabDefinitionI */ #[\Override] public function register_hooks(): void { - new ProductDataTab( Plugin::PREFIX . '_packing', $this )->register_hooks(); + new ProductDataTab( $this->identity, Plugin::PREFIX . '_packing', $this )->register_hooks(); } /** diff --git a/tests/Fixtures/consumer-a/uninstall.php b/tests/Fixtures/consumer-a/uninstall.php index 58a0d6f6..deb42307 100644 --- a/tests/Fixtures/consumer-a/uninstall.php +++ b/tests/Fixtures/consumer-a/uninstall.php @@ -8,8 +8,10 @@ * @package Acme\ConsumerA */ +use Acme\ConsumerA\Options; use Acme\ConsumerA\Plugin; use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Bootstrap\Requirements; +use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity; use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\Core\Uninstall\Uninstaller; use Acme\ConsumerA\Scoped\DeepWebSolutions\Framework\WooCommerce\Uninstall\MetaTypes; @@ -28,13 +30,14 @@ } 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_footprint = Plugin::FOOTPRINT; + $acme_ca_consent = Options::SETTINGS; $acme_ca_meta_types = MetaTypes::DEFINITIONS; if ( ! class_exists( Uninstaller::class ) ) { throw new RuntimeException( 'its uninstall engine is missing.' ); } + + $acme_ca_identity = new PluginIdentity( $acme_ca_file, Plugin::SLUG, Plugin::PREFIX ); } 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 +50,7 @@ try { Uninstaller::run( - $acme_ca_file, + $acme_ca_identity, $acme_ca_footprint, array( 'consent' => array( diff --git a/tests/Fixtures/consumer-b/composer.lock b/tests/Fixtures/consumer-b/composer.lock index 066b5350..9bdb98a1 100644 --- a/tests/Fixtures/consumer-b/composer.lock +++ b/tests/Fixtures/consumer-b/composer.lock @@ -771,23 +771,23 @@ }, { "name": "jetbrains/phpstorm-stubs", - "version": "v2026.2", + "version": "v2026.3", "source": { "type": "git", - "url": "https://github.com/JetBrains/phpstorm-stubs", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9" + "url": "https://github.com/JetBrains/phpstorm-stubs.git", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/709e512210784a7c0a677b3a89d35def844a59b9", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9", + "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/912ae721089fc48534d3a5795997ee8a2802b8cd", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd", "shasum": "" }, "require-dev": { "friendsofphp/php-cs-fixer": "^v3.86", "nikic/php-parser": "^v5.6", - "phpdocumentor/reflection-docblock": "^5.6", - "phpunit/phpunit": "^12.3" + "phpdocumentor/reflection-docblock": "^6.0", + "phpunit/phpunit": "^13.2" }, "type": "library", "autoload": { @@ -811,8 +811,11 @@ "stubs", "type" ], - "time": "2026-06-12T13:19:10+00:00", - "published-time": "2026-07-22T11:41:13+00:00" + "support": { + "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2026.3" + }, + "time": "2026-10-19T10:54:17+00:00", + "published-time": "2026-11-11T13:34:29+00:00" }, { "name": "johnbillion/wp-compat", @@ -892,16 +895,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.1.0", + "version": "v2.2.0", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5" + "reference": "2bfb44e8af009b553ed2c060c94b0bc3c67b4769" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5", - "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2bfb44e8af009b553ed2c060c94b0bc3c67b4769", + "reference": "2bfb44e8af009b553ed2c060c94b0bc3c67b4769", "shasum": "" }, "require": { @@ -949,8 +952,8 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-09-22T14:32:34+00:00", - "published-time": "2026-09-22T14:33:13+00:00" + "time": "2026-10-14T21:20:29+00:00", + "published-time": "2026-11-10T16:11:08+00:00" }, { "name": "nikic/php-parser", @@ -2083,12 +2086,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4" + "reference": "9fb1049dda127f1732cac541e68f8e490db77825" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4", - "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", + "reference": "9fb1049dda127f1732cac541e68f8e490db77825", "shasum": "" }, "conflict": { @@ -2170,6 +2173,7 @@ "barzahlen/barzahlen-php": "<2.0.1", "baserproject/basercms": "<=5.2.2", "bassjobsen/bootstrap-3-typeahead": ">4.0.2", + "bazaarvoice/bazaarvoice-magento2-ext": ">=8,<9.1.11", "bbpress/bbpress": "<2.6.5", "bcit-ci/codeigniter": "<3.1.3", "bcosca/fatfree": "<3.7.2", @@ -2241,9 +2245,9 @@ "contao-components/colorbox": ">=1,<1.6.4.3-dev", "contao-components/mediaelement": ">=2.14.2,<2.21.1", "contao/comments-bundle": ">=2,<5.3.50|>=5.4,<5.7.12", - "contao/contao": ">=3,<3.5.37|>=4,<5.3.50|>=5.4,<5.7.12", + "contao/contao": ">=3,<3.5.37|>=4,<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6", "contao/core": "<3.5.39", - "contao/core-bundle": "<5.3.50|>=5.4,<5.7.12", + "contao/core-bundle": "<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6", "contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8", "contao/managed-edition": "<=1.5", "contao/newsletter-bundle": ">=5,<5.3.50|>=5.4,<5.7.12", @@ -2453,6 +2457,7 @@ "gp247/core": "<1.1.24", "gree/jose": "<2.2.1", "gregwar/rst": "<1.0.3", + "grpc/grpc": "<1.84.1", "grumpydictator/firefly-iii": "<=6.6.2", "gugoan/economizzer": "<=0.9.0.0-beta1", "guzzlehttp/guzzle": "<7.15.2|>=8,<8.0.1", @@ -2623,7 +2628,7 @@ "mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7", "maximebf/debugbar": "<1.19", "mckenziearts/livewire-markdown-editor": "<1.3", - "mcp/sdk": ">=0.5,<0.7.1", + "mcp/sdk": ">=0.5,<0.7.1|>=0.8,<0.9", "mdanter/ecc": "<2", "mediaessenz/mail": "<3.12.5|>=4,<4.2", "mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2", @@ -2774,7 +2779,7 @@ "phpoffice/phpexcel": "<=1.8.2", "phpoffice/phpspreadsheet": "<=1.30.6|>=2,<=2.1.18|>=2.2,<=2.4.7|>=3,<=3.10.7|>=4,<=5.9", "phppgadmin/phppgadmin": "<=7.13", - "phpseclib/phpseclib": "<3.0.57|>=4,<4.0.1", + "phpseclib/phpseclib": "<3.0.58|>=4,<4.0.3", "phpservermon/phpservermon": "<3.6", "phpsysinfo/phpsysinfo": "<=3.4.5", "phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6|>=13.4,<13.4.4", @@ -3251,8 +3256,8 @@ "type": "tidelift" } ], - "time": "2026-11-10T03:11:40+00:00", - "published-time": "2026-11-10T03:11:44+00:00" + "time": "2026-11-12T14:42:16+00:00", + "published-time": "2026-11-12T14:42:22+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/consumer-b/functions.php b/tests/Fixtures/consumer-b/functions.php index 429a12c1..bae76a40 100644 --- a/tests/Fixtures/consumer-b/functions.php +++ b/tests/Fixtures/consumer-b/functions.php @@ -12,11 +12,21 @@ defined( 'ABSPATH' ) || exit; -add_action( 'plugins_loaded', 'acme_cb_boot', 15 ); +add_action( + 'plugins_loaded', + static function (): void { + acme_cb_plugin()->boot(); + }, + 15 +); /** * Returns the plugin's composition root, built on first use. * + * Only the function is contract: the root's members stay internal unless tagged @api. + * + * @api + * * @since 1.0.0 * @version 1.0.0 * @@ -30,15 +40,3 @@ function acme_cb_plugin(): Plugin { return $plugin; } - -/** - * Boots the plugin. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @return void - */ -function acme_cb_boot(): void { - acme_cb_plugin()->boot(); -} diff --git a/tests/Fixtures/consumer-b/src/Plugin.php b/tests/Fixtures/consumer-b/src/Plugin.php index 13471085..981f7386 100644 --- a/tests/Fixtures/consumer-b/src/Plugin.php +++ b/tests/Fixtures/consumer-b/src/Plugin.php @@ -5,6 +5,7 @@ use Acme\ConsumerB\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; use Acme\ConsumerB\Scoped\DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger; use Acme\ConsumerB\Scoped\DeepWebSolutions\Framework\Core\PluginBoot; +use Acme\ConsumerB\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity; use Acme\ConsumerB\Scoped\DeepWebSolutions\Framework\Core\PluginKernel; use Acme\ConsumerB\Scoped\DI\ContainerBuilder; use Acme\ConsumerB\Scoped\Psr\Log\LoggerInterface as PsrLoggerInterface; @@ -21,7 +22,7 @@ final class Plugin { // region FIELDS AND CONSTANTS /** - * The plugin's slug, which is also its text domain and its log channel. + * The plugin's slug, which is also its text domain, its log channel and, in snake case, the root of its hook names. * * @since 1.0.0 * @version 1.0.0 @@ -31,7 +32,7 @@ final class Plugin { public const string SLUG = 'acme-consumer-b'; /** - * The prefix of the plugin's hook names, options and admin page. + * The prefix of the plugin's options and admin page. * * @since 1.0.0 * @version 1.0.0 @@ -83,6 +84,16 @@ final class Plugin { */ protected readonly PsrLoggerInterface $logger; + /** + * The plugin's identity, which every framework class the root builds takes. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var PluginIdentity + */ + protected readonly PluginIdentity $identity; + // endregion // region MAGIC METHODS @@ -94,8 +105,9 @@ final class Plugin { * @version 1.0.0 */ public function __construct() { - $this->logger = new ErrorLogLogger( self::SLUG ); - $this->boundary = new PluginBoot( ACME_CB_FILE, self::PREFIX, $this->logger ); + $this->identity = new PluginIdentity( ACME_CB_FILE, self::SLUG, self::PREFIX ); + $this->logger = new ErrorLogLogger( $this->identity ); + $this->boundary = new PluginBoot( $this->identity, $this->logger ); } // endregion @@ -127,7 +139,7 @@ public function boot(): void { * @return bool */ protected function compose(): bool { - $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger ) )->build() ); + $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->identity, $this->logger ) )->build() ); $this->kernel->boot( self::COMPONENTS ); return true; @@ -139,13 +151,15 @@ protected function compose(): bool { * @since 1.0.0 * @version 1.0.0 * - * @param PsrLoggerInterface $logger The plugin's logger. + * @param PluginIdentity $identity The plugin's identity. + * @param PsrLoggerInterface $logger The plugin's logger. * * @return array */ - protected function get_definitions( PsrLoggerInterface $logger ): array { + protected function get_definitions( PluginIdentity $identity, PsrLoggerInterface $logger ): array { return array( - AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::PREFIX, $logger ), + PluginIdentity::class => static fn (): PluginIdentity => $identity, + AdminNotices::class => static fn (): AdminNotices => new AdminNotices( $identity, $logger ), ); } diff --git a/tests/Fixtures/consumer-b/src/Settings.php b/tests/Fixtures/consumer-b/src/Settings.php index 40a0ddcf..e7fdeb1d 100644 --- a/tests/Fixtures/consumer-b/src/Settings.php +++ b/tests/Fixtures/consumer-b/src/Settings.php @@ -3,6 +3,7 @@ namespace Acme\ConsumerB; use Acme\ConsumerB\Scoped\DeepWebSolutions\Framework\Core\ComponentInterface; +use Acme\ConsumerB\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity; use Acme\ConsumerB\Scoped\DeepWebSolutions\Framework\Settings\AdminPage\OptionsPage; use Acme\ConsumerB\Scoped\DeepWebSolutions\Framework\Settings\AdminPage\OptionsPageDefinitionInterface; @@ -15,6 +16,22 @@ * @version 1.0.0 */ final class Settings implements ComponentInterface, OptionsPageDefinitionInterface { + // region MAGIC METHODS + + /** + * Constructor. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @param PluginIdentity $identity The plugin's identity, which the page takes. + */ + public function __construct( + protected PluginIdentity $identity + ) {} + + // endregion + // region INHERITED METHODS /** @@ -25,7 +42,7 @@ final class Settings implements ComponentInterface, OptionsPageDefinitionInterfa */ #[\Override] public function register_hooks(): void { - new OptionsPage( Plugin::PREFIX, $this )->register_hooks(); + new OptionsPage( $this->identity, Plugin::PREFIX, $this )->register_hooks(); } /** 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 ffab61bd..231b5d90 100644 --- a/tests/Fixtures/end-to-end/acme-end-to-end.php +++ b/tests/Fixtures/end-to-end/acme-end-to-end.php @@ -12,6 +12,7 @@ use DeepWebSolutions\Framework\Core\AdminNotice\NoticeScope; use DeepWebSolutions\Framework\Core\AdminNotice\NoticeType; use DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Settings\EditScreen\Hosts\PostMetaBox; use DeepWebSolutions\Framework\Settings\EditScreen\ObjectForm; use DeepWebSolutions\Framework\Settings\EditScreen\ObjectFormDefinitionInterface; @@ -19,7 +20,8 @@ // wp-env maps the repository to wp-content/mu-plugins/wp-framework and this directory into the plugins directory. require_once WPMU_PLUGIN_DIR . '/wp-framework/vendor/autoload.php'; -$acme_notices = new AdminNotices( 'acme_e2e', new ErrorLogLogger( 'acme-end-to-end' ) ); +$acme_identity = new PluginIdentity( __FILE__, 'acme-e2e', 'acme_e2e' ); +$acme_notices = new AdminNotices( $acme_identity, new ErrorLogLogger( $acme_identity ) ); register_activation_hook( __FILE__, @@ -37,7 +39,7 @@ static function () use ( $acme_notices ): void { add_action( 'plugins_loaded', - static function () use ( $acme_notices ): void { + static function () use ( $acme_identity, $acme_notices ): void { $acme_notices->register_hooks(); $definition = new class() implements ObjectFormDefinitionInterface { @@ -56,7 +58,7 @@ public function get_object_form( string $form_id, int $object_id ): array { ); } }; - new ObjectForm( 'acme_e2e_details', $definition, new PostMetaBox( array( 'post' ) ), $acme_notices )->register_hooks(); + new ObjectForm( $acme_identity, 'acme_e2e_details', $definition, new PostMetaBox( array( 'post' ) ), $acme_notices )->register_hooks(); } ); diff --git a/tests/Fixtures/personas/bootstrap/composer.lock b/tests/Fixtures/personas/bootstrap/composer.lock index e7a0cc4a..a983027f 100644 --- a/tests/Fixtures/personas/bootstrap/composer.lock +++ b/tests/Fixtures/personas/bootstrap/composer.lock @@ -663,23 +663,23 @@ }, { "name": "jetbrains/phpstorm-stubs", - "version": "v2026.2", + "version": "v2026.3", "source": { "type": "git", - "url": "https://github.com/JetBrains/phpstorm-stubs", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9" + "url": "https://github.com/JetBrains/phpstorm-stubs.git", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/709e512210784a7c0a677b3a89d35def844a59b9", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9", + "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/912ae721089fc48534d3a5795997ee8a2802b8cd", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd", "shasum": "" }, "require-dev": { "friendsofphp/php-cs-fixer": "^v3.86", "nikic/php-parser": "^v5.6", - "phpdocumentor/reflection-docblock": "^5.6", - "phpunit/phpunit": "^12.3" + "phpdocumentor/reflection-docblock": "^6.0", + "phpunit/phpunit": "^13.2" }, "type": "library", "autoload": { @@ -703,8 +703,11 @@ "stubs", "type" ], - "time": "2026-06-12T13:19:10+00:00", - "published-time": "2026-07-22T11:41:13+00:00" + "support": { + "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2026.3" + }, + "time": "2026-10-19T10:54:17+00:00", + "published-time": "2026-11-11T13:34:29+00:00" }, { "name": "johnbillion/wp-compat", @@ -1682,12 +1685,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4" + "reference": "9fb1049dda127f1732cac541e68f8e490db77825" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4", - "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", + "reference": "9fb1049dda127f1732cac541e68f8e490db77825", "shasum": "" }, "conflict": { @@ -1769,6 +1772,7 @@ "barzahlen/barzahlen-php": "<2.0.1", "baserproject/basercms": "<=5.2.2", "bassjobsen/bootstrap-3-typeahead": ">4.0.2", + "bazaarvoice/bazaarvoice-magento2-ext": ">=8,<9.1.11", "bbpress/bbpress": "<2.6.5", "bcit-ci/codeigniter": "<3.1.3", "bcosca/fatfree": "<3.7.2", @@ -1840,9 +1844,9 @@ "contao-components/colorbox": ">=1,<1.6.4.3-dev", "contao-components/mediaelement": ">=2.14.2,<2.21.1", "contao/comments-bundle": ">=2,<5.3.50|>=5.4,<5.7.12", - "contao/contao": ">=3,<3.5.37|>=4,<5.3.50|>=5.4,<5.7.12", + "contao/contao": ">=3,<3.5.37|>=4,<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6", "contao/core": "<3.5.39", - "contao/core-bundle": "<5.3.50|>=5.4,<5.7.12", + "contao/core-bundle": "<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6", "contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8", "contao/managed-edition": "<=1.5", "contao/newsletter-bundle": ">=5,<5.3.50|>=5.4,<5.7.12", @@ -2052,6 +2056,7 @@ "gp247/core": "<1.1.24", "gree/jose": "<2.2.1", "gregwar/rst": "<1.0.3", + "grpc/grpc": "<1.84.1", "grumpydictator/firefly-iii": "<=6.6.2", "gugoan/economizzer": "<=0.9.0.0-beta1", "guzzlehttp/guzzle": "<7.15.2|>=8,<8.0.1", @@ -2222,7 +2227,7 @@ "mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7", "maximebf/debugbar": "<1.19", "mckenziearts/livewire-markdown-editor": "<1.3", - "mcp/sdk": ">=0.5,<0.7.1", + "mcp/sdk": ">=0.5,<0.7.1|>=0.8,<0.9", "mdanter/ecc": "<2", "mediaessenz/mail": "<3.12.5|>=4,<4.2", "mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2", @@ -2373,7 +2378,7 @@ "phpoffice/phpexcel": "<=1.8.2", "phpoffice/phpspreadsheet": "<=1.30.6|>=2,<=2.1.18|>=2.2,<=2.4.7|>=3,<=3.10.7|>=4,<=5.9", "phppgadmin/phppgadmin": "<=7.13", - "phpseclib/phpseclib": "<3.0.57|>=4,<4.0.1", + "phpseclib/phpseclib": "<3.0.58|>=4,<4.0.3", "phpservermon/phpservermon": "<3.6", "phpsysinfo/phpsysinfo": "<=3.4.5", "phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6|>=13.4,<13.4.4", @@ -2850,8 +2855,8 @@ "type": "tidelift" } ], - "time": "2026-11-10T03:11:40+00:00", - "published-time": "2026-11-10T03:11:44+00:00" + "time": "2026-11-12T14:42:16+00:00", + "published-time": "2026-11-12T14:42:22+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/personas/core/composer.lock b/tests/Fixtures/personas/core/composer.lock index 38b1e68b..6273806f 100644 --- a/tests/Fixtures/personas/core/composer.lock +++ b/tests/Fixtures/personas/core/composer.lock @@ -736,23 +736,23 @@ }, { "name": "jetbrains/phpstorm-stubs", - "version": "v2026.2", + "version": "v2026.3", "source": { "type": "git", - "url": "https://github.com/JetBrains/phpstorm-stubs", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9" + "url": "https://github.com/JetBrains/phpstorm-stubs.git", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/709e512210784a7c0a677b3a89d35def844a59b9", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9", + "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/912ae721089fc48534d3a5795997ee8a2802b8cd", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd", "shasum": "" }, "require-dev": { "friendsofphp/php-cs-fixer": "^v3.86", "nikic/php-parser": "^v5.6", - "phpdocumentor/reflection-docblock": "^5.6", - "phpunit/phpunit": "^12.3" + "phpdocumentor/reflection-docblock": "^6.0", + "phpunit/phpunit": "^13.2" }, "type": "library", "autoload": { @@ -776,8 +776,11 @@ "stubs", "type" ], - "time": "2026-06-12T13:19:10+00:00", - "published-time": "2026-07-22T11:41:13+00:00" + "support": { + "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2026.3" + }, + "time": "2026-10-19T10:54:17+00:00", + "published-time": "2026-11-11T13:34:29+00:00" }, { "name": "johnbillion/wp-compat", @@ -857,16 +860,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.1.0", + "version": "v2.2.0", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5" + "reference": "2bfb44e8af009b553ed2c060c94b0bc3c67b4769" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5", - "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2bfb44e8af009b553ed2c060c94b0bc3c67b4769", + "reference": "2bfb44e8af009b553ed2c060c94b0bc3c67b4769", "shasum": "" }, "require": { @@ -914,8 +917,8 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-09-22T14:32:34+00:00", - "published-time": "2026-09-22T14:33:13+00:00" + "time": "2026-10-14T21:20:29+00:00", + "published-time": "2026-11-10T16:11:08+00:00" }, { "name": "nikic/php-parser", @@ -2048,12 +2051,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4" + "reference": "9fb1049dda127f1732cac541e68f8e490db77825" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4", - "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", + "reference": "9fb1049dda127f1732cac541e68f8e490db77825", "shasum": "" }, "conflict": { @@ -2135,6 +2138,7 @@ "barzahlen/barzahlen-php": "<2.0.1", "baserproject/basercms": "<=5.2.2", "bassjobsen/bootstrap-3-typeahead": ">4.0.2", + "bazaarvoice/bazaarvoice-magento2-ext": ">=8,<9.1.11", "bbpress/bbpress": "<2.6.5", "bcit-ci/codeigniter": "<3.1.3", "bcosca/fatfree": "<3.7.2", @@ -2206,9 +2210,9 @@ "contao-components/colorbox": ">=1,<1.6.4.3-dev", "contao-components/mediaelement": ">=2.14.2,<2.21.1", "contao/comments-bundle": ">=2,<5.3.50|>=5.4,<5.7.12", - "contao/contao": ">=3,<3.5.37|>=4,<5.3.50|>=5.4,<5.7.12", + "contao/contao": ">=3,<3.5.37|>=4,<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6", "contao/core": "<3.5.39", - "contao/core-bundle": "<5.3.50|>=5.4,<5.7.12", + "contao/core-bundle": "<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6", "contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8", "contao/managed-edition": "<=1.5", "contao/newsletter-bundle": ">=5,<5.3.50|>=5.4,<5.7.12", @@ -2418,6 +2422,7 @@ "gp247/core": "<1.1.24", "gree/jose": "<2.2.1", "gregwar/rst": "<1.0.3", + "grpc/grpc": "<1.84.1", "grumpydictator/firefly-iii": "<=6.6.2", "gugoan/economizzer": "<=0.9.0.0-beta1", "guzzlehttp/guzzle": "<7.15.2|>=8,<8.0.1", @@ -2588,7 +2593,7 @@ "mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7", "maximebf/debugbar": "<1.19", "mckenziearts/livewire-markdown-editor": "<1.3", - "mcp/sdk": ">=0.5,<0.7.1", + "mcp/sdk": ">=0.5,<0.7.1|>=0.8,<0.9", "mdanter/ecc": "<2", "mediaessenz/mail": "<3.12.5|>=4,<4.2", "mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2", @@ -2739,7 +2744,7 @@ "phpoffice/phpexcel": "<=1.8.2", "phpoffice/phpspreadsheet": "<=1.30.6|>=2,<=2.1.18|>=2.2,<=2.4.7|>=3,<=3.10.7|>=4,<=5.9", "phppgadmin/phppgadmin": "<=7.13", - "phpseclib/phpseclib": "<3.0.57|>=4,<4.0.1", + "phpseclib/phpseclib": "<3.0.58|>=4,<4.0.3", "phpservermon/phpservermon": "<3.6", "phpsysinfo/phpsysinfo": "<=3.4.5", "phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6|>=13.4,<13.4.4", @@ -3216,8 +3221,8 @@ "type": "tidelift" } ], - "time": "2026-11-10T03:11:40+00:00", - "published-time": "2026-11-10T03:11:44+00:00" + "time": "2026-11-12T14:42:16+00:00", + "published-time": "2026-11-12T14:42:22+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 a9bb42f1..d53a194c 100644 --- a/tests/Fixtures/personas/core/functions.php +++ b/tests/Fixtures/personas/core/functions.php @@ -1,6 +1,6 @@ boot(); + }, + 15 +); /** * Returns the plugin's composition root, built on first use. * + * Only the function is contract: the root's members stay internal unless tagged @api. + * + * @api + * * @since 1.0.0 * @version 1.0.0 * @@ -33,15 +40,3 @@ function acme_pc_plugin(): Plugin { return $plugin; } - -/** - * Boots the plugin. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @return void - */ -function acme_pc_boot(): void { - acme_pc_plugin()->boot(); -} diff --git a/tests/Fixtures/personas/core/includes/footprint.php b/tests/Fixtures/personas/core/includes/footprint.php deleted file mode 100644 index 9b3c0ade..00000000 --- a/tests/Fixtures/personas/core/includes/footprint.php +++ /dev/null @@ -1,32 +0,0 @@ -, 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 deleted file mode 100644 index f70b891b..00000000 --- a/tests/Fixtures/personas/core/includes/migrations.php +++ /dev/null @@ -1,41 +0,0 @@ - - */ -function acme_pc_get_migration_steps(): array { - return array( - 'acme_pc_add_settings', - ); -} - -/** - * Stores the plugin's settings with their defaults. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @throws RuntimeException Thrown when the settings could not be stored. - * - * @return void - */ -function acme_pc_add_settings(): void { - 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 deleted file mode 100644 index 3bb77a31..00000000 --- a/tests/Fixtures/personas/core/includes/options.php +++ /dev/null @@ -1,23 +0,0 @@ - + */ + public static function get_steps(): array { + return array( + self::add_settings( ... ), + ); + } + + // endregion + + // region HELPERS + + /** + * Stores the plugin's settings with their defaults. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @throws \RuntimeException Thrown when the settings could not be stored. + * + * @return void + */ + protected static function add_settings(): void { + if ( false === get_option( Options::SETTINGS ) && ! add_option( Options::SETTINGS, Options::DEFAULTS ) ) { + throw new \RuntimeException( 'The settings could not be stored.' ); + } + } + + // endregion +} diff --git a/tests/Fixtures/personas/core/src/Options.php b/tests/Fixtures/personas/core/src/Options.php new file mode 100644 index 00000000..48da6c84 --- /dev/null +++ b/tests/Fixtures/personas/core/src/Options.php @@ -0,0 +1,67 @@ + 'Hello' ); + + // endregion + + // region MAGIC METHODS + + /** + * Constructor. + * + * @since 1.0.0 + * @version 1.0.0 + */ + protected function __construct() {} + + // endregion + + // region METHODS + + /** + * Returns the stored greeting, or the default one where none is stored. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @return string + */ + public static function get_greeting(): string { + $settings = get_option( self::SETTINGS, self::DEFAULTS ); + + return \is_array( $settings ) && \is_string( $settings['greeting'] ?? null ) ? $settings['greeting'] : self::DEFAULTS['greeting']; + } + + // endregion +} diff --git a/tests/Fixtures/personas/core/src/Plugin.php b/tests/Fixtures/personas/core/src/Plugin.php index a2cf80ea..ad671820 100644 --- a/tests/Fixtures/personas/core/src/Plugin.php +++ b/tests/Fixtures/personas/core/src/Plugin.php @@ -6,6 +6,7 @@ use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger; use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner; use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\PluginBoot; +use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity; 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; @@ -24,7 +25,7 @@ final class Plugin { // region FIELDS AND CONSTANTS /** - * The plugin's slug, which is also its text domain and its log channel. + * The plugin's slug, which is also its text domain, its log channel and, in snake case, the root of its hook names. * * @since 1.0.0 * @version 1.0.0 @@ -34,7 +35,7 @@ final class Plugin { public const string SLUG = 'acme-persona-core'; /** - * The prefix of the plugin's hook names, options and scheduled hooks. + * The prefix of the plugin's options and scheduled actions. * * @since 1.0.0 * @version 1.0.0 @@ -57,6 +58,23 @@ final class Plugin { Greeting::class, ); + /** + * The plugin's stored data by kind, which covers every option, user meta key and scheduled job under the plugin's prefix. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var array{option_prefixes: list, user_meta_site_prefixes: list, user_meta_global_prefixes: list, network_options: list, cron_hooks: list, as_groups: list} + */ + public const array FOOTPRINT = array( + 'option_prefixes' => array( self::PREFIX . '_' ), + 'user_meta_site_prefixes' => array( self::PREFIX . '_' ), + 'user_meta_global_prefixes' => array( self::PREFIX . '_' ), + 'network_options' => array( self::PREFIX . '_network_notices' ), + 'cron_hooks' => array( 'acme_persona_core/migration/run' ), + 'as_groups' => array( self::PREFIX ), + ); + /** * The boot boundary, whose status tells whether the plugin booted. * @@ -107,6 +125,16 @@ final class Plugin { */ protected readonly PsrLoggerInterface $logger; + /** + * The plugin's identity, which every framework class the root builds takes. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var PluginIdentity + */ + protected readonly PluginIdentity $identity; + // endregion // region MAGIC METHODS @@ -118,9 +146,10 @@ final class Plugin { * @version 1.0.0 */ public function __construct() { - $this->logger = new ErrorLogLogger( self::SLUG ); - $this->scheduler = new Scheduler( self::PREFIX ); - $this->boundary = new PluginBoot( ACME_PC_FILE, self::PREFIX, $this->logger ); + $this->identity = new PluginIdentity( ACME_PC_FILE, self::SLUG, self::PREFIX ); + $this->logger = new ErrorLogLogger( $this->identity ); + $this->scheduler = new Scheduler( $this->identity ); + $this->boundary = new PluginBoot( $this->identity, $this->logger ); } // endregion @@ -152,10 +181,10 @@ public function boot(): void { * @return bool */ protected function compose(): bool { - $this->migrations = new MigrationRunner( ACME_PC_FILE, self::PREFIX . '_revision', acme_pc_get_migration_steps(), $this->logger, $this->scheduler ); + $this->migrations = new MigrationRunner( $this->identity, Migrations::get_steps(), $this->logger, $this->scheduler ); $this->migrations->register_hooks(); - $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() ); + $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->identity, $this->logger, $this->scheduler ) )->build() ); $this->kernel->boot( self::COMPONENTS ); return true; @@ -167,17 +196,19 @@ protected function compose(): bool { * @since 1.0.0 * @version 1.0.0 * + * @param PluginIdentity $identity The plugin's identity. * @param PsrLoggerInterface $logger The plugin's logger. * @param Scheduler $scheduler The plugin's scheduler. * * @return array */ - protected function get_definitions( PsrLoggerInterface $logger, Scheduler $scheduler ): array { + protected function get_definitions( PluginIdentity $identity, PsrLoggerInterface $logger, Scheduler $scheduler ): array { return array( + PluginIdentity::class => static fn (): PluginIdentity => $identity, 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 ), + AdminNotices::class => static fn (): AdminNotices => new AdminNotices( $identity, $logger ), ); } diff --git a/tests/Fixtures/personas/core/uninstall.php b/tests/Fixtures/personas/core/uninstall.php index 6dd4e7ef..a16410f0 100644 --- a/tests/Fixtures/personas/core/uninstall.php +++ b/tests/Fixtures/personas/core/uninstall.php @@ -8,7 +8,10 @@ * @package Acme\PersonaCore */ +use Acme\PersonaCore\Options; +use Acme\PersonaCore\Plugin; use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Bootstrap\Requirements; +use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity; use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Uninstall\Uninstaller; defined( 'WP_UNINSTALL_PLUGIN' ) || exit; @@ -26,13 +29,13 @@ } require_once __DIR__ . '/vendor/autoload.php'; - require_once __DIR__ . '/includes/footprint.php'; - require_once __DIR__ . '/includes/options.php'; - $acme_pc_footprint = acme_pc_get_footprint(); - $acme_pc_consent = ACME_PC_OPTION_SETTINGS; + $acme_pc_footprint = Plugin::FOOTPRINT; + $acme_pc_consent = Options::SETTINGS; if ( ! class_exists( Uninstaller::class ) ) { throw new RuntimeException( 'its uninstall engine is missing.' ); } + + $acme_pc_identity = new PluginIdentity( $acme_pc_file, Plugin::SLUG, Plugin::PREFIX ); } 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 ) { @@ -44,7 +47,7 @@ } Uninstaller::run( - $acme_pc_file, + $acme_pc_identity, $acme_pc_footprint, array( 'consent' => array( diff --git a/tests/Fixtures/personas/settings/acme-persona-settings.php b/tests/Fixtures/personas/settings/acme-persona-settings.php index 6c013e29..8924ca4d 100644 --- a/tests/Fixtures/personas/settings/acme-persona-settings.php +++ b/tests/Fixtures/personas/settings/acme-persona-settings.php @@ -22,6 +22,8 @@ defined( 'ABSPATH' ) || exit; +define( 'ACME_PS_FILE', __FILE__ ); + $acme_ps_bootstrap = __DIR__ . '/vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php'; if ( ! is_readable( $acme_ps_bootstrap ) ) { // The floor gate lives in the bootstrap package, so nothing built may load without it. return; diff --git a/tests/Fixtures/personas/settings/composer.lock b/tests/Fixtures/personas/settings/composer.lock index 1dc2a359..489f4ecd 100644 --- a/tests/Fixtures/personas/settings/composer.lock +++ b/tests/Fixtures/personas/settings/composer.lock @@ -771,23 +771,23 @@ }, { "name": "jetbrains/phpstorm-stubs", - "version": "v2026.2", + "version": "v2026.3", "source": { "type": "git", - "url": "https://github.com/JetBrains/phpstorm-stubs", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9" + "url": "https://github.com/JetBrains/phpstorm-stubs.git", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/709e512210784a7c0a677b3a89d35def844a59b9", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9", + "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/912ae721089fc48534d3a5795997ee8a2802b8cd", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd", "shasum": "" }, "require-dev": { "friendsofphp/php-cs-fixer": "^v3.86", "nikic/php-parser": "^v5.6", - "phpdocumentor/reflection-docblock": "^5.6", - "phpunit/phpunit": "^12.3" + "phpdocumentor/reflection-docblock": "^6.0", + "phpunit/phpunit": "^13.2" }, "type": "library", "autoload": { @@ -811,8 +811,11 @@ "stubs", "type" ], - "time": "2026-06-12T13:19:10+00:00", - "published-time": "2026-07-22T11:41:13+00:00" + "support": { + "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2026.3" + }, + "time": "2026-10-19T10:54:17+00:00", + "published-time": "2026-11-11T13:34:29+00:00" }, { "name": "johnbillion/wp-compat", @@ -1891,12 +1894,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4" + "reference": "9fb1049dda127f1732cac541e68f8e490db77825" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4", - "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", + "reference": "9fb1049dda127f1732cac541e68f8e490db77825", "shasum": "" }, "conflict": { @@ -1978,6 +1981,7 @@ "barzahlen/barzahlen-php": "<2.0.1", "baserproject/basercms": "<=5.2.2", "bassjobsen/bootstrap-3-typeahead": ">4.0.2", + "bazaarvoice/bazaarvoice-magento2-ext": ">=8,<9.1.11", "bbpress/bbpress": "<2.6.5", "bcit-ci/codeigniter": "<3.1.3", "bcosca/fatfree": "<3.7.2", @@ -2049,9 +2053,9 @@ "contao-components/colorbox": ">=1,<1.6.4.3-dev", "contao-components/mediaelement": ">=2.14.2,<2.21.1", "contao/comments-bundle": ">=2,<5.3.50|>=5.4,<5.7.12", - "contao/contao": ">=3,<3.5.37|>=4,<5.3.50|>=5.4,<5.7.12", + "contao/contao": ">=3,<3.5.37|>=4,<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6", "contao/core": "<3.5.39", - "contao/core-bundle": "<5.3.50|>=5.4,<5.7.12", + "contao/core-bundle": "<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6", "contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8", "contao/managed-edition": "<=1.5", "contao/newsletter-bundle": ">=5,<5.3.50|>=5.4,<5.7.12", @@ -2261,6 +2265,7 @@ "gp247/core": "<1.1.24", "gree/jose": "<2.2.1", "gregwar/rst": "<1.0.3", + "grpc/grpc": "<1.84.1", "grumpydictator/firefly-iii": "<=6.6.2", "gugoan/economizzer": "<=0.9.0.0-beta1", "guzzlehttp/guzzle": "<7.15.2|>=8,<8.0.1", @@ -2431,7 +2436,7 @@ "mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7", "maximebf/debugbar": "<1.19", "mckenziearts/livewire-markdown-editor": "<1.3", - "mcp/sdk": ">=0.5,<0.7.1", + "mcp/sdk": ">=0.5,<0.7.1|>=0.8,<0.9", "mdanter/ecc": "<2", "mediaessenz/mail": "<3.12.5|>=4,<4.2", "mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2", @@ -2582,7 +2587,7 @@ "phpoffice/phpexcel": "<=1.8.2", "phpoffice/phpspreadsheet": "<=1.30.6|>=2,<=2.1.18|>=2.2,<=2.4.7|>=3,<=3.10.7|>=4,<=5.9", "phppgadmin/phppgadmin": "<=7.13", - "phpseclib/phpseclib": "<3.0.57|>=4,<4.0.1", + "phpseclib/phpseclib": "<3.0.58|>=4,<4.0.3", "phpservermon/phpservermon": "<3.6", "phpsysinfo/phpsysinfo": "<=3.4.5", "phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6|>=13.4,<13.4.4", @@ -3059,8 +3064,8 @@ "type": "tidelift" } ], - "time": "2026-11-10T03:11:40+00:00", - "published-time": "2026-11-10T03:11:44+00:00" + "time": "2026-11-12T14:42:16+00:00", + "published-time": "2026-11-12T14:42:22+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 ee7ff9e9..a9160531 100644 --- a/tests/Fixtures/personas/settings/functions.php +++ b/tests/Fixtures/personas/settings/functions.php @@ -12,26 +12,22 @@ 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\Core\PluginIdentity; 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_hooks', 15 ); +add_action( + 'plugins_loaded', + static function (): void { + $identity = new PluginIdentity( ACME_PS_FILE, Settings::SLUG, Settings::PREFIX ); + $notices = new AdminNotices( $identity, new ErrorLogLogger( $identity ) ); + $notices->register_hooks(); -/** - * Registers the hooks of the plugin's settings page, of its post form and of the notices that report the form's rejections. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @return 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(); -} + new OptionsPage( $identity, Settings::PREFIX, new Settings() )->register_hooks(); + new ObjectForm( $identity, Settings::PREFIX . '_details', new Details(), new PostMetaBox( array( 'post' ) ), $notices )->register_hooks(); + }, + 15 +); diff --git a/tests/Fixtures/personas/settings/src/Settings.php b/tests/Fixtures/personas/settings/src/Settings.php index 0c47af58..8d605c29 100644 --- a/tests/Fixtures/personas/settings/src/Settings.php +++ b/tests/Fixtures/personas/settings/src/Settings.php @@ -15,6 +15,16 @@ final class Settings implements OptionsPageDefinitionInterface { // region FIELDS AND CONSTANTS + /** + * The plugin's slug, which is also its text domain and, in snake case, the root of its hook names. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var string + */ + public const string SLUG = 'acme-persona-settings'; + /** * The prefix of the plugin's admin page and option. * diff --git a/tests/Fixtures/personas/shared/composer.lock b/tests/Fixtures/personas/shared/composer.lock index c4cf4c5a..766148d9 100644 --- a/tests/Fixtures/personas/shared/composer.lock +++ b/tests/Fixtures/personas/shared/composer.lock @@ -667,23 +667,23 @@ }, { "name": "jetbrains/phpstorm-stubs", - "version": "v2026.2", + "version": "v2026.3", "source": { "type": "git", - "url": "https://github.com/JetBrains/phpstorm-stubs", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9" + "url": "https://github.com/JetBrains/phpstorm-stubs.git", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/709e512210784a7c0a677b3a89d35def844a59b9", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9", + "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/912ae721089fc48534d3a5795997ee8a2802b8cd", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd", "shasum": "" }, "require-dev": { "friendsofphp/php-cs-fixer": "^v3.86", "nikic/php-parser": "^v5.6", - "phpdocumentor/reflection-docblock": "^5.6", - "phpunit/phpunit": "^12.3" + "phpdocumentor/reflection-docblock": "^6.0", + "phpunit/phpunit": "^13.2" }, "type": "library", "autoload": { @@ -707,8 +707,11 @@ "stubs", "type" ], - "time": "2026-06-12T13:19:10+00:00", - "published-time": "2026-07-22T11:41:13+00:00" + "support": { + "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2026.3" + }, + "time": "2026-10-19T10:54:17+00:00", + "published-time": "2026-11-11T13:34:29+00:00" }, { "name": "johnbillion/wp-compat", @@ -1686,12 +1689,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4" + "reference": "9fb1049dda127f1732cac541e68f8e490db77825" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4", - "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", + "reference": "9fb1049dda127f1732cac541e68f8e490db77825", "shasum": "" }, "conflict": { @@ -1773,6 +1776,7 @@ "barzahlen/barzahlen-php": "<2.0.1", "baserproject/basercms": "<=5.2.2", "bassjobsen/bootstrap-3-typeahead": ">4.0.2", + "bazaarvoice/bazaarvoice-magento2-ext": ">=8,<9.1.11", "bbpress/bbpress": "<2.6.5", "bcit-ci/codeigniter": "<3.1.3", "bcosca/fatfree": "<3.7.2", @@ -1844,9 +1848,9 @@ "contao-components/colorbox": ">=1,<1.6.4.3-dev", "contao-components/mediaelement": ">=2.14.2,<2.21.1", "contao/comments-bundle": ">=2,<5.3.50|>=5.4,<5.7.12", - "contao/contao": ">=3,<3.5.37|>=4,<5.3.50|>=5.4,<5.7.12", + "contao/contao": ">=3,<3.5.37|>=4,<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6", "contao/core": "<3.5.39", - "contao/core-bundle": "<5.3.50|>=5.4,<5.7.12", + "contao/core-bundle": "<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6", "contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8", "contao/managed-edition": "<=1.5", "contao/newsletter-bundle": ">=5,<5.3.50|>=5.4,<5.7.12", @@ -2056,6 +2060,7 @@ "gp247/core": "<1.1.24", "gree/jose": "<2.2.1", "gregwar/rst": "<1.0.3", + "grpc/grpc": "<1.84.1", "grumpydictator/firefly-iii": "<=6.6.2", "gugoan/economizzer": "<=0.9.0.0-beta1", "guzzlehttp/guzzle": "<7.15.2|>=8,<8.0.1", @@ -2226,7 +2231,7 @@ "mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7", "maximebf/debugbar": "<1.19", "mckenziearts/livewire-markdown-editor": "<1.3", - "mcp/sdk": ">=0.5,<0.7.1", + "mcp/sdk": ">=0.5,<0.7.1|>=0.8,<0.9", "mdanter/ecc": "<2", "mediaessenz/mail": "<3.12.5|>=4,<4.2", "mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2", @@ -2377,7 +2382,7 @@ "phpoffice/phpexcel": "<=1.8.2", "phpoffice/phpspreadsheet": "<=1.30.6|>=2,<=2.1.18|>=2.2,<=2.4.7|>=3,<=3.10.7|>=4,<=5.9", "phppgadmin/phppgadmin": "<=7.13", - "phpseclib/phpseclib": "<3.0.57|>=4,<4.0.1", + "phpseclib/phpseclib": "<3.0.58|>=4,<4.0.3", "phpservermon/phpservermon": "<3.6", "phpsysinfo/phpsysinfo": "<=3.4.5", "phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6|>=13.4,<13.4.4", @@ -2854,8 +2859,8 @@ "type": "tidelift" } ], - "time": "2026-11-10T03:11:40+00:00", - "published-time": "2026-11-10T03:11:44+00:00" + "time": "2026-11-12T14:42:16+00:00", + "published-time": "2026-11-12T14:42:22+00:00" }, { "name": "sniccowp/php-scoper-wordpress-excludes", diff --git a/tests/Fixtures/personas/woocommerce/acme-persona-woocommerce.php b/tests/Fixtures/personas/woocommerce/acme-persona-woocommerce.php index d0278ec8..b11e5027 100644 --- a/tests/Fixtures/personas/woocommerce/acme-persona-woocommerce.php +++ b/tests/Fixtures/personas/woocommerce/acme-persona-woocommerce.php @@ -8,8 +8,8 @@ * Requires at least: 7.1 * Requires PHP: 8.5 * Requires Plugins: woocommerce - * WC requires at least: 11.2 - * WC tested up to: 11.2 + * WC requires at least: 11.3 + * WC tested up to: 11.3 * Author: Acme * License: GPL-3.0-or-later * License URI: https://www.gnu.org/licenses/gpl-3.0.html diff --git a/tests/Fixtures/personas/woocommerce/composer.json b/tests/Fixtures/personas/woocommerce/composer.json index b87afdf9..3de724b7 100644 --- a/tests/Fixtures/personas/woocommerce/composer.json +++ b/tests/Fixtures/personas/woocommerce/composer.json @@ -43,7 +43,7 @@ "ahegyes/wordpress-configs": "dev-trunk", "ahegyes/wp-framework-woocommerce": "^2.0", "humbug/php-scoper": "^0.18", - "php-stubs/woocommerce-stubs": "^11.2" + "php-stubs/woocommerce-stubs": "^11.3" }, "minimum-stability": "dev", diff --git a/tests/Fixtures/personas/woocommerce/composer.lock b/tests/Fixtures/personas/woocommerce/composer.lock index 357f4bfb..b8818bf0 100644 --- a/tests/Fixtures/personas/woocommerce/composer.lock +++ b/tests/Fixtures/personas/woocommerce/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "54ead8d182aa937ebeadd23c9de90674", + "content-hash": "2798ab16164aa0370f362a1cc1d7a045", "packages": [], "packages-dev": [ { @@ -290,7 +290,7 @@ "dist": { "type": "path", "url": "../../../../packages/woocommerce", - "reference": "79d6aea22490f658088fcea374c11a136074961f" + "reference": "7aa4faa5c982e5ba2e2ed72bdf6255e8a6cf79e9" }, "require": { "ahegyes/wp-framework-settings": "^2.0", @@ -302,6 +302,7 @@ "dev-trunk": "2.x-dev" }, "requires-wp": "7.1", + "requires-wc": "11.3", "requires-plugins": [ "woocommerce" ], @@ -812,23 +813,23 @@ }, { "name": "jetbrains/phpstorm-stubs", - "version": "v2026.2", + "version": "v2026.3", "source": { "type": "git", - "url": "https://github.com/JetBrains/phpstorm-stubs", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9" + "url": "https://github.com/JetBrains/phpstorm-stubs.git", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/709e512210784a7c0a677b3a89d35def844a59b9", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9", + "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/912ae721089fc48534d3a5795997ee8a2802b8cd", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd", "shasum": "" }, "require-dev": { "friendsofphp/php-cs-fixer": "^v3.86", "nikic/php-parser": "^v5.6", - "phpdocumentor/reflection-docblock": "^5.6", - "phpunit/phpunit": "^12.3" + "phpdocumentor/reflection-docblock": "^6.0", + "phpunit/phpunit": "^13.2" }, "type": "library", "autoload": { @@ -852,8 +853,11 @@ "stubs", "type" ], - "time": "2026-06-12T13:19:10+00:00", - "published-time": "2026-07-22T11:41:13+00:00" + "support": { + "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2026.3" + }, + "time": "2026-10-19T10:54:17+00:00", + "published-time": "2026-11-11T13:34:29+00:00" }, { "name": "johnbillion/wp-compat", @@ -991,16 +995,16 @@ }, { "name": "php-stubs/woocommerce-stubs", - "version": "v11.2.2", + "version": "v11.3.0", "source": { "type": "git", "url": "https://github.com/php-stubs/woocommerce-stubs.git", - "reference": "d5c232c183c09689d75c177b01bef60e6dd53639" + "reference": "a897f9ad0552482e9d50d3937daab20dfb97265c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/d5c232c183c09689d75c177b01bef60e6dd53639", - "reference": "d5c232c183c09689d75c177b01bef60e6dd53639", + "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/a897f9ad0552482e9d50d3937daab20dfb97265c", + "reference": "a897f9ad0552482e9d50d3937daab20dfb97265c", "shasum": "" }, "require": { @@ -1029,10 +1033,10 @@ ], "support": { "issues": "https://github.com/php-stubs/woocommerce-stubs/issues", - "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.2.2" + "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.3.0" }, - "time": "2026-11-04T21:47:17+00:00", - "published-time": "2026-11-04T21:48:23+00:00" + "time": "2026-11-12T00:45:07+00:00", + "published-time": "2026-11-12T00:47:40+00:00" }, { "name": "php-stubs/wordpress-stubs", @@ -1977,12 +1981,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4" + "reference": "9fb1049dda127f1732cac541e68f8e490db77825" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4", - "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/9fb1049dda127f1732cac541e68f8e490db77825", + "reference": "9fb1049dda127f1732cac541e68f8e490db77825", "shasum": "" }, "conflict": { @@ -2064,6 +2068,7 @@ "barzahlen/barzahlen-php": "<2.0.1", "baserproject/basercms": "<=5.2.2", "bassjobsen/bootstrap-3-typeahead": ">4.0.2", + "bazaarvoice/bazaarvoice-magento2-ext": ">=8,<9.1.11", "bbpress/bbpress": "<2.6.5", "bcit-ci/codeigniter": "<3.1.3", "bcosca/fatfree": "<3.7.2", @@ -2135,9 +2140,9 @@ "contao-components/colorbox": ">=1,<1.6.4.3-dev", "contao-components/mediaelement": ">=2.14.2,<2.21.1", "contao/comments-bundle": ">=2,<5.3.50|>=5.4,<5.7.12", - "contao/contao": ">=3,<3.5.37|>=4,<5.3.50|>=5.4,<5.7.12", + "contao/contao": ">=3,<3.5.37|>=4,<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6", "contao/core": "<3.5.39", - "contao/core-bundle": "<5.3.50|>=5.4,<5.7.12", + "contao/core-bundle": "<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6", "contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8", "contao/managed-edition": "<=1.5", "contao/newsletter-bundle": ">=5,<5.3.50|>=5.4,<5.7.12", @@ -2347,6 +2352,7 @@ "gp247/core": "<1.1.24", "gree/jose": "<2.2.1", "gregwar/rst": "<1.0.3", + "grpc/grpc": "<1.84.1", "grumpydictator/firefly-iii": "<=6.6.2", "gugoan/economizzer": "<=0.9.0.0-beta1", "guzzlehttp/guzzle": "<7.15.2|>=8,<8.0.1", @@ -2517,7 +2523,7 @@ "mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7", "maximebf/debugbar": "<1.19", "mckenziearts/livewire-markdown-editor": "<1.3", - "mcp/sdk": ">=0.5,<0.7.1", + "mcp/sdk": ">=0.5,<0.7.1|>=0.8,<0.9", "mdanter/ecc": "<2", "mediaessenz/mail": "<3.12.5|>=4,<4.2", "mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2", @@ -2668,7 +2674,7 @@ "phpoffice/phpexcel": "<=1.8.2", "phpoffice/phpspreadsheet": "<=1.30.6|>=2,<=2.1.18|>=2.2,<=2.4.7|>=3,<=3.10.7|>=4,<=5.9", "phppgadmin/phppgadmin": "<=7.13", - "phpseclib/phpseclib": "<3.0.57|>=4,<4.0.1", + "phpseclib/phpseclib": "<3.0.58|>=4,<4.0.3", "phpservermon/phpservermon": "<3.6", "phpsysinfo/phpsysinfo": "<=3.4.5", "phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6|>=13.4,<13.4.4", @@ -3145,8 +3151,8 @@ "type": "tidelift" } ], - "time": "2026-11-10T03:11:40+00:00", - "published-time": "2026-11-10T03:11:44+00:00" + "time": "2026-11-12T14:42:16+00:00", + "published-time": "2026-11-12T14:42:22+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 bc7c8aa7..f6235260 100644 --- a/tests/Fixtures/personas/woocommerce/functions.php +++ b/tests/Fixtures/personas/woocommerce/functions.php @@ -1,6 +1,6 @@ register_hooks(); - add_filter( 'woocommerce_get_settings_pages', 'acme_pw_add_settings_tab' ); - add_action( 'woocommerce_update_options_' . Packing::PREFIX, 'acme_pw_log_settings_save' ); -} - -/** - * Adds the plugin's settings tab, which WooCommerce's settings page class must exist to construct. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @param array $pages The settings tabs. - * - * @return array - */ -function acme_pw_add_settings_tab( array $pages ): array { - $pages[] = new SettingsTab( - Packing::PREFIX, - array( - 'label' => __( 'Packing', 'acme-persona-woocommerce' ), - 'sections' => array( '' => __( 'General', 'acme-persona-woocommerce' ) ), - ), - new Packing() - ); - - return $pages; -} - -/** - * Writes the save of the plugin's settings tab to WooCommerce's log. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @return void - */ -function acme_pw_log_settings_save(): void { - new WooCommerceLogger( 'acme-persona-woocommerce', 'info' )->info( 'The packing settings were saved.' ); -} +add_action( + 'plugins_loaded', + static function (): void { + $identity = new PluginIdentity( ACME_PW_FILE, Packing::SLUG, Packing::PREFIX ); + if ( ! WooCommerceRequirement::check( $identity ) ) { + return; + } + + new ProductDataTab( $identity, Packing::PREFIX, new Packing() )->register_hooks(); + add_filter( + 'woocommerce_get_settings_pages', + static function ( array $pages ): array { + // WooCommerce's settings page class, which the tab extends, exists only once this filter runs. + $pages[] = new SettingsTab( + Packing::PREFIX, + array( + 'label' => __( 'Packing', 'acme-persona-woocommerce' ), + 'sections' => array( '' => __( 'General', 'acme-persona-woocommerce' ) ), + ), + new Packing() + ); + + return $pages; + } + ); + add_action( + 'woocommerce_update_options_' . Packing::PREFIX, + static function () use ( $identity ): void { + new WooCommerceLogger( $identity, 'info' )->info( 'The packing settings were saved.' ); + } + ); + }, + 15 +); diff --git a/tests/Fixtures/personas/woocommerce/includes/footprint.php b/tests/Fixtures/personas/woocommerce/includes/footprint.php deleted file mode 100644 index c0f985fd..00000000 --- a/tests/Fixtures/personas/woocommerce/includes/footprint.php +++ /dev/null @@ -1,28 +0,0 @@ -, 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 deleted file mode 100644 index f7673463..00000000 --- a/tests/Fixtures/personas/woocommerce/includes/index.php +++ /dev/null @@ -1 +0,0 @@ -, meta: array{post: list}} + */ + public const array FOOTPRINT = array( + 'option_prefixes' => array( self::PREFIX . '_' ), + 'meta' => array( 'post' => array( self::META_BOX ) ), + ); + // endregion // region INHERITED METHODS @@ -46,7 +79,7 @@ public function get_section_fields( string $tab_id, string $section_id ): array ), array( 'type' => 'checkbox', - 'id' => ACME_PW_OPTION_DELETE_DATA, + 'id' => Options::DELETE_DATA, 'title' => __( 'Data on uninstall', 'acme-persona-woocommerce' ), 'desc' => __( 'Delete the packing data when the plugin is uninstalled', 'acme-persona-woocommerce' ), ), @@ -68,7 +101,7 @@ public function get_product_data_tab( string $tab_id, \WC_Product $product ): ar 'fields' => array( array( 'type' => 'text', - 'id' => ACME_PW_META_BOX, + 'id' => self::META_BOX, 'label' => __( 'Box', 'acme-persona-woocommerce' ), ), ), diff --git a/tests/Fixtures/personas/woocommerce/uninstall.php b/tests/Fixtures/personas/woocommerce/uninstall.php index f6f0abff..8ed9a4fd 100644 --- a/tests/Fixtures/personas/woocommerce/uninstall.php +++ b/tests/Fixtures/personas/woocommerce/uninstall.php @@ -8,7 +8,10 @@ * @package Acme\PersonaWooCommerce */ +use Acme\PersonaWooCommerce\Options; +use Acme\PersonaWooCommerce\Packing; use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\Bootstrap\Requirements; +use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity; use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\Core\Uninstall\Uninstaller; use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\WooCommerce\Uninstall\MetaTypes; @@ -27,14 +30,14 @@ } require_once __DIR__ . '/vendor/autoload.php'; - require_once __DIR__ . '/includes/footprint.php'; - require_once __DIR__ . '/includes/options.php'; - $acme_pw_footprint = acme_pw_get_footprint(); - $acme_pw_consent = ACME_PW_OPTION_DELETE_DATA; + $acme_pw_footprint = Packing::FOOTPRINT; + $acme_pw_consent = Options::DELETE_DATA; $acme_pw_meta_types = MetaTypes::DEFINITIONS; if ( ! class_exists( Uninstaller::class ) ) { throw new RuntimeException( 'its uninstall engine is missing.' ); } + + $acme_pw_identity = new PluginIdentity( $acme_pw_file, Packing::SLUG, Packing::PREFIX ); } 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 ) { @@ -46,7 +49,7 @@ } Uninstaller::run( - $acme_pw_file, + $acme_pw_identity, $acme_pw_footprint, array( 'consent' => array( 'option' => $acme_pw_consent ), diff --git a/tests/Integration/Bootstrap/UpdaterTest.php b/tests/Integration/Bootstrap/UpdaterTest.php index b1128faa..ddd71a53 100644 --- a/tests/Integration/Bootstrap/UpdaterTest.php +++ b/tests/Integration/Bootstrap/UpdaterTest.php @@ -16,6 +16,8 @@ final class UpdaterTest extends TestCase { protected string $slug; + protected string $hook_root; + protected array $responses = array(); protected array $requests = array(); @@ -30,7 +32,9 @@ public static function setUpBeforeClass(): void { #[\Override] protected function setUp(): void { - $this->slug = 'acme-updater-' . \bin2hex( \random_bytes( 3 ) ); + $suffix = \bin2hex( \random_bytes( 3 ) ); + $this->slug = "acme-updater-$suffix"; + $this->hook_root = "acme_updater_$suffix"; add_filter( 'pre_http_request', array( $this, 'answer_request' ), 10, 3 ); } @@ -441,7 +445,7 @@ public function test_the_configuration_filter_runs_at_check_time_with_the_defaul Updater::register( $this->write_plugin( $this->slug ) ); $calls = array(); add_filter( - "dws_github_updater/config/$this->slug", + "$this->hook_root/github_updater/config", static function ( array $config, string $plugin_basename ) use ( &$calls ): array { $calls[] = array( $config, $plugin_basename ); @@ -467,7 +471,7 @@ static function ( array $config, string $plugin_basename ) use ( &$calls ): arra #[DataProvider( 'unusable_configurations' )] public function test_a_disabled_or_unusable_configuration_offers_nothing_and_leaves_details_to_wordpress( \Closure $filter ): void { Updater::register( $this->write_plugin( $this->slug ) ); - add_filter( "dws_github_updater/config/$this->slug", $filter ); + add_filter( "$this->hook_root/github_updater/config", $filter ); $this->stage_latest( $this->release( 'v2.0.0' ) ); $updates = $this->check_for_updates(); @@ -481,7 +485,7 @@ public function test_a_disabled_or_unusable_configuration_offers_nothing_and_lea public function test_a_filtered_repository_asset_and_channel_drive_the_request_the_package_and_the_cache(): void { Updater::register( $this->write_plugin( $this->slug ) ); add_filter( - "dws_github_updater/config/$this->slug", + "$this->hook_root/github_updater/config", static fn (): array => array( 'repository' => 'acme/elsewhere', 'asset' => 'bundle.zip', diff --git a/tests/Integration/ConsumerA/ConsumerABuildTest.php b/tests/Integration/ConsumerA/ConsumerABuildTest.php index d482e0e4..7147225c 100644 --- a/tests/Integration/ConsumerA/ConsumerABuildTest.php +++ b/tests/Integration/ConsumerA/ConsumerABuildTest.php @@ -20,7 +20,6 @@ public static function missing_build_files(): array { 'bootstrap-loader' => array( 'vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php', false ), 'autoloader' => array( 'vendor/autoload.php', true ), 'scoped-autoloader' => array( 'vendor-prefixed/scoper-autoload.php', true ), - 'composition-root' => array( 'src/Plugin.php', false ), ); } diff --git a/tests/Integration/ConsumerA/ConsumerAOnNetworkTest.php b/tests/Integration/ConsumerA/ConsumerAOnNetworkTest.php index 24784773..552545e0 100644 --- a/tests/Integration/ConsumerA/ConsumerAOnNetworkTest.php +++ b/tests/Integration/ConsumerA/ConsumerAOnNetworkTest.php @@ -39,6 +39,6 @@ public function test_a_network_activated_plugin_blocked_by_woocommerce_keeps_its self::assertSame( 'blocked', $request['records']['consumer_a']['status'] ); self::assertTrue( $request['records']['schedules_hooked'] ); - self::assertStringContainsString( 'Acme Consumer A benötigt WooCommerce 11.2 oder neuer, daher bleiben seine Funktionen aus, bis WooCommerce aktualisiert ist.', $request['body'] ); + self::assertStringContainsString( 'Acme Consumer A benötigt WooCommerce 11.3 oder neuer, daher bleiben seine Funktionen aus, bis WooCommerce aktualisiert ist.', $request['body'] ); } } diff --git a/tests/Integration/ConsumerA/ConsumerAUninstallTest.php b/tests/Integration/ConsumerA/ConsumerAUninstallTest.php index 9e79fa44..891b95dd 100644 --- a/tests/Integration/ConsumerA/ConsumerAUninstallTest.php +++ b/tests/Integration/ConsumerA/ConsumerAUninstallTest.php @@ -31,7 +31,7 @@ public static function engines_that_cannot_run(): 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() ), + 'options' => array( array( 'src/Options.php' ), array() ), 'below-php-floor' => array( array(), array( 'Requires PHP' => '99' ) ), ); } diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php index 821c4df4..61104a20 100644 --- a/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php +++ b/tests/Integration/Core/AdminNotice/AdminNoticesBlockEditorTest.php @@ -76,7 +76,7 @@ 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->hook_root/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 ); @@ -126,7 +126,7 @@ public function test_the_script_loads_after_its_dependencies_with_the_delivery_p self::assertSame( array( 'prefix' => $this->prefix, - 'path' => \str_replace( '_', '-', $this->prefix ) . '/v1/notices/deliveries', + 'path' => "$this->slug/v1/notices/deliveries", 'waiting' => false, ), $settings @@ -138,7 +138,7 @@ public function test_registered_hooks_load_the_script_on_the_editors_assets_and_ $notices->register_hooks(); 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() ); + self::assertArrayHasKey( "/$this->slug/v1/notices/deliveries", rest_get_server()->get_routes() ); } #[Group( 'multisite' )] @@ -202,11 +202,11 @@ protected function load_editor( int $user_id ): array { } protected function deliver(): \WP_REST_Response { - return rest_do_request( new \WP_REST_Request( 'POST', '/' . \str_replace( '_', '-', $this->prefix ) . '/v1/notices/deliveries' ) ); + return rest_do_request( new \WP_REST_Request( 'POST', "/$this->slug/v1/notices/deliveries" ) ); } protected function notices(): AdminNotices { - return new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ); + return new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ); } protected function script_handle(): string { diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesDismissalTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesDismissalTest.php index 6640ec21..c42077df 100644 --- a/tests/Integration/Core/AdminNotice/AdminNoticesDismissalTest.php +++ b/tests/Integration/Core/AdminNotice/AdminNoticesDismissalTest.php @@ -39,9 +39,9 @@ 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 RecordingLogger(); - $this->notices = new AdminNotices( $this->prefix, $this->logger ); + $this->notices = new AdminNotices( $this->identity, $this->logger ); add_action( - "$this->prefix/admin_notice/dismissed", + "$this->hook_root/admin_notice/dismissed", function ( mixed ...$arguments ): void { $this->dismissed[] = $arguments; }, @@ -271,7 +271,7 @@ 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 ThrowingLogger( new \UnexpectedValueException( 'The log is not writable.' ) ) ); + $notices = new AdminNotices( $this->identity, 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' ) ); @@ -289,7 +289,7 @@ public function test_a_failing_store_whose_logger_throws_answers_500_and_reaches self::assertInstanceOf( \WP_Error::class, $response ); self::assertSame( array( 'status' => 500 ), $response->get_error_data() ); - self::assertStringContainsString( "[$this->prefix.error] Could not dismiss a {scope} notice: {message} {\"scope\":\"site\",\"message\":", $log ); + self::assertStringContainsString( "[$this->slug.error] Could not dismiss a {scope} notice: {message} {\"scope\":\"site\",\"message\":", $log ); self::assertStringContainsString( "\nThe logger failed: UnexpectedValueException: The log is not writable.", $log ); } @@ -384,7 +384,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 = new \WP_REST_Request( 'POST', "/$this->slug/v1/notices/dismissals" ); $request->set_body_params( \array_filter( $params, static fn ( mixed $value ): bool => ! \is_null( $value ) ) ); return rest_do_request( $request ); @@ -404,7 +404,7 @@ protected function serve( array $params, ?string $rest_nonce ): array { $_POST = $params; \ob_start(); - rest_get_server()->serve_request( '/' . \str_replace( '_', '-', $this->prefix ) . '/v1/notices/dismissals' ); + rest_get_server()->serve_request( "/$this->slug/v1/notices/dismissals" ); return \json_decode( (string) \ob_get_clean(), true ); } diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php index 174dad93..3402ddcd 100644 --- a/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php +++ b/tests/Integration/Core/AdminNotice/AdminNoticesStorageTest.php @@ -27,15 +27,10 @@ final class AdminNoticesStorageTest extends TestCase { #[\Override] protected function setUp(): void { $this->user_id = $this->make_user( 'acme_notices_admin', 'administrator' ); - $this->notices = new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ); + $this->notices = new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ); wp_set_current_user( $this->user_id ); } - #[\Override] - protected function tearDown(): void { - wp_set_current_user( 0 ); - } - public function test_each_scope_keeps_its_notices_as_one_array_by_id_in_its_home(): void { global $wpdb; $user = array( $this->notice( 'one' ), $this->notice( 'two' ) ); diff --git a/tests/Integration/Core/AdminNotice/AdminNoticesTest.php b/tests/Integration/Core/AdminNotice/AdminNoticesTest.php index 0356e676..813b9ba8 100644 --- a/tests/Integration/Core/AdminNotice/AdminNoticesTest.php +++ b/tests/Integration/Core/AdminNotice/AdminNoticesTest.php @@ -5,6 +5,7 @@ use DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; use DeepWebSolutions\Framework\Core\AdminNotice\NoticeScope; use DeepWebSolutions\Framework\Core\AdminNotice\NoticeType; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; use DeepWebSolutions\Framework\Shared\Exception\LogicException; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesSitesTrait; @@ -43,31 +44,27 @@ protected function setUp(): void { $this->logger = new RecordingLogger(); } - public static function invalid_prefixes(): array { + public static function reserved_prefixes(): array { return array( - 'empty' => array( '' ), - 'uppercase' => array( 'Acme' ), - 'leading-digit' => array( '1acme' ), - 'hyphen' => array( 'ac-me' ), - 'trailing-newline' => array( "acme\n" ), - '33-characters' => array( 'a' . \str_repeat( 'b', 32 ) ), - 'hidden-segment' => array( 'acme_hidden' ), - 'network-segment' => array( 'acme_network_tools' ), - 'leading-hidden' => array( 'hidden_acme' ), - 'only-network' => array( 'network' ), + 'hidden-segment' => array( 'acme_hidden' ), + 'network-segment' => array( 'acme_network_tools' ), + 'leading-hidden' => array( 'hidden_acme' ), + 'only-network' => array( 'network' ), ); } - #[DataProvider( 'invalid_prefixes' )] - public function test_a_prefix_outside_the_pattern_or_with_a_reserved_segment_is_rejected( string $prefix ): void { + #[DataProvider( 'reserved_prefixes' )] + public function test_a_prefix_with_a_reserved_segment_is_rejected( string $prefix ): void { + $identity = new PluginIdentity( $this->identity->file, $this->slug, $prefix ); + $this->expectException( InvalidArgumentException::class ); - new AdminNotices( $prefix, $this->logger ); + new AdminNotices( $identity, $this->logger ); } - public function test_a_prefix_may_hold_the_reserved_words_inside_longer_segments_and_reach_32_characters(): void { - foreach ( array( 'acme_notice', 'acme_notices', 'acmenetwork_x', 'acme_hiddenx', 'a' . \str_repeat( 'b', 31 ) ) as $prefix ) { - $notices = new AdminNotices( $prefix, $this->logger ); + public function test_a_prefix_may_hold_the_reserved_words_inside_longer_segments(): void { + foreach ( array( 'acme_notice', 'acme_notices', 'acmenetwork_x', 'acme_hiddenx' ) as $prefix ) { + $notices = new AdminNotices( new PluginIdentity( $this->identity->file, $this->slug, $prefix ), $this->logger ); self::assertSame( array(), $notices->get_notices( NoticeScope::Request ) ); } } @@ -285,7 +282,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->hook_root/admin_notice/should_show", static function ( mixed $visible, string $id, string $scope ) use ( &$calls ): mixed { $calls[] = array( $visible, $id, $scope ); return match ( $id ) { @@ -310,7 +307,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->hook_root/admin_notice/message", static function ( string $message, string $id, string $scope ) use ( &$calls ): ?string { $calls[] = array( $message, $id, $scope ); return 'kept' === $id ? null : 'Translated.'; @@ -371,7 +368,7 @@ public function test_the_dismiss_script_loads_on_wp_api_fetch_only_after_a_persi self::assertSame( array( 'prefix' => $this->prefix, - 'path' => \str_replace( '_', '-', $this->prefix ) . '/v1/notices/dismissals', + 'path' => "$this->slug/v1/notices/dismissals", ), \json_decode( $matches[1], true ) ); @@ -464,7 +461,7 @@ public function test_a_network_notice_shows_on_every_site_and_in_the_network_adm } protected function notices(): AdminNotices { - return new AdminNotices( $this->prefix, $this->logger ); + return new AdminNotices( $this->identity, $this->logger ); } // A fresh instance stands for the next request. diff --git a/tests/Integration/Core/Asset/AssetsTest.php b/tests/Integration/Core/Asset/AssetsTest.php new file mode 100644 index 00000000..078332a4 --- /dev/null +++ b/tests/Integration/Core/Asset/AssetsTest.php @@ -0,0 +1,87 @@ +register_script( self::SCRIPT, 'assets/build/editor.js', array( 'in_footer' => true ) ) ); + + $script = wp_scripts()->registered[ self::SCRIPT ]; + self::assertSame( plugin_dir_url( self::PLUGIN_FILE ) . 'assets/build/editor.js', $script->src ); + self::assertSame( array( 'wp-blocks', 'wp-i18n' ), $script->deps ); + self::assertSame( 'a1b2c3', $script->ver ); + self::assertSame( 1, $script->extra['group'] ?? null ); + self::assertSame( 'acme-plugin', $script->textdomain ); + self::assertSame( plugin_dir_path( self::PLUGIN_FILE ) . 'languages', $script->translations_path ); + } + + public function test_a_style_registers_with_its_build_s_version_and_a_right_to_left_variant(): void { + self::assertTrue( new Assets( new PluginIdentity( self::PLUGIN_FILE, 'acme-plugin', 'acme' ) )->register_style( self::STYLE, 'assets/build/editor.css' ) ); + + $style = wp_styles()->registered[ self::STYLE ]; + self::assertSame( plugin_dir_url( self::PLUGIN_FILE ) . 'assets/build/editor.css', $style->src ); + self::assertSame( array(), $style->deps ); + self::assertSame( 'a1b2c3', $style->ver ); + self::assertSame( 'replace', $style->extra['rtl'] ?? null ); + } + + public function test_a_style_without_a_right_to_left_file_keeps_its_own_file_on_right_to_left_sites(): void { + self::assertTrue( new Assets( new PluginIdentity( self::PLUGIN_FILE, 'acme-plugin', 'acme' ) )->register_style( self::STYLE, 'assets/build/vendor.css' ) ); + + self::assertArrayNotHasKey( 'rtl', wp_styles()->registered[ self::STYLE ]->extra ); + } + + public function test_the_style_chunk_of_an_entry_takes_the_entry_s_version(): void { + self::assertTrue( new Assets( new PluginIdentity( self::PLUGIN_FILE, 'acme-plugin', 'acme' ) )->register_style( self::STYLE, 'assets/build/style-editor.css' ) ); + + self::assertSame( 'a1b2c3', wp_styles()->registered[ self::STYLE ]->ver ); + } + + public function test_a_stylesheet_without_an_asset_file_is_versioned_by_its_modification_time(): void { + self::assertTrue( new Assets( new PluginIdentity( self::PLUGIN_FILE, 'acme-plugin', 'acme' ) )->register_style( self::STYLE, 'assets/build/vendor.css' ) ); + + self::assertSame( (string) \filemtime( plugin_dir_path( self::PLUGIN_FILE ) . 'assets/build/vendor.css' ), wp_styles()->registered[ self::STYLE ]->ver ); + } + + public function test_a_script_named_like_a_style_chunk_takes_no_entry_s_asset_file(): void { + self::assertTrue( new Assets( new PluginIdentity( self::PLUGIN_FILE, 'acme-plugin', 'acme' ) )->register_script( self::SCRIPT, 'assets/build/style-editor.js' ) ); + + $script = wp_scripts()->registered[ self::SCRIPT ]; + self::assertSame( array( 'wp-i18n' ), $script->deps ); + self::assertSame( (string) \filemtime( plugin_dir_path( self::PLUGIN_FILE ) . 'assets/build/style-editor.js' ), $script->ver ); + } + + public function test_a_script_built_without_an_asset_file_registers_without_dependencies_at_its_modification_time(): void { + self::assertTrue( new Assets( new PluginIdentity( self::PLUGIN_FILE, 'acme-plugin', 'acme' ) )->register_script( self::SCRIPT, 'assets/build/vendor.js' ) ); + + $script = wp_scripts()->registered[ self::SCRIPT ]; + self::assertSame( array( 'wp-i18n' ), $script->deps ); + self::assertSame( (string) \filemtime( plugin_dir_path( self::PLUGIN_FILE ) . 'assets/build/vendor.js' ), $script->ver ); + } + + public function test_a_handle_registered_elsewhere_keeps_its_script_and_gains_no_translations(): void { + wp_register_script( self::SCRIPT, 'https://example.com/other.js', array(), '1', true ); + + self::assertFalse( new Assets( new PluginIdentity( self::PLUGIN_FILE, 'acme-plugin', 'acme' ) )->register_script( self::SCRIPT, 'assets/build/editor.js' ) ); + self::assertSame( 'https://example.com/other.js', wp_scripts()->registered[ self::SCRIPT ]->src ); + self::assertNull( wp_scripts()->registered[ self::SCRIPT ]->textdomain ); + } +} diff --git a/tests/Integration/Core/Caching/CachesTest.php b/tests/Integration/Core/Caching/CachesTest.php index 1ea96f8c..5633a5d8 100644 --- a/tests/Integration/Core/Caching/CachesTest.php +++ b/tests/Integration/Core/Caching/CachesTest.php @@ -489,7 +489,7 @@ protected static function cross( array $rows ): array { } protected function cache( string $kind, string $name = 'cache' ): PsrCacheInterface { - return 'transient' === $kind ? new TransientCache( "{$this->prefix}_{$name}_" ) : new ObjectCache( "{$this->prefix}_$name" ); + return 'transient' === $kind ? new TransientCache( $this->identity, $name ) : new ObjectCache( $this->identity, $name ); } protected function generation( string $kind ): ?string { diff --git a/tests/Integration/Core/Caching/ObjectCacheTest.php b/tests/Integration/Core/Caching/ObjectCacheTest.php index 48222473..2d22a307 100644 --- a/tests/Integration/Core/Caching/ObjectCacheTest.php +++ b/tests/Integration/Core/Caching/ObjectCacheTest.php @@ -113,33 +113,33 @@ protected function restore_the_object_cache(): void { wp_cache_flush(); } - public static function invalid_groups(): array { + public static function invalid_regions(): array { return array( - 'empty' => array( '' ), - 'zero' => array( '0' ), - 'digit-first' => array( '1acme' ), - 'underscore-first' => array( '_acme' ), - 'uppercase' => array( 'Acme_cache' ), + 'uppercase' => array( 'Cache' ), 'colon' => array( 'acme:cache' ), 'space' => array( 'acme cache' ), 'dot' => array( 'acme.cache' ), - 'leading-space' => array( ' acme' ), - 'trailing-newline' => array( "acme\n" ), - 'overlong' => array( 'a' . \str_repeat( 'b', 64 ) ), + 'trailing-newline' => array( "cache\n" ), ); } - #[DataProvider( 'invalid_groups' )] - public function test_a_group_that_is_not_a_lowercase_identifier_of_up_to_64_characters_is_rejected( string $group ): void { + #[DataProvider( 'invalid_regions' )] + public function test_a_region_that_makes_the_group_no_lowercase_identifier_is_rejected( string $region ): void { $this->expectException( InvalidArgumentException::class ); - new ObjectCache( $group ); + new ObjectCache( $this->identity, $region ); + } + + // The prefix, an underscore and the region fill the group's 64 characters. + public function test_a_group_past_64_characters_is_rejected(): void { + $this->expectException( InvalidArgumentException::class ); + + new ObjectCache( $this->identity, \str_repeat( 'r', 64 - \strlen( $this->prefix ) ) ); } public function test_a_valid_group_is_accepted_without_touching_the_object_cache(): void { - new ObjectCache( 'a' ); - new ObjectCache( 'a' . \str_repeat( 'b', 63 ) ); - new ObjectCache( 'acme_cache-2' ); + new ObjectCache( $this->identity, 'cache-2' ); + new ObjectCache( $this->identity, \str_repeat( 'r', 63 - \strlen( $this->prefix ) ) ); self::assertSame( array(), $this->spy->calls ); } @@ -391,7 +391,7 @@ public function test_empty_batches_and_reads_without_a_salt_touch_no_entry(): vo } protected function cache(): ObjectCache { - return new ObjectCache( $this->group() ); + return new ObjectCache( $this->identity, 'cache' ); } protected function group(): string { diff --git a/tests/Integration/Core/Caching/TransientCacheTest.php b/tests/Integration/Core/Caching/TransientCacheTest.php index ab903117..9647ffa3 100644 --- a/tests/Integration/Core/Caching/TransientCacheTest.php +++ b/tests/Integration/Core/Caching/TransientCacheTest.php @@ -20,44 +20,30 @@ final class TransientCacheTest extends TestCase { use IsolatesHooksTrait; use IsolatesStorageTrait; - public static function invalid_prefixes(): array { - return array( - 'empty' => array( '' ), - 'leading' => array( ' acme_' ), - 'trailing' => array( 'acme_ ' ), - 'overlong' => array( \str_repeat( 'a', 141 ) ), - ); - } + // The prefix, the region and an underscore after each fill the 140 characters that leave room for a key's hash. + public function test_a_region_that_leaves_no_room_for_a_keys_hash_is_rejected(): void { + new TransientCache( $this->identity, \str_repeat( 'r', 138 - \strlen( $this->prefix ) ) ); - #[DataProvider( 'invalid_prefixes' )] - public function test_a_prefix_the_transient_names_would_not_keep_is_rejected( string $prefix ): void { $this->expectException( InvalidArgumentException::class ); - new TransientCache( $prefix ); + new TransientCache( $this->identity, \str_repeat( 'r', 139 - \strlen( $this->prefix ) ) ); } - public static function invalid_generation_options(): array { - return array( - 'empty' => array( '' ), - 'leading' => array( ' acme_generation' ), - 'trailing' => array( 'acme_generation ' ), - 'overlong' => array( \str_repeat( 'a', 192 ) ), - ); - } + // The prefix, an underscore, the generation and _cache_generation fill the 191 characters of an option name. + public function test_a_generation_whose_option_name_would_pass_191_characters_is_rejected(): void { + new TransientCache( $this->identity, 'cache', \str_repeat( 'g', 173 - \strlen( $this->prefix ) ) ); - #[DataProvider( 'invalid_generation_options' )] - public function test_a_generation_option_the_options_table_would_not_keep_is_rejected( string $option ): void { $this->expectException( InvalidArgumentException::class ); - new TransientCache( "{$this->prefix}_", $option ); + new TransientCache( $this->identity, 'cache', \str_repeat( 'g', 174 - \strlen( $this->prefix ) ) ); } public function test_construction_reads_and_writes_nothing(): void { global $wpdb; $queries = $wpdb->num_queries; - new TransientCache( \str_pad( "{$this->prefix}_", 140, 'p' ) ); - new TransientCache( "{$this->prefix}_", \str_pad( "{$this->prefix}_generation", 191, 'g' ) ); + new TransientCache( $this->identity, \str_repeat( 'r', 138 - \strlen( $this->prefix ) ) ); + new TransientCache( $this->identity, 'cache', \str_repeat( 'g', 173 - \strlen( $this->prefix ) ) ); self::assertSame( $queries, $wpdb->num_queries ); self::assertSame( array(), $this->stored() ); @@ -99,10 +85,10 @@ public function test_each_entry_is_a_transient_named_by_the_hash_of_its_key_with $this->cache()->set( 'key', 'Value.' ); $this->cache()->set( $long, 'Value.' ); - $expected = array( "{$this->prefix}_cache_generation" => 'on' ); + $expected = array( "{$this->prefix}_cache_cache_generation" => 'on' ); foreach ( array( 'key', $long ) as $key ) { - $expected[ "_transient_{$this->prefix}_" . \md5( $key ) ] = 'off'; - $expected[ "_transient_timeout_{$this->prefix}_" . \md5( $key ) ] = 'off'; + $expected[ "_transient_{$this->prefix}_cache_" . \md5( $key ) ] = 'off'; + $expected[ "_transient_timeout_{$this->prefix}_cache_" . \md5( $key ) ] = 'off'; } \ksort( $expected ); @@ -110,7 +96,7 @@ public function test_each_entry_is_a_transient_named_by_the_hash_of_its_key_with } public function test_a_filtered_scalar_reads_as_a_miss(): void { - add_filter( "pre_transient_{$this->prefix}_" . \md5( 'key' ), static fn (): string => 'Filtered.' ); + add_filter( "pre_transient_{$this->prefix}_cache_" . \md5( 'key' ), static fn (): string => 'Filtered.' ); self::assertSame( 'Default.', $this->cache()->get( 'key', 'Default.' ) ); } @@ -140,7 +126,7 @@ public function test_an_entry_whose_timeout_has_passed_reads_as_the_default(): v $cache = $this->cache(); $cache->set( 'key', 'Value.', HOUR_IN_SECONDS ); - $wpdb->update( $wpdb->options, array( 'option_value' => \time() - 1 ), array( 'option_name' => "_transient_timeout_{$this->prefix}_" . \md5( 'key' ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Expires the entry without waiting. + $wpdb->update( $wpdb->options, array( 'option_value' => \time() - 1 ), array( 'option_name' => "_transient_timeout_{$this->prefix}_cache_" . \md5( 'key' ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Expires the entry without waiting. wp_cache_flush(); self::assertSame( 'Default.', $cache->get( 'key', 'Default.' ) ); @@ -188,7 +174,7 @@ public function test_a_failed_delete_returns_true_because_wordpress_reads_the_en public function test_a_delete_that_leaves_the_timeout_row_returns_true(): void { $cache = $this->cache(); $cache->set( 'key', 'Value.' ); - $this->fail_queries( "_transient_timeout_{$this->prefix}_" . \md5( 'key' ) ); + $this->fail_queries( "_transient_timeout_{$this->prefix}_cache_" . \md5( 'key' ) ); self::assertTrue( $cache->delete( 'key' ) ); self::assertFalse( $cache->has( 'key' ) ); @@ -206,7 +192,7 @@ public function test_behind_a_persistent_object_cache_the_same_results_hold(): v self::assertTrue( $cache->delete( 'key' ) ); self::assertSame( 'Default.', $cache->get( 'key', 'Default.' ) ); self::assertTrue( $cache->set( 'key', 'Value.', 0 ) ); - self::assertSame( array( "{$this->prefix}_cache_generation" => 'on' ), $this->stored() ); + self::assertSame( array( "{$this->prefix}_cache_cache_generation" => 'on' ), $this->stored() ); } finally { // The flag is null without a drop-in, and passing null only reads it. $GLOBALS['_wp_using_ext_object_cache'] = $previous; @@ -257,14 +243,14 @@ public function test_each_clear_stores_a_new_random_autoloaded_generation_even_o $cache = $this->cache(); self::assertTrue( $cache->clear() ); - $first = get_option( "{$this->prefix}_cache_generation" ); + $first = get_option( "{$this->prefix}_cache_cache_generation" ); self::assertTrue( $cache->clear() ); - $second = get_option( "{$this->prefix}_cache_generation" ); + $second = get_option( "{$this->prefix}_cache_cache_generation" ); self::assertMatchesRegularExpression( '/\A[0-9a-f]{16}\z/', $first ); self::assertMatchesRegularExpression( '/\A[0-9a-f]{16}\z/', $second ); self::assertNotSame( $first, $second ); - self::assertSame( array( "{$this->prefix}_cache_generation" => 'on' ), $this->stored() ); + self::assertSame( array( "{$this->prefix}_cache_cache_generation" => 'on' ), $this->stored() ); } public static function unusable_generations(): array { @@ -276,9 +262,9 @@ public static function unusable_generations(): array { #[DataProvider( 'unusable_generations' )] public function test_a_generation_option_holding_no_non_empty_string_reads_as_none_until_a_write_starts_one( mixed $stored ): void { - update_option( "{$this->prefix}_cache_generation", $stored ); + update_option( "{$this->prefix}_cache_cache_generation", $stored ); set_transient( - "{$this->prefix}_" . \md5( 'key' ), + "{$this->prefix}_cache_" . \md5( 'key' ), array( 'value' => 'Value.', 'generation' => $stored, @@ -289,13 +275,13 @@ public function test_a_generation_option_holding_no_non_empty_string_reads_as_no self::assertSame( 'Default.', $cache->get( 'key', 'Default.' ) ); self::assertTrue( $cache->set( 'other', 'Other.' ) ); - self::assertMatchesRegularExpression( '/\A[0-9a-f]{16}\z/', get_option( "{$this->prefix}_cache_generation" ) ); + self::assertMatchesRegularExpression( '/\A[0-9a-f]{16}\z/', get_option( "{$this->prefix}_cache_cache_generation" ) ); self::assertSame( 'Other.', $cache->get( 'other' ) ); } public function test_without_a_generation_every_read_misses_without_a_write(): void { set_transient( - "{$this->prefix}_" . \md5( 'key' ), + "{$this->prefix}_cache_" . \md5( 'key' ), array( 'value' => 'Value.', 'generation' => null, @@ -314,7 +300,7 @@ public function test_without_a_generation_every_read_misses_without_a_write(): v public function test_an_entry_from_before_the_generation_was_deleted_never_comes_back(): void { $cache = $this->cache(); $cache->set( 'old', 'Old.' ); - delete_option( "{$this->prefix}_cache_generation" ); + delete_option( "{$this->prefix}_cache_cache_generation" ); $cache->set( 'new', 'New.' ); @@ -338,7 +324,7 @@ public function test_reading_several_keys_from_the_options_table_runs_one_query( public function test_a_failed_write_or_delete_among_several_returns_false(): void { $cache = $this->cache(); $cache->setMultiple( \array_fill_keys( array( 'a', 'b' ), 'Prior.' ) ); - $this->fail_queries( "_transient_{$this->prefix}_" . \md5( 'a' ) ); + $this->fail_queries( "_transient_{$this->prefix}_cache_" . \md5( 'a' ) ); self::assertFalse( $cache->setMultiple( \array_fill_keys( array( 'a', 'b' ), 'Newer.' ) ) ); self::assertSame( 'Newer.', $cache->get( 'b' ) ); @@ -346,9 +332,9 @@ public function test_a_failed_write_or_delete_among_several_returns_false(): voi self::assertFalse( $cache->has( 'b' ) ); } - public function test_caches_that_share_a_generation_option_clear_together(): void { - $one = new TransientCache( "{$this->prefix}_one_", "{$this->prefix}_shared_generation" ); - $two = new TransientCache( "{$this->prefix}_two_", "{$this->prefix}_shared_generation" ); + public function test_caches_that_share_a_generation_clear_together(): void { + $one = new TransientCache( $this->identity, 'one', 'shared' ); + $two = new TransientCache( $this->identity, 'two', 'shared' ); $one->set( 'key', 'One.' ); $two->set( 'key', 'Two.' ); @@ -361,7 +347,7 @@ public function test_caches_that_share_a_generation_option_clear_together(): voi // Returning the stored value from this filter makes update_option() report a failed write, as a refused database write would. public function test_a_generation_that_cannot_be_stored_fails_writes_and_clears(): void { - add_filter( "pre_update_option_{$this->prefix}_cache_generation", static fn ( mixed $value, mixed $old_value ): mixed => $old_value, 10, 2 ); + add_filter( "pre_update_option_{$this->prefix}_cache_cache_generation", static fn ( mixed $value, mixed $old_value ): mixed => $old_value, 10, 2 ); $cache = $this->cache(); self::assertFalse( $cache->set( 'key', 'Value.' ) ); @@ -395,13 +381,13 @@ public function delete( $key, $group = 'default', $deprecated = false ) { } protected function cache(): TransientCache { - return new TransientCache( "{$this->prefix}_" ); + return new TransientCache( $this->identity, 'cache' ); } protected function timeout( string $key ): int { global $wpdb; - return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name = %s', $wpdb->options, "_transient_timeout_{$this->prefix}_" . \md5( $key ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Inspects the stored timeout. + return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name = %s', $wpdb->options, "_transient_timeout_{$this->prefix}_cache_" . \md5( $key ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Inspects the stored timeout. } protected function stored(): array { diff --git a/tests/Integration/Core/Logging/ErrorLogLoggerTest.php b/tests/Integration/Core/Logging/ErrorLogLoggerTest.php index 44a18df5..5b08a80c 100644 --- a/tests/Integration/Core/Logging/ErrorLogLoggerTest.php +++ b/tests/Integration/Core/Logging/ErrorLogLoggerTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Core\Logging; use DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesHooksTrait; use DeepWebSolutions\Framework\Tests\Integration\Support\ProvidesLogMessagesTrait; use PHPUnit\Framework\Attributes\CoversClass; @@ -49,7 +50,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( new PluginIdentity( __FILE__, 'acme', '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 +67,14 @@ static function ( mixed ...$args ) use ( &$received ): void { 2 ); - new ErrorLogLogger( 'acme' )->log( $level, 'Cache {name} is cold.', array( 'name' => 'acme' ) ); + new ErrorLogLogger( new PluginIdentity( __FILE__, 'acme', '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( new PluginIdentity( __FILE__, 'acme', 'acme' ) )->error( $message, $context ); self::assertStringContainsString( "[acme.error] $expected" . \PHP_EOL, (string) \file_get_contents( $this->log_file ) ); } @@ -90,7 +91,7 @@ public function __toString(): string { } }; - new ErrorLogLogger( 'acme' )->error( + new ErrorLogLogger( new PluginIdentity( __FILE__, 'acme', 'acme' ) )->error( 'Hello {name}.', array( 'name' => 'world', @@ -105,7 +106,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( new PluginIdentity( __FILE__, 'acme', 'acme' ) )->error( 'Warm-up failed: {message}', array( 'message' => $exception->getMessage(), @@ -122,13 +123,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( new PluginIdentity( __FILE__, 'acme', '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( new PluginIdentity( __FILE__, 'acme', 'acme' ) )->error( 'Warm-up failed.', array( 'exception' => 'Cache is cold.' ) ); self::assertStringNotContainsString( 'Cache is cold.', (string) \file_get_contents( $this->log_file ) ); } @@ -137,6 +138,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( new PluginIdentity( __FILE__, 'acme', 'acme' ) )->log( 'verbose', 'Cache is cold.' ); } } diff --git a/tests/Integration/Core/Logging/GuardedLoggerTest.php b/tests/Integration/Core/Logging/GuardedLoggerTest.php index 7b5ee4da..5162ce6e 100644 --- a/tests/Integration/Core/Logging/GuardedLoggerTest.php +++ b/tests/Integration/Core/Logging/GuardedLoggerTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Core\Logging; use DeepWebSolutions\Framework\Core\Logging\GuardedLogger; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Tests\Integration\Support\RecordingLogger; use DeepWebSolutions\Framework\Tests\Integration\Support\ThrowingLogger; use PHPUnit\Framework\Attributes\CoversClass; @@ -31,7 +32,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, new PluginIdentity( __FILE__, 'acme', '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 ) ); @@ -41,7 +42,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 ), new PluginIdentity( __FILE__, 'acme', 'acme' ) )->error( 'Warm-up of {name} failed.', array( 'name' => 'acme', @@ -58,7 +59,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, new PluginIdentity( __FILE__, 'acme', '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 index 30caa3b8..98bf7fe5 100644 --- a/tests/Integration/Core/Migration/MigrationFixturesTrait.php +++ b/tests/Integration/Core/Migration/MigrationFixturesTrait.php @@ -2,16 +2,25 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Core\Migration; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Core\Storage\OptionRows; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; trait MigrationFixturesTrait { + protected PluginIdentity $identity; + protected string $option; + protected string $hook_root; + + // The slug differs from the prefix, so a row tells the runner's hook from its stored names. #[Before] - protected function use_a_fresh_option(): void { - $this->option = 'acme_db_' . \bin2hex( \random_bytes( 4 ) ); + protected function use_a_fresh_identity(): void { + $suffix = \bin2hex( \random_bytes( 4 ) ); + $this->identity = new PluginIdentity( \dirname( __DIR__, 3 ) . '/Fixtures/Core/acme-boot.php', "acme-runner-$suffix", "acme_db_$suffix" ); + $this->option = "acme_db_{$suffix}_revision"; + $this->hook_root = "acme_runner_$suffix"; } #[After] diff --git a/tests/Integration/Core/Migration/MigrationRunnerInLocalEnvironmentTest.php b/tests/Integration/Core/Migration/MigrationRunnerInLocalEnvironmentTest.php index 7a12703a..2eb54201 100644 --- a/tests/Integration/Core/Migration/MigrationRunnerInLocalEnvironmentTest.php +++ b/tests/Integration/Core/Migration/MigrationRunnerInLocalEnvironmentTest.php @@ -83,6 +83,6 @@ 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' ) ); + return new MigrationRunner( $this->identity, $steps, new PsrNullLogger(), new Scheduler( $this->identity ) ); } } diff --git a/tests/Integration/Core/Migration/MigrationRunnerTest.php b/tests/Integration/Core/Migration/MigrationRunnerTest.php index c1667452..e79a9e4f 100644 --- a/tests/Integration/Core/Migration/MigrationRunnerTest.php +++ b/tests/Integration/Core/Migration/MigrationRunnerTest.php @@ -4,6 +4,7 @@ use DeepWebSolutions\Framework\Core\Migration\BatchedMigrationStepInterface; use DeepWebSolutions\Framework\Core\Migration\MigrationRunner; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Core\Scheduling\Scheduler; use DeepWebSolutions\Framework\Core\Scheduling\SchedulerInterface; use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; @@ -78,9 +79,9 @@ protected function tearDown(): void { global $wpdb; \ini_set( 'memory_limit', $this->memory_limit ); - wp_unschedule_hook( "$this->option/migration/run" ); + wp_unschedule_hook( "$this->hook_root/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->hook_root/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 { @@ -99,11 +100,11 @@ public function test_steps_below_the_stored_revision_never_run(): void { self::assertSame( 3, MigrationRunner::get_revision( $this->option ) ); } - public function test_an_option_whose_scheduled_hook_would_pass_191_bytes_is_refused(): void { - new MigrationRunner( self::PLUGIN_FILE, \str_repeat( 'a', 177 ), array(), $this->logger, $this->make_wp_cron_scheduler() ); + public function test_a_slug_whose_scheduled_hook_would_pass_191_bytes_is_refused(): void { + new MigrationRunner( new PluginIdentity( self::PLUGIN_FILE, \str_repeat( 'a', 177 ), $this->identity->prefix ), array(), $this->logger, $this->make_wp_cron_scheduler() ); $this->expectException( InvalidArgumentException::class ); - new MigrationRunner( self::PLUGIN_FILE, \str_repeat( 'a', 178 ), array(), $this->logger, $this->make_wp_cron_scheduler() ); + new MigrationRunner( new PluginIdentity( self::PLUGIN_FILE, \str_repeat( 'a', 178 ), $this->identity->prefix ), array(), $this->logger, $this->make_wp_cron_scheduler() ); } public static function current_revisions(): array { @@ -639,7 +640,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->identity->slug}.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" ) ); } @@ -666,13 +667,13 @@ 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->identity->slug}.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 ); } public function test_a_batch_time_limit_below_1_second_runs_one_step_per_request(): void { - $this->load_wordpress( new MigrationRunner( self::PLUGIN_FILE, $this->option, array( $this->step( 1 ), $this->step( 2 ) ), $this->logger, $this->make_wp_cron_scheduler(), 0 ) ); + $this->load_wordpress( new MigrationRunner( $this->identity, array( $this->step( 1 ), $this->step( 2 ) ), $this->logger, $this->make_wp_cron_scheduler(), 0 ) ); self::assertSame( array( array( 1, 0 ) ), $this->ran ); self::assertSame( 1, MigrationRunner::get_revision( $this->option ) ); @@ -694,17 +695,17 @@ 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->hook_root/migration/run", array(), $this->identity->prefix ); $running = as_get_scheduled_actions( array( - 'hook' => "$this->option/migration/run", + 'hook' => "$this->hook_root/migration/run", 'status' => \ActionScheduler_Store::STATUS_PENDING, ), 'ids' ); \ActionScheduler::store()->log_execution( (int) $running[0] ); - $this->load_wordpress( $this->make_runner( array( new RecordedBatches( 3 ) ), null, null, new Scheduler( 'acme' ) ) ); + $this->load_wordpress( $this->make_runner( array( new RecordedBatches( 3 ) ), null, null, new Scheduler( $this->identity ) ) ); self::assertSame( 0, $this->count_pending_runs() ); } @@ -713,7 +714,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->hook_root/migration/run" ); self::assertSame( 3, $batches->processed ); self::assertSame( array( array( 2, 1 ) ), $this->ran ); @@ -736,7 +737,7 @@ function ( mixed $event ) use ( &$locks ): mixed { } ); - do_action( "$this->option/migration/run" ); + do_action( "$this->hook_root/migration/run" ); self::assertSame( 2, $batches->processed ); self::assertSame( array( null ), $locks ); @@ -751,9 +752,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->hook_root/migration/run" ); $this->make_runner( array( $batches ), $clock )->register_hooks(); - do_action( "$this->option/migration/run" ); + do_action( "$this->hook_root/migration/run" ); $revisions[] = array( $batches->processed, MigrationRunner::get_revision( $this->option ) ); } @@ -774,7 +775,7 @@ static function () use ( $clock ): void { ); $this->load_wordpress( $runner ); - do_action( "$this->option/migration/run" ); + do_action( "$this->hook_root/migration/run" ); self::assertSame( 1, $batches->processed ); self::assertSame( 1, MigrationRunner::get_revision( $this->option ) ); @@ -793,7 +794,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->hook_root/migration/run" ); self::assertSame( 1, $batches->processed ); self::assertSame( $later->now->getTimestamp(), $this->get_lock()['time'] ?? null ); @@ -833,7 +834,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->hook_root/migration/run" ); self::assertSame( array( false, false, false ), $cached ); self::assertFalse( wp_cache_get( 'acme_batch', 'acme' ) ); @@ -891,7 +892,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->hook_root/migration/run" ); } finally { \ini_set( 'memory_limit', $previous ); } @@ -904,7 +905,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->hook_root/migration/run" ); self::assertSame( 0, $batches->processed ); self::assertSame( 0, $this->count_pending_runs() ); @@ -922,7 +923,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->hook_root/migration/run" ); $error = get_option( "{$this->option}_error" ); self::assertIsArray( $error ); @@ -948,7 +949,7 @@ public function test_a_successful_batch_clears_the_error_of_an_earlier_failure() false ); - do_action( "$this->option/migration/run" ); + do_action( "$this->hook_root/migration/run" ); self::assertSame( 1, $batches->processed ); self::assertFalse( get_option( "{$this->option}_error" ) ); @@ -965,14 +966,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 = 'action-scheduler' === $kind ? new Scheduler( 'acme' ) : $this->make_wp_cron_scheduler(); + $scheduler = 'action-scheduler' === $kind ? new Scheduler( $this->identity ) : $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->hook_root/migration/run" ); $this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, $scheduler ) ); - 'action-scheduler' === $kind ? self::run_due_actions( "$this->option/migration/run" ) : self::run_due_cron_events( "$this->option/migration/run" ); + 'action-scheduler' === $kind ? self::run_due_actions( "$this->hook_root/migration/run" ) : self::run_due_cron_events( "$this->hook_root/migration/run" ); ++$requests; } @@ -985,18 +986,18 @@ public function test_a_batched_step_finishes_through_real_scheduler_dispatch( st public function test_a_spent_budget_leaves_nothing_due_for_the_queue_run_in_progress_and_the_next_request_resumes(): void { $clock = new SettableClock( new \DateTimeImmutable( '@' . \time() ) ); $batches = new RecordedBatches( 5, static fn () => $clock->advance( 30 ) ); - $this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, new Scheduler( 'acme' ) ) ); + $this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, new Scheduler( $this->identity ) ) ); - self::run_due_actions( "$this->option/migration/run" ); + self::run_due_actions( "$this->hook_root/migration/run" ); // A queue run that pauses between actions claims again later, as wp action-scheduler run --pause does. - self::run_due_actions( "$this->option/migration/run", \time() + MINUTE_IN_SECONDS ); + self::run_due_actions( "$this->hook_root/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" ); - $this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, new Scheduler( 'acme' ) ) ); - self::run_due_actions( "$this->option/migration/run" ); + remove_all_actions( "$this->hook_root/migration/run" ); + $this->load_wordpress( $this->make_runner( array( $batches ), $clock, null, new Scheduler( $this->identity ) ) ); + self::run_due_actions( "$this->hook_root/migration/run" ); self::assertSame( 2, $batches->processed ); self::assertSame( 0, MigrationRunner::get_revision( $this->option ) ); @@ -1023,7 +1024,7 @@ static function (): void { ); $runner = $this->make_runner( array( $batches ) ); $runner->register_hooks(); - do_action( "$this->option/migration/run" ); + do_action( "$this->hook_root/migration/run" ); $notices = $this->render_all_admin_notices( $this->make_user( 'acme-migrations-admin', 'administrator' ) ); self::assertStringContainsString( self::ERROR_NOTICE . 'The <batch> failed.', $notices ); @@ -1040,8 +1041,8 @@ public function test_a_pending_batched_step_shows_plugin_activators_a_run_now_li 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->hook_root/migration/run_now", $query['action'] ); + self::assertSame( 1, wp_verify_nonce( $query['_wpnonce'], "$this->hook_root/migration/run_now" ) ); self::assertStringNotContainsString( self::PROGRESS_NOTICE, $this->render_all_admin_notices( $this->make_user( 'acme-migrations-editor', 'editor' ) ) ); } @@ -1057,7 +1058,7 @@ static function () use ( &$broken ): void { } ); $this->make_runner( array( $batches ) )->register_hooks(); - do_action( "$this->option/migration/run" ); + do_action( "$this->hook_root/migration/run" ); $this->age_lock(); $broken = false; @@ -1148,7 +1149,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->hook_root/migration/run_now" ); $_SERVER['HTTP_REFERER'] = \str_starts_with( $referer, 'https://' ) ? $referer : admin_url( $referer ); self::assertSame( admin_url( $destination ), $this->post_run_now() ); @@ -1168,21 +1169,21 @@ 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->hook_root/migration/run_now" ) : 'invalid'; self::assertSame( 'died', $this->post_run_now() ); self::assertSame( 0, $batches->processed ); } protected function make_runner( array $steps, ?PsrClockInterface $clock = null, ?PsrLoggerInterface $logger = null, ?SchedulerInterface $scheduler = null ): MigrationRunner { - return new MigrationRunner( self::PLUGIN_FILE, $this->option, $steps, $logger ?? $this->logger, $scheduler ?? $this->make_wp_cron_scheduler(), 20, $clock ); + return new MigrationRunner( $this->identity, $steps, $logger ?? $this->logger, $scheduler ?? $this->make_wp_cron_scheduler(), 20, $clock ); } // Action Scheduler below its floor leaves the writes to WP-Cron. protected function make_wp_cron_scheduler(): Scheduler { $this->report_action_scheduler_versions( array( '3.9.0' ) ); - return new Scheduler( 'acme' ); + return new Scheduler( $this->identity ); } protected function step( int|string $name ): \Closure { @@ -1242,7 +1243,7 @@ static function ( string $location ): never { ); try { - do_action( "admin_post_$this->option/migration/run_now" ); + do_action( "admin_post_$this->hook_root/migration/run_now" ); } catch ( \OutOfBoundsException | \OverflowException $stop ) { return $stop->getMessage(); } @@ -1253,10 +1254,10 @@ 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->hook_root/migration/run" ] ?? array() ); } $query = array( - 'hook' => "$this->option/migration/run", + 'hook' => "$this->hook_root/migration/run", 'status' => \ActionScheduler_Store::STATUS_PENDING, 'per_page' => -1, ); diff --git a/tests/Integration/Core/Permission/CapabilityGrantsTest.php b/tests/Integration/Core/Permission/CapabilityGrantsTest.php index e8784614..06c099dd 100644 --- a/tests/Integration/Core/Permission/CapabilityGrantsTest.php +++ b/tests/Integration/Core/Permission/CapabilityGrantsTest.php @@ -53,23 +53,20 @@ public static function invalid_constructions(): array { $cap = array( 'acme_cap' ); return array( - 'empty-option' => array( '', array( 'acme_role' => $cap ) ), - 'padded-option' => array( ' acme_grants', array( 'acme_role' => $cap ) ), - 'overlong-option' => array( \str_repeat( 'a', 192 ), array( 'acme_role' => $cap ) ), - 'empty-role' => array( 'acme_grants', array( '' => $cap ) ), - 'numeric-role' => array( 'acme_grants', array( 7 => $cap ) ), - 'caps-not-an-array' => array( 'acme_grants', array( 'acme_role' => 'acme_cap' ) ), - 'caps-not-a-list' => array( 'acme_grants', array( 'acme_role' => array( 'a' => 'acme_cap' ) ) ), - 'empty-cap' => array( 'acme_grants', array( 'acme_role' => array( '' ) ) ), - 'non-string-cap' => array( 'acme_grants', array( 'acme_role' => array( 1 ) ) ), + 'empty-role' => array( array( '' => $cap ) ), + 'numeric-role' => array( array( 7 => $cap ) ), + 'caps-not-an-array' => array( array( 'acme_role' => 'acme_cap' ) ), + 'caps-not-a-list' => array( array( 'acme_role' => array( 'a' => 'acme_cap' ) ) ), + 'empty-cap' => array( array( 'acme_role' => array( '' ) ) ), + 'non-string-cap' => array( array( 'acme_role' => array( 1 ) ) ), ); } #[DataProvider( 'invalid_constructions' )] - public function test_an_invalid_option_or_grants_map_throws_on_construction( string $option, array $grants ): void { + public function test_an_invalid_grants_map_throws_on_construction( array $grants ): void { $this->expectException( InvalidArgumentException::class ); - new CapabilityGrants( $option, $grants, $this->logger ); + new CapabilityGrants( $this->identity, $grants, $this->logger ); } public function test_construction_reads_writes_and_hooks_nothing(): void { @@ -263,13 +260,13 @@ public function test_deleting_an_undeclared_role_or_writing_another_option_leave $grants = $this->grants( array( $editor => array( $this->cap( 'a' ) ) ) ); $grants->register_hooks(); $grants->run(); - $row = get_option( "{$this->prefix}_grants" ); + $row = get_option( "{$this->prefix}_capabilities" ); remove_role( $other ); add_option( "{$this->prefix}_unrelated", array( $editor => array() ) ); update_option( "{$this->prefix}_unrelated", array() ); - self::assertSame( $row, get_option( "{$this->prefix}_grants" ) ); + self::assertSame( $row, get_option( "{$this->prefix}_capabilities" ) ); } public function test_a_changed_declaration_grants_the_added_pairs_and_revokes_the_dropped_ones_in_one_run(): void { @@ -345,7 +342,7 @@ public function test_a_warning_whose_logger_throws_reaches_the_error_log_without wp_delete_file( $log_file ); } - self::assertStringContainsString( "[{$this->prefix}_grants.warning] The roles row did not keep the capability changes {changes}, so the next request makes them again. {\"changes\":\"grant {$this->cap( 'a' )} to $editor\"}", $log ); + self::assertStringContainsString( "[{$this->slug}.warning] The roles row did not keep the capability changes {changes}, so the next request makes them again. {\"changes\":\"grant {$this->cap( 'a' )} to $editor\"}", $log ); self::assertStringContainsString( "\nThe logger failed: UnexpectedValueException: The log is not writable.", $log ); } @@ -357,7 +354,7 @@ public function test_while_a_run_grants_the_ledger_row_lists_the_pairs_as_pendin $this->before_query( wp_roles()->role_key, function () use ( $wpdb, &$pending ): void { - $raw = $wpdb->get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name = %s', $wpdb->options, "{$this->prefix}_grants" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads past the options cache. + $raw = $wpdb->get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name = %s', $wpdb->options, "{$this->prefix}_capabilities" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads past the options cache. $pending = maybe_unserialize( (string) $raw )['pending'] ?? null; } ); @@ -365,20 +362,20 @@ function () use ( $wpdb, &$pending ): void { $this->grants( array( $editor => array( $this->cap( 'a' ) ) ) )->run(); self::assertSame( array( $editor => array( $this->cap( 'a' ) ) ), $pending ); - self::assertSame( array(), get_option( "{$this->prefix}_grants" )['pending'] ); + self::assertSame( array(), get_option( "{$this->prefix}_capabilities" )['pending'] ); } public function test_a_pending_record_the_ledger_row_does_not_keep_is_logged_and_grants_nothing_until_the_next_request(): void { $editor = $this->add_role( 'editor' ); $declared = array( $editor => array( $this->cap( 'a' ) ) ); - add_filter( "pre_update_option_{$this->prefix}_grants", static fn ( mixed $value, mixed $old_value ): mixed => $old_value, 10, 2 ); + add_filter( "pre_update_option_{$this->prefix}_capabilities", static fn ( mixed $value, mixed $old_value ): mixed => $old_value, 10, 2 ); $this->grants( $declared )->run(); self::assertSame( array(), $this->stored_caps( $editor ) ); self::assertSame( array( array( 'warning', 'The ledger row did not keep the pending grants, so the next request makes them.', array() ) ), $this->logger->records ); - remove_all_filters( "pre_update_option_{$this->prefix}_grants" ); + remove_all_filters( "pre_update_option_{$this->prefix}_capabilities" ); $this->next_request(); $this->grants( $declared )->run(); @@ -395,7 +392,7 @@ public function test_a_run_interrupted_after_its_pending_write_converges_on_the_ ) ); update_option( - "{$this->prefix}_grants", + "{$this->prefix}_capabilities", array( 'v' => 1, 'fingerprint' => '', @@ -422,7 +419,7 @@ public function test_a_run_interrupted_after_its_pending_write_converges_on_the_ public function test_a_pending_grant_that_the_declaration_has_since_dropped_is_revoked(): void { $editor = $this->add_role( 'editor', array( $this->cap( 'a' ) => true ) ); update_option( - "{$this->prefix}_grants", + "{$this->prefix}_capabilities", array( 'v' => 1, 'fingerprint' => '', @@ -481,7 +478,7 @@ public function test_a_missing_or_malformed_row_reads_as_an_empty_ledger( string ), ), }; - update_option( "{$this->prefix}_grants", $row, true ); + update_option( "{$this->prefix}_capabilities", $row, true ); $this->grants( $grants )->run(); @@ -512,11 +509,11 @@ public function test_a_run_and_a_role_deletion_change_only_their_own_sites_roles self::assertEquals( array( $editor => array( $this->cap( 'a' ) ) ), $ledger ); self::assertSame( array(), $forgotten ); self::assertArrayNotHasKey( $editor, $this->stored_roles() ); - self::assertFalse( get_option( "{$this->prefix}_grants" ) ); + self::assertFalse( get_option( "{$this->prefix}_capabilities" ) ); } protected function grants( array $grants, ?PsrLoggerInterface $logger = null ): CapabilityGrants { - return new CapabilityGrants( "{$this->prefix}_grants", $grants, $logger ?? $this->logger ); + return new CapabilityGrants( $this->identity, $grants, $logger ?? $this->logger ); } protected function role( string $name ): string { @@ -563,7 +560,7 @@ static function ( array $caps ): array { \sort( $caps ); return $caps; }, - get_option( "{$this->prefix}_grants" )['grants'] ?? array() + get_option( "{$this->prefix}_capabilities" )['grants'] ?? array() ); } diff --git a/tests/Integration/Core/PluginBootInLocalEnvironmentTest.php b/tests/Integration/Core/PluginBootInLocalEnvironmentTest.php index ff941da5..18bc6006 100644 --- a/tests/Integration/Core/PluginBootInLocalEnvironmentTest.php +++ b/tests/Integration/Core/PluginBootInLocalEnvironmentTest.php @@ -35,7 +35,7 @@ public static function tearDownAfterClass(): void { #[\Override] protected function setUp(): void { add_action( - "$this->prefix/boot/succeeded", + "$this->hook_root/boot/succeeded", function (): void { $this->fired[] = 'succeeded'; }, @@ -43,7 +43,7 @@ function (): void { 0 ); add_action( - "$this->prefix/boot/failed", + "$this->hook_root/boot/failed", function ( \Throwable $failure ): void { $this->fired[] = 'failed'; $this->failures[] = $failure; @@ -54,7 +54,7 @@ function ( \Throwable $failure ): void { 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', $this->prefix, new PsrNullLogger() ); + $boot = new PluginBoot( $this->identity, new PsrNullLogger() ); $caught = null; try { diff --git a/tests/Integration/Core/PluginBootTest.php b/tests/Integration/Core/PluginBootTest.php index 914dfdc1..314185a5 100644 --- a/tests/Integration/Core/PluginBootTest.php +++ b/tests/Integration/Core/PluginBootTest.php @@ -34,7 +34,7 @@ final class PluginBootTest extends TestCase { protected function setUp(): void { $this->logger = new RecordingLogger(); add_action( - "$this->prefix/boot/succeeded", + "$this->hook_root/boot/succeeded", function (): void { $this->fired[] = 'succeeded'; }, @@ -42,7 +42,7 @@ function (): void { 0 ); add_action( - "$this->prefix/boot/blocked", + "$this->hook_root/boot/blocked", function (): void { $this->fired[] = 'blocked'; }, @@ -50,7 +50,7 @@ function (): void { 0 ); add_action( - "$this->prefix/boot/failed", + "$this->hook_root/boot/failed", function ( \Throwable $failure ): void { $this->fired[] = 'failed'; $this->failures[] = $failure; @@ -79,7 +79,7 @@ public function test_a_composition_named_as_a_callable_boots(): void { 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"; + $blocked = "$this->hook_root/boot/blocked"; add_action( 'all', static function ( string $hook_name, mixed ...$args ) use ( &$arguments, $blocked ): void { @@ -140,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, $this->prefix, new ThrowingLogger( $logging_failure ) ); + $boot = new PluginBoot( $this->identity, new ThrowingLogger( $logging_failure ) ); $log_file = (string) \tempnam( \sys_get_temp_dir(), 'acme-log-' ); $previous = \ini_set( 'error_log', $log_file ); @@ -155,13 +155,13 @@ public function test_a_failure_whose_logger_throws_still_reaches_the_error_log_a self::assertSame( 'failed', $boot->status ); self::assertSame( array( 'failed' ), $this->fired ); self::assertSame( array( $failure ), $this->failures ); - self::assertStringContainsString( "[$this->prefix.error] Boot failed; the plugin did not finish starting: {message} {\"message\":\"composition failed\"}", $log ); + self::assertStringContainsString( "[$this->slug.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 ); 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, $this->prefix, $this->logger ); + return new PluginBoot( $this->identity, $this->logger ); } } diff --git a/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php b/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php index 6f327b0f..2b1fe710 100644 --- a/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php +++ b/tests/Integration/Core/Scheduling/SchedulerOnSwitchedSiteTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Core\Scheduling; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Core\Scheduling\ScheduledHook; use DeepWebSolutions\Framework\Core\Scheduling\Scheduler; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesSitesTrait; @@ -111,7 +112,7 @@ public function test_a_switch_to_the_site_that_loaded_the_plugin_still_schedules // The network carries no WooCommerce, so the default backends resolve to WP-Cron alone. protected function make_scheduler(): Scheduler { - $scheduler = new Scheduler( 'acme' ); + $scheduler = new Scheduler( new PluginIdentity( __FILE__, 'acme', 'acme' ) ); $scheduler->register_hooks(); return $scheduler; diff --git a/tests/Integration/Core/Scheduling/SchedulerTest.php b/tests/Integration/Core/Scheduling/SchedulerTest.php index 7caf58a9..6c7bd216 100644 --- a/tests/Integration/Core/Scheduling/SchedulerTest.php +++ b/tests/Integration/Core/Scheduling/SchedulerTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Core\Scheduling; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Core\Scheduling\Backends\ActionSchedulerBackend; use DeepWebSolutions\Framework\Core\Scheduling\Backends\WpCronBackend; use DeepWebSolutions\Framework\Core\Scheduling\Recurrence; @@ -73,43 +74,10 @@ protected function tearDown(): void { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE hook LIKE %s', $wpdb->actionscheduler_actions, $wpdb->esc_like( $this->hook ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Removes the rows the test created. } - public static function valid_groups(): array { - return array( - 'one-letter' => array( 'a' ), - 'hook-prefix' => array( 'dws_tpl' ), - 'mixed-case-hyphen' => array( 'Acme-2_tpl' ), - '190-bytes' => array( \str_repeat( 'a', 190 ) ), - ); - } - - #[DataProvider( 'valid_groups' )] - public function test_a_valid_group_constructs_a_scheduler( string $group ): void { - self::assertInstanceOf( Scheduler::class, new Scheduler( $group ) ); - } - - public static function invalid_groups(): array { - return array( - 'empty' => array( '' ), - 'zero' => array( '0' ), - 'leading-digit' => array( '1acme' ), - 'space' => array( 'acme tpl' ), - 'dot' => array( 'acme.tpl' ), - 'trailing-break' => array( "acme\n" ), - '191-bytes' => array( \str_repeat( 'a', 191 ) ), - ); - } - - #[DataProvider( 'invalid_groups' )] - public function test_an_invalid_group_is_rejected( string $group ): void { - $this->expectException( InvalidArgumentException::class ); - - new Scheduler( $group ); - } - public function test_unscheduling_a_hook_rejects_an_empty_hook_name(): void { $this->expectException( InvalidArgumentException::class ); - new Scheduler( 'acme' )->unschedule_hook( '' ); + new Scheduler( new PluginIdentity( __FILE__, 'acme', self::GROUP ) )->unschedule_hook( '' ); } public static function schedulers(): array { @@ -426,7 +394,7 @@ function () use ( $scheduler, $hook ): void { public function test_a_recurring_wp_cron_write_needs_no_earlier_hook_registration(): void { $this->report_action_scheduler_versions( array( '3.9.0' ) ); - $scheduler = new Scheduler( self::GROUP ); + $scheduler = new Scheduler( new PluginIdentity( __FILE__, 'acme', self::GROUP ) ); self::assertTrue( $scheduler->ensure_recurring( new ScheduledHook( $this->hook ), Recurrence::from_interval( 3900 ) ) ); @@ -832,7 +800,7 @@ protected static function assert_wp_error( mixed $actual, string $code ): void { // Action Scheduler below its floor leaves the writes to WP-Cron. A scheduler reads the version at each call, so the kind holds until the next call here. protected function make_scheduler( string $kind ): Scheduler { 'wp-cron' === $kind ? $this->report_action_scheduler_versions( array( '3.9.0' ) ) : $this->restore_action_scheduler_versions(); - $scheduler = new Scheduler( self::GROUP ); + $scheduler = new Scheduler( new PluginIdentity( __FILE__, 'acme', self::GROUP ) ); $scheduler->register_hooks(); return $scheduler; diff --git a/tests/Integration/Core/Storage/LockTest.php b/tests/Integration/Core/Storage/LockTest.php index d5ca1ff6..7dc6e515 100644 --- a/tests/Integration/Core/Storage/LockTest.php +++ b/tests/Integration/Core/Storage/LockTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Core\Storage; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Core\Storage\Lock; use DeepWebSolutions\Framework\Core\Storage\LockClaim; use DeepWebSolutions\Framework\Core\Storage\OptionRows; @@ -53,7 +54,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->identity, 'lock', 600, $this->clock )->acquire( 'first' ); $this->clock->advance( 61 ); self::assertSame( LockClaim::Busy, $this->make_lock()->acquire( 'second' ) ); @@ -219,7 +220,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->identity, 'lock', 60 )->acquire( 'first' ); $time = $this->stored()['time'] ?? 0; self::assertGreaterThanOrEqual( $before, $time ); @@ -228,18 +229,18 @@ public function test_without_a_clock_the_lock_reads_the_system_time(): void { public static function invalid_calls(): array { return 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( '' ) ), + 'zero-ttl' => array( static fn ( PluginIdentity $identity ): mixed => new Lock( $identity, 'lock', 0 ) ), + 'negative-ttl' => array( static fn ( PluginIdentity $identity ): mixed => new Lock( $identity, 'lock', -60 ) ), + 'long-name' => array( static fn ( PluginIdentity $identity ): mixed => new Lock( $identity, \str_repeat( 'a', 191 - \strlen( $identity->prefix ) ), 60 ) ), + 'empty-owner' => array( static fn ( PluginIdentity $identity ): mixed => new Lock( $identity, 'lock', 60 )->acquire( '' ) ), ); } #[DataProvider( 'invalid_calls' )] - public function test_a_ttl_below_1_an_invalid_name_or_an_empty_owner_is_rejected( \Closure $call ): void { + public function test_a_ttl_below_1_a_name_too_long_for_an_option_or_an_empty_owner_is_rejected( \Closure $call ): void { $this->expectException( InvalidArgumentException::class ); - $call(); + $call( $this->identity ); } public static function failing_operations(): array { @@ -262,7 +263,7 @@ public function test_a_storage_failure_throws( string $needle, \Closure $operati } protected function make_lock(): Lock { - return new Lock( "{$this->prefix}_lock", 60, $this->clock ); + return new Lock( $this->identity, 'lock', 60, $this->clock ); } protected function stored(): mixed { diff --git a/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php b/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php index 210bfbaf..be3f4e7f 100644 --- a/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php +++ b/tests/Integration/Core/Uninstall/UninstallFixturesTrait.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Core\Uninstall; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Tests\Integration\Support\IsolatesStorageTrait; use PHPUnit\Framework\Attributes\After; @@ -16,6 +17,10 @@ trait UninstallFixturesTrait { protected array $id_reads = array(); + protected function uninstalled_plugin(): PluginIdentity { + return new PluginIdentity( self::PLUGIN_FILE, $this->slug, $this->prefix ); + } + protected function footprint(): array { $p = $this->prefix; diff --git a/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php b/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php index 07ace5d0..19b59524 100644 --- a/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php +++ b/tests/Integration/Core/Uninstall/UninstallerOnNetworkTest.php @@ -60,7 +60,7 @@ public function test_a_subsite_only_plugin_deleted_from_the_network_admin_cleans $this->seed_site( $this->main_site, null ); $this->seed_site( $this->other_site, 'yes' ); - Uninstaller::run( self::PLUGIN_FILE, $this->network_footprint(), $this->args() ); + Uninstaller::run( $this->uninstalled_plugin(), $this->network_footprint(), $this->args() ); self::assertSame( array( true, true, false, false ), $this->site_state( $this->main_site ) ); self::assertSame( array( false, false, false, false ), $this->site_state( $this->other_site ) ); @@ -103,7 +103,7 @@ function () use ( $p ): void { try { Uninstaller::run( - self::PLUGIN_FILE, + $this->uninstalled_plugin(), array( 'option_prefixes' => array( "{$p}_cache_" ), 'meta' => array( 'item' => array( "{$p}_flag" ) ), @@ -138,7 +138,7 @@ public function test_the_network_pass_runs_only_on_the_consent_constant( bool $c $this->seed_site( $this->main_site, 'yes' ); $this->seed_site( $this->other_site, 'yes' ); - Uninstaller::run( self::PLUGIN_FILE, $this->network_footprint(), $this->args( $constant ) ); + Uninstaller::run( $this->uninstalled_plugin(), $this->network_footprint(), $this->args( $constant ) ); // The constant consents for every site of the network, which may hold more sites than the test created. $consenting = $constant ? get_sites( @@ -165,7 +165,7 @@ public function test_the_network_pass_reads_a_global_user_meta_key_in_pages_and_ } $this->record_id_reads(); - Uninstaller::run( self::PLUGIN_FILE, $this->network_footprint(), $this->args( true ) ); + Uninstaller::run( $this->uninstalled_plugin(), $this->network_footprint(), $this->args( true ) ); self::assertFalse( $this->user_meta_exists( "{$this->prefix}_global_state" ) ); $this->assert_id_reads_paged(); @@ -183,7 +183,7 @@ public function test_the_network_pass_keeps_a_network_option_that_differs_only_i ) ); - Uninstaller::run( self::PLUGIN_FILE, $this->network_footprint(), $this->args( true ) ); + Uninstaller::run( $this->uninstalled_plugin(), $this->network_footprint(), $this->args( true ) ); self::assertFalse( $this->network_option_exists( "{$this->prefix}_network_settings" ) ); self::assertTrue( $this->network_option_exists( $twin ) ); @@ -194,7 +194,7 @@ public function test_the_network_pass_clears_a_deleted_network_options_cached_va // The network cleanup runs just before the deletes, so it caches the value they must clear. $args['cleanup'] = fn ( array $context ): mixed => get_network_option( null, "{$this->prefix}_network_settings" ); - Uninstaller::run( self::PLUGIN_FILE, $this->network_footprint(), $args ); + Uninstaller::run( $this->uninstalled_plugin(), $this->network_footprint(), $args ); self::assertFalse( get_network_option( null, "{$this->prefix}_network_settings" ) ); } @@ -214,7 +214,7 @@ public function test_a_throw_on_one_site_leaves_the_other_sites_swept_and_is_thr $failure = null; try { - Uninstaller::run( self::PLUGIN_FILE, $this->network_footprint(), $this->args() ); + Uninstaller::run( $this->uninstalled_plugin(), $this->network_footprint(), $this->args() ); } catch ( RuntimeException $exception ) { $failure = $exception; } finally { @@ -236,7 +236,7 @@ public function test_a_web_request_on_a_large_network_refuses_before_any_change_ $failure = null; try { - Uninstaller::run( self::PLUGIN_FILE, $this->network_footprint(), $this->args( true ) ); + Uninstaller::run( $this->uninstalled_plugin(), $this->network_footprint(), $this->args( true ) ); } catch ( RuntimeException $exception ) { $failure = $exception; } @@ -258,7 +258,7 @@ public function test_wp_cli_sweeps_a_large_network(): void { $this->seed_site( $this->main_site, 'yes' ); $this->seed_site( $this->other_site, 'yes' ); - Uninstaller::run( self::PLUGIN_FILE, $this->network_footprint(), $this->args() ); + Uninstaller::run( $this->uninstalled_plugin(), $this->network_footprint(), $this->args() ); self::assertSame( array( false, false, false, false ), $this->site_state( $this->main_site ) ); self::assertSame( array( false, false, false, false ), $this->site_state( $this->other_site ) ); @@ -273,7 +273,7 @@ public function test_memory_stays_flat_across_a_sixty_site_sweep(): void { \gc_collect_cycles(); $before = \memory_get_usage(); - Uninstaller::run( self::PLUGIN_FILE, $this->network_footprint(), $this->args() ); + Uninstaller::run( $this->uninstalled_plugin(), $this->network_footprint(), $this->args() ); \gc_collect_cycles(); $growth = \memory_get_usage() - $before; diff --git a/tests/Integration/Core/Uninstall/UninstallerTest.php b/tests/Integration/Core/Uninstall/UninstallerTest.php index 670bb4e2..9d657332 100644 --- a/tests/Integration/Core/Uninstall/UninstallerTest.php +++ b/tests/Integration/Core/Uninstall/UninstallerTest.php @@ -84,7 +84,7 @@ public function test_a_run_without_consent_keeps_the_footprint_and_runs_no_clean $args = $consent( $this->prefix ) + $this->recording_cleanup(); $consent_stored = $this->option_exists( "{$this->prefix}_remove_data" ); - Uninstaller::run( self::PLUGIN_FILE, $this->footprint(), $args ); + Uninstaller::run( $this->uninstalled_plugin(), $this->footprint(), $args ); $this->assert_footprint_kept(); self::assertSame( array(), $this->calls ); @@ -96,7 +96,7 @@ public function test_a_consenting_run_without_a_current_user_deletes_exactly_the wp_set_current_user( 0 ); // WP-CLI uninstalls a plugin with no current user. $this->seed_footprint(); - Uninstaller::run( self::PLUGIN_FILE, $this->footprint(), $consent( $this->prefix ) ); + Uninstaller::run( $this->uninstalled_plugin(), $this->footprint(), $consent( $this->prefix ) ); $this->assert_footprint_deleted(); $this->assert_foreign_rows_kept(); @@ -110,7 +110,7 @@ public function test_a_key_on_more_rows_than_one_read_takes_is_read_in_pages_and } $this->record_id_reads(); - Uninstaller::run( self::PLUGIN_FILE, array( 'meta' => array( 'post' => array( "{$this->prefix}_flag" ) ) ), self::stored_consent( 'yes' )( $this->prefix ) ); + Uninstaller::run( $this->uninstalled_plugin(), array( 'meta' => array( 'post' => array( "{$this->prefix}_flag" ) ) ), self::stored_consent( 'yes' )( $this->prefix ) ); self::assertFalse( $this->meta_exists( 'post', "{$this->prefix}_flag" ) ); $this->assert_id_reads_paged(); @@ -125,7 +125,7 @@ public function test_a_post_of_an_unregistered_type_is_deleted_with_its_term_rel $post_id = $this->make_post( "{$this->prefix}_type", 'publish' ); wp_set_object_terms( $post_id, array( $this->ids['term'] ), 'category' ); - Uninstaller::run( self::PLUGIN_FILE, array( 'post_types' => array( "{$this->prefix}_type" ) ), self::stored_consent( 'yes' )( $this->prefix ) ); + Uninstaller::run( $this->uninstalled_plugin(), array( 'post_types' => array( "{$this->prefix}_type" ) ), self::stored_consent( 'yes' )( $this->prefix ) ); self::assertFalse( $this->post_exists( $post_id ) ); self::assertSame( '0', $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE object_id = %d', $wpdb->term_relationships, $post_id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads past the term cache. @@ -142,7 +142,7 @@ public function test_a_deleted_posts_id_keeps_its_relationships_in_a_taxonomy_of $post_id = $this->make_post( "{$this->prefix}_type", 'publish' ); wp_set_object_terms( $post_id, array( (int) $term['term_id'] ), $taxonomy ); // Stands for the user that shares the post's id. - Uninstaller::run( self::PLUGIN_FILE, array( 'post_types' => array( "{$this->prefix}_type" ) ), self::stored_consent( 'yes' )( $this->prefix ) ); + Uninstaller::run( $this->uninstalled_plugin(), array( 'post_types' => array( "{$this->prefix}_type" ) ), self::stored_consent( 'yes' )( $this->prefix ) ); self::assertFalse( $this->post_exists( $post_id ) ); self::assertSame( '1', $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE object_id = %d', $wpdb->term_relationships, $post_id ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads past the term cache. @@ -165,7 +165,7 @@ public function test_a_post_whose_delete_wordpress_refuses_keeps_its_term_relati add_filter( 'pre_delete_post', '__return_false' ); try { - Uninstaller::run( self::PLUGIN_FILE, array( 'post_types' => array( "{$this->prefix}_type" ) ), self::stored_consent( 'yes' )( $this->prefix ) ); + Uninstaller::run( $this->uninstalled_plugin(), array( 'post_types' => array( "{$this->prefix}_type" ) ), self::stored_consent( 'yes' )( $this->prefix ) ); self::fail( 'The refused delete was not reported.' ); } catch ( RuntimeException ) { self::assertTrue( $this->post_exists( $post_id ) ); @@ -183,7 +183,7 @@ public function test_the_cleanup_runs_first_while_the_footprint_and_the_consent_ $seen = array(); Uninstaller::run( - self::PLUGIN_FILE, + $this->uninstalled_plugin(), array( 'options' => array( $consent_option ), 'option_prefixes' => array( "{$this->prefix}_cache_" ), @@ -207,7 +207,7 @@ public function test_the_cleanup_runs_once_with_the_site_context(): void { $log = $this->capture_error_log( fn () => Uninstaller::run( - self::PLUGIN_FILE, + $this->uninstalled_plugin(), array( 'options' => array( "{$this->prefix}_settings" ) ), array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) ) + $this->recording_cleanup() ) @@ -227,7 +227,7 @@ public function test_a_kind_keeps_its_non_empty_strings_and_skips_every_other_en update_option( "{$this->prefix}_remove_data", 'yes' ); Uninstaller::run( - self::PLUGIN_FILE, + $this->uninstalled_plugin(), array( 'options' => array( '', '0', 7, null, array( "{$this->prefix}_cache_a" ), "{$this->prefix}_settings" ), 'option_prefixes' => array( '', '0' ), @@ -247,7 +247,7 @@ public function test_a_meta_kind_skips_the_names_of_unknown_meta_types_and_clean update_option( "{$this->prefix}_remove_data", 'yes' ); Uninstaller::run( - self::PLUGIN_FILE, + $this->uninstalled_plugin(), array( 'meta' => array( 'user' => array( "{$this->prefix}_global_state" ), @@ -279,7 +279,7 @@ public function test_a_job_wordpress_refuses_to_unschedule_is_logged_without_hol try { $log = $this->capture_error_log( fn () => Uninstaller::run( - self::PLUGIN_FILE, + $this->uninstalled_plugin(), array( 'cron_hooks' => array( "{$this->prefix}_cron" ) ) + $this->footprint(), array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) ) ) @@ -301,7 +301,7 @@ public function test_cron_hooks_are_unscheduled_with_every_argument_variant_what wp_schedule_single_event( \time() + 3600, "{$this->prefix}_cron", array( 2 ) ); wp_schedule_single_event( \time() + 3600, "{$this->prefix}_foreign_cron" ); - Uninstaller::run( self::PLUGIN_FILE, array( 'cron_hooks' => array( "{$this->prefix}_cron" ) ), array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) ) ); + Uninstaller::run( $this->uninstalled_plugin(), array( 'cron_hooks' => array( "{$this->prefix}_cron" ) ), array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) ) ); self::assertFalse( wp_next_scheduled( "{$this->prefix}_cron", array( 1 ) ) ); self::assertFalse( wp_next_scheduled( "{$this->prefix}_cron", array( 2 ) ) ); @@ -317,7 +317,7 @@ public function test_action_scheduler_actions_are_unscheduled_by_hook_and_by_gro as_schedule_single_action( \time() + 3600, "{$this->prefix}_foreign_as" ); Uninstaller::run( - self::PLUGIN_FILE, + $this->uninstalled_plugin(), array( 'as_hooks' => array( "{$this->prefix}_as" ), 'as_groups' => array( "$this->prefix-group" ), @@ -336,7 +336,7 @@ public function test_an_action_scheduler_hook_or_group_named_0_cancels_no_other_ as_schedule_single_action( \time() + 3600, "{$this->prefix}_grouped", array(), "$this->prefix-group" ); Uninstaller::run( - self::PLUGIN_FILE, + $this->uninstalled_plugin(), array( 'as_hooks' => array( '0' ), 'as_groups' => array( '0' ), @@ -359,7 +359,7 @@ public function test_a_failed_statement_keeps_the_consent_row_and_throws_after_t $failure = null; try { - Uninstaller::run( self::PLUGIN_FILE, $this->footprint(), $args ); + Uninstaller::run( $this->uninstalled_plugin(), $this->footprint(), $args ); } catch ( RuntimeException $exception ) { $failure = $exception; } @@ -373,7 +373,7 @@ public function test_a_failed_statement_keeps_the_consent_row_and_throws_after_t remove_all_filters( 'query' ); $this->restore_query_errors(); - Uninstaller::run( self::PLUGIN_FILE, $this->footprint(), $args ); + Uninstaller::run( $this->uninstalled_plugin(), $this->footprint(), $args ); self::assertFalse( $kept( $this ) ); self::assertFalse( $this->option_exists( $this->prefix . $consent ) ); @@ -389,7 +389,7 @@ public function test_a_delete_wordpress_refuses_is_thrown_after_the_rest_and_a_r $failure = null; try { Uninstaller::run( - self::PLUGIN_FILE, + $this->uninstalled_plugin(), array( 'cron_hooks' => array( "{$this->prefix}_cron" ) ) + $this->footprint(), array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) ) ); @@ -405,7 +405,7 @@ public function test_a_delete_wordpress_refuses_is_thrown_after_the_rest_and_a_r self::assertTrue( $this->option_exists( "{$this->prefix}_remove_data" ) ); Uninstaller::run( - self::PLUGIN_FILE, + $this->uninstalled_plugin(), array( 'cron_hooks' => array( "{$this->prefix}_cron" ) ) + $this->footprint(), array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) ) ); @@ -422,7 +422,7 @@ public function test_a_failing_cleanup_keeps_that_sites_footprint_and_consent_ro $failure = null; try { Uninstaller::run( - self::PLUGIN_FILE, + $this->uninstalled_plugin(), array( 'cron_hooks' => array( "{$this->prefix}_cron" ), 'options' => array( "{$this->prefix}_settings" ), @@ -453,7 +453,7 @@ public function test_a_throw_from_another_plugins_delete_hook_ends_the_sites_pas $failure = null; try { - Uninstaller::run( self::PLUGIN_FILE, $this->footprint(), array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) ) ); + Uninstaller::run( $this->uninstalled_plugin(), $this->footprint(), array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) ) ); } catch ( RuntimeException $exception ) { $failure = $exception; } finally { @@ -466,7 +466,7 @@ public function test_a_throw_from_another_plugins_delete_hook_ends_the_sites_pas self::assertTrue( $this->table_exists( "{$this->prefix}_log" ) ); self::assertTrue( $this->option_exists( "{$this->prefix}_remove_data" ) ); - Uninstaller::run( self::PLUGIN_FILE, $this->footprint(), array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) ) ); + Uninstaller::run( $this->uninstalled_plugin(), $this->footprint(), array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) ) ); $this->assert_footprint_deleted(); self::assertFalse( $this->option_exists( "{$this->prefix}_remove_data" ) ); @@ -483,7 +483,7 @@ public function test_an_action_scheduler_cancel_that_throws_is_logged_without_ho try { $log = $this->capture_error_log( fn () => Uninstaller::run( - self::PLUGIN_FILE, + $this->uninstalled_plugin(), array( 'as_hooks' => array( "{$this->prefix}_as" ) ) + $this->footprint(), array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ) ) ) diff --git a/tests/Integration/Core/Uninstall/UninstallerWithoutActionSchedulerTest.php b/tests/Integration/Core/Uninstall/UninstallerWithoutActionSchedulerTest.php index f9686e05..63f81607 100644 --- a/tests/Integration/Core/Uninstall/UninstallerWithoutActionSchedulerTest.php +++ b/tests/Integration/Core/Uninstall/UninstallerWithoutActionSchedulerTest.php @@ -21,7 +21,7 @@ public function test_a_consenting_run_without_action_scheduler_completes(): void wp_schedule_single_event( \time() + 3600, "{$this->prefix}_cron" ); Uninstaller::run( - self::PLUGIN_FILE, + $this->uninstalled_plugin(), array( 'cron_hooks' => array( "{$this->prefix}_cron" ), 'as_hooks' => array( "{$this->prefix}_as" ), diff --git a/tests/Integration/Settings/AdminPage/OptionsPageTest.php b/tests/Integration/Settings/AdminPage/OptionsPageTest.php index 53ed702f..25bade36 100644 --- a/tests/Integration/Settings/AdminPage/OptionsPageTest.php +++ b/tests/Integration/Settings/AdminPage/OptionsPageTest.php @@ -81,19 +81,19 @@ public static function relocated_option_pages(): array { public function test_an_invalid_id_is_rejected( string $id ): void { $this->expectException( InvalidArgumentException::class ); - new OptionsPage( $id, new RecordedDefinition( static fn (): array => array() ) ); + new OptionsPage( $this->identity, $id, new RecordedDefinition( static fn (): array => array() ) ); } #[DataProvider( 'v1_helper_ids' )] public function test_every_v1_key_is_a_valid_id( string $id ): void { $this->expectNotToPerformAssertions(); - new OptionsPage( $id, new RecordedDefinition( static fn (): array => array() ) ); + new OptionsPage( $this->identity, $id, new RecordedDefinition( static fn (): array => array() ) ); } public function test_the_definition_is_evaluated_neither_at_construction_nor_at_registration(): void { $definition = new RecordedDefinition( static fn (): array => array() ); - $page = new OptionsPage( $this->id, $definition ); + $page = new OptionsPage( $this->identity, $this->id, $definition ); $page->register_hooks(); @@ -110,7 +110,7 @@ public function test_the_definition_receives_the_pages_id_once(): void { 'tabs' => array(), ) ); - $page = new OptionsPage( $this->id, $definition ); + $page = new OptionsPage( $this->identity, $this->id, $definition ); $page->get_capability(); $page->get_capability(); @@ -622,6 +622,7 @@ public function test_saving_the_second_of_two_tabs_on_one_option_keeps_the_first protected function spec( array $fields, array $page = array(), array $extra = array() ): array { return array( 'id' => $this->id, + 'slug' => $this->slug, 'page' => $page + array( 'parent' => 'options-general.php', 'capability' => 'manage_options', diff --git a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php index d1370e8d..09e05c00 100644 --- a/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php +++ b/tests/Integration/Settings/EditScreen/Hosts/PostMetaBoxTest.php @@ -4,6 +4,7 @@ use DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; use DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Settings\EditScreen\Hosts\PostMetaBox; use DeepWebSolutions\Framework\Settings\EditScreen\ObjectForm; use DeepWebSolutions\Framework\Shared\Exception\InvalidArgumentException; @@ -70,19 +71,19 @@ public static function heartbeat_refreshes(): array { public function test_an_invalid_form_id_is_rejected( string $id ): void { $this->expectException( InvalidArgumentException::class ); - new ObjectForm( $id, new RecordedDefinition( static fn (): array => array() ), new PostMetaBox( array( 'post' ) ), new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) ); + new ObjectForm( $this->identity, $id, new RecordedDefinition( static fn (): array => array() ), new PostMetaBox( array( 'post' ) ), new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) ); } #[DataProvider( 'v1_helper_ids' )] public function test_every_v1_key_is_a_valid_form_id( string $id ): void { - $form = new ObjectForm( $id, new RecordedDefinition( static fn (): array => array() ), new PostMetaBox( array( 'post' ) ), new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) ); + $form = new ObjectForm( $this->identity, $id, new RecordedDefinition( static fn (): array => array() ), new PostMetaBox( array( 'post' ) ), new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) ); self::assertSame( $id, $form->get_id() ); } public function test_the_definition_is_evaluated_only_when_a_screen_needs_it(): void { $definition = new RecordedDefinition( static fn (): array => array() ); - $form = new ObjectForm( $this->id, $definition, new PostMetaBox( array( 'post' ) ), new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) ); + $form = new ObjectForm( $this->identity, $this->id, $definition, new PostMetaBox( array( 'post' ) ), new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) ); $form->register_hooks(); $post_id = $this->make_post(); $spec = $this->spec( array( $this->field( 'text', 'name' ) ) ); @@ -230,7 +231,7 @@ public function test_per_object_fields_receive_the_post_and_contributed_fields_r $box = $this->box( self::document( $request['body'] ) ); self::assertContains( array( 'fields', $post_id ), $request['records'] ); - self::assertContains( array( 'contributed', $post_id ), $request['records'] ); + self::assertContains( array( 'contributed', $post_id, $this->id ), $request['records'] ); $this->control( $box, 'name' ); $this->control( $box, 'extra' ); } @@ -531,6 +532,7 @@ public function test_bulk_edit_stores_nothing(): void { public function test_a_save_without_the_forms_nonce_stores_nothing(): void { $post_id = $this->make_post( array( "_{$this->prefix}_name" => 'kept' ) ); $form = new ObjectForm( + $this->identity, $this->id, new RecordedDefinition( fn (): array => array( @@ -540,7 +542,7 @@ public function test_a_save_without_the_forms_nonce_stores_nothing(): void { ) ), new PostMetaBox( array( 'post' ) ), - new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) + new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) ); $form->register_hooks(); wp_set_current_user( $this->admin ); @@ -555,7 +557,6 @@ public function test_a_save_without_the_forms_nonce_stores_nothing(): void { ); } finally { unset( $_POST[ $this->id ] ); - wp_set_current_user( 0 ); } self::assertSame( 'Updated in code', get_the_title( $post_id ) ); @@ -676,7 +677,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( new PluginIdentity( $this->identity->file, $this->slug, $this->id ), new ErrorLogLogger( $this->identity ) )->rest_deliver(); $list = $this->admin_request( 'GET', 'edit.php', $this->admin, self::FIXTURE, $spec ); self::assertSame( array( 'kept' ), $this->meta( $post_id, 'note' ) ); @@ -726,6 +727,7 @@ public function test_only_the_block_editor_prints_the_script_that_refreshes_the_ protected function spec( array $fields, array $form = array(), array $extra = array() ): array { return $extra + array( 'id' => $this->id, + 'slug' => $this->slug, 'host' => 'post', 'host_args' => array( 'post_types' => array( 'post' ) ), 'editor' => 'classic', diff --git a/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php b/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php index 13aa3a7f..286d2175 100644 --- a/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php +++ b/tests/Integration/Settings/EditScreen/Hosts/TermFormTest.php @@ -37,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->identity, $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) )->register_hooks(); foreach ( array( 'category', 'post_tag' ) as $taxonomy ) { self::assertSame( 10, has_action( "{$taxonomy}_edit_form", array( $host, 'render_section' ) ) ); @@ -139,6 +139,7 @@ public function test_an_update_in_code_stores_nothing(): void { $term_id = $this->make_term( 'category' ); add_term_meta( $term_id, "_{$this->prefix}_name", 'kept' ); $form = new ObjectForm( + $this->identity, $this->id, new RecordedDefinition( fn (): array => array( @@ -148,7 +149,7 @@ public function test_an_update_in_code_stores_nothing(): void { ) ), new TermForm( array( 'category' ) ), - new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) + new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) ); $form->register_hooks(); wp_set_current_user( $this->admin ); @@ -158,7 +159,6 @@ public function test_an_update_in_code_stores_nothing(): void { 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 ); @@ -168,6 +168,7 @@ public function test_an_update_in_code_stores_nothing(): void { protected function spec( array $fields, array $form = array() ): array { return array( 'id' => $this->id, + 'slug' => $this->slug, 'host' => 'term', 'host_args' => array( 'taxonomies' => array( 'category' ) ), 'form' => $form + array( diff --git a/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php b/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php index 6d699d68..7b40434f 100644 --- a/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php +++ b/tests/Integration/Settings/EditScreen/Hosts/UserProfileTest.php @@ -51,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->identity, $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) )->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' ) ) ); @@ -192,6 +192,7 @@ public function test_a_user_editing_themself_without_the_forms_capability_gets_n public function test_an_update_in_code_stores_nothing(): void { add_user_meta( $this->subscriber, "_{$this->prefix}_name", 'kept' ); $form = new ObjectForm( + $this->identity, $this->id, new RecordedDefinition( fn (): array => array( @@ -201,7 +202,7 @@ public function test_an_update_in_code_stores_nothing(): void { ) ), new UserProfile(), - new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) + new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) ); $form->register_hooks(); wp_set_current_user( $this->admin ); @@ -216,7 +217,6 @@ public function test_an_update_in_code_stores_nothing(): void { ); } finally { unset( $_POST[ $this->id ] ); - wp_set_current_user( 0 ); } self::assertSame( 'Updated in code', get_userdata( $this->subscriber )->display_name ); @@ -238,6 +238,7 @@ protected function make_profile_user( string $login, string $role ): int { protected function spec( array $fields, array $form = array() ): array { return array( 'id' => $this->id, + 'slug' => $this->slug, 'host' => 'user', 'host_args' => array(), 'form' => $form + array( diff --git a/tests/Integration/Settings/EditScreen/ObjectFormTest.php b/tests/Integration/Settings/EditScreen/ObjectFormTest.php index 9a9e1c39..efe5a1b6 100644 --- a/tests/Integration/Settings/EditScreen/ObjectFormTest.php +++ b/tests/Integration/Settings/EditScreen/ObjectFormTest.php @@ -52,7 +52,6 @@ protected function setUp(): void { protected function tearDown(): void { $_POST = $this->post_backup; wp_delete_post( $this->post_id, true ); - wp_set_current_user( 0 ); } public static function foreign_nonces(): array { @@ -158,7 +157,7 @@ public function test_a_save_without_the_forms_nonce_stores_nothing_and_evaluates public function test_each_rejection_becomes_a_one_shot_error_notice_for_the_user_who_saved(): void { wp_set_current_user( $this->author_id ); $this->post_form( $this->post_id ); - $notices = new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ); + $notices = new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ); $this->rejecting_form( $notices )->save( $this->post_id ); @@ -172,7 +171,7 @@ public function test_each_rejection_becomes_a_one_shot_error_notice_for_the_user public function test_a_second_rejection_of_a_field_replaces_the_first(): void { wp_set_current_user( $this->author_id ); - $notices = new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ); + $notices = new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ); $this->post_form( $this->post_id ); $this->rejecting_form( $notices )->save( $this->post_id ); @@ -193,7 +192,7 @@ public function test_a_rejection_whose_notice_cannot_be_stored_is_logged_and_kee $previous = \ini_set( 'error_log', $log_file ); try { - $this->rejecting_form( new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) )->save( $this->post_id ); + $this->rejecting_form( new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) )->save( $this->post_id ); $log = (string) \file_get_contents( $log_file ); } finally { \ini_set( 'error_log', (string) $previous ); @@ -201,7 +200,7 @@ public function test_a_rejection_whose_notice_cannot_be_stored_is_logged_and_kee } self::assertSame( 'Saved.', get_post_meta( $this->post_id, "_{$this->prefix}_name", true ) ); - self::assertStringContainsString( '[acme.error] Could not add a user notice: The user meta is unavailable.', $log ); + self::assertStringContainsString( "[$this->slug.error] Could not add a user notice: The user meta is unavailable.", $log ); } public function test_a_host_serves_one_form(): void { @@ -210,7 +209,7 @@ 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->identity, "{$this->prefix}_other", new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) )->register_hooks(); } public function test_a_host_the_consumer_writes_serves_one_form_too(): void { @@ -237,7 +236,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->identity, "{$this->prefix}_other", new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) )->register_hooks(); } protected function form( ObjectFormHostInterface $host, string $capability = 'edit_posts' ): ObjectForm { @@ -255,10 +254,11 @@ protected function form( ObjectFormHostInterface $host, string $capability = 'ed ); return new ObjectForm( + $this->identity, "{$this->prefix}_details", $this->definition, $host, - new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) + new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) ); } @@ -266,6 +266,7 @@ protected function rejecting_form( AdminNotices $notices, string $title = 'Note $_POST[ "{$this->prefix}_details" ][ "_{$this->prefix}_note" ] = 'Too long.'; return new ObjectForm( + $this->identity, "{$this->prefix}_details", new RecordedDefinition( fn (): array => array( diff --git a/tests/Integration/Support/CreatesUsersTrait.php b/tests/Integration/Support/CreatesUsersTrait.php index fba38262..f19771d9 100644 --- a/tests/Integration/Support/CreatesUsersTrait.php +++ b/tests/Integration/Support/CreatesUsersTrait.php @@ -2,6 +2,8 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Support; +use PHPUnit\Framework\Attributes\After; + trait CreatesUsersTrait { protected function make_user( string $login, string $role ): int { $existing = get_user_by( 'login', $login ); @@ -21,4 +23,10 @@ protected function make_user( string $login, string $role ): int { return $user_id; } + + // Rows run in random order, so a row that logged a user in must not leave them logged in for the next one. + #[After] + protected function log_out(): void { + wp_set_current_user( 0 ); + } } diff --git a/tests/Integration/Support/IsolatesStorageTrait.php b/tests/Integration/Support/IsolatesStorageTrait.php index 7721952f..0beb9e23 100644 --- a/tests/Integration/Support/IsolatesStorageTrait.php +++ b/tests/Integration/Support/IsolatesStorageTrait.php @@ -2,15 +2,27 @@ namespace DeepWebSolutions\Framework\Tests\Integration\Support; +use DeepWebSolutions\Framework\Core\PluginIdentity; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; trait IsolatesStorageTrait { protected string $prefix; + protected string $slug; + + protected string $hook_root; + + protected PluginIdentity $identity; + + // The slug differs from the prefix, so a row tells a hook or REST name from a storage key. #[Before] protected function use_a_fresh_prefix(): void { - $this->prefix = 'acme_' . \bin2hex( \random_bytes( 3 ) ); + $suffix = \bin2hex( \random_bytes( 3 ) ); + $this->prefix = "acme_$suffix"; + $this->slug = "acme-plugin-$suffix"; + $this->hook_root = "acme_plugin_$suffix"; + $this->identity = new PluginIdentity( \dirname( __DIR__, 2 ) . '/Fixtures/Core/acme-boot.php', $this->slug, $this->prefix ); } #[After] diff --git a/tests/Integration/Support/consumer-request.php b/tests/Integration/Support/consumer-request.php index dcba6a50..c2937d8d 100644 --- a/tests/Integration/Support/consumer-request.php +++ b/tests/Integration/Support/consumer-request.php @@ -44,16 +44,29 @@ static function ( int $level, string $message, string $file, int $line ) use ( $ } if ( array_key_exists( 'relaxation', $spec ) ) { - add_filter( 'acme_ca/exports/should_restrict', static fn (): mixed => $spec['relaxation'] ); + add_filter( 'acme_consumer_a/exports/should_restrict', static fn (): mixed => $spec['relaxation'] ); } +// Consumer-a's safeguard is a static closure, so it is found by the class that declares it rather than by name. +$acme_safeguard_priority = static function (): int|false { + foreach ( $GLOBALS['wp_filter']['map_meta_cap']->callbacks ?? array() as $priority => $callbacks ) { + foreach ( $callbacks as $callback ) { + if ( $callback['function'] instanceof Closure && 'Acme\\ConsumerA\\Safeguards' === ( new ReflectionFunction( $callback['function'] ) )->getClosureScopeClass()?->getName() ) { + return $priority; + } + } + } + + return false; +}; + $acme_record_boots = static function (): void { $consumers = array( - 'consumer_a' => 'acme_ca', - 'consumer_b' => 'acme_cb', - 'persona_core' => 'acme_pc', + 'consumer_a' => array( 'acme_ca', 'acme_consumer_a' ), + 'consumer_b' => array( 'acme_cb', 'acme_consumer_b' ), + 'persona_core' => array( 'acme_pc', 'acme_persona_core' ), ); - foreach ( $consumers as $key => $prefix ) { + foreach ( $consumers as $key => list( $prefix, $hook_root ) ) { try { $plugin = function_exists( "{$prefix}_plugin" ) ? ( "{$prefix}_plugin" )() : null; } catch ( Error ) { // The composition root's class file may be missing. @@ -62,7 +75,7 @@ static function ( int $level, string $message, string $file, int $line ) use ( $ $GLOBALS['acme_outcome']['records'][ $key ] = array( 'status' => $plugin?->boundary->status, 'skipped' => $plugin?->kernel?->skipped, - 'boot_succeeded' => did_action( "$prefix/boot/succeeded" ), + 'boot_succeeded' => did_action( "$hook_root/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, ); @@ -100,7 +113,7 @@ static function () use ( $acme_capability ): void { add_action( 'admin_init', - static function () use ( $spec, $acme_capability, $acme_record_boots ): void { + static function () use ( $spec, $acme_capability, $acme_record_boots, $acme_safeguard_priority ): void { $records = &$GLOBALS['acme_outcome']['records']; if ( isset( $spec['uninstall'] ) ) { $records['deleted'] = delete_plugins( array( $spec['uninstall'] ) ); @@ -115,12 +128,12 @@ static function () use ( $spec, $acme_capability, $acme_record_boots ): void { $records += array( 'woocommerce_present' => class_exists( 'WooCommerce', false ), 'can_export' => current_user_can( $acme_capability ), - 'safeguard_attached' => has_filter( 'map_meta_cap', 'acme_ca_map_export_capability' ), + 'safeguard_attached' => $acme_safeguard_priority(), 'export_hooked' => has_action( 'admin_post_acme_ca_export' ), 'reports_page_class' => class_exists( 'Acme\ConsumerA\Reports\ReportsPage', false ), 'updater_hooked' => has_filter( 'update_plugins_github.com' ), 'schedules_hooked' => has_filter( 'cron_schedules' ), - 'safeguards_loaded' => function_exists( 'acme_ca_is_relaxed' ), + 'safeguards_loaded' => class_exists( 'Acme\\ConsumerA\\Safeguards', false ), 'unloaded_include' => function_exists( 'acme_ca_unloaded_include' ), 'greeting_shortcode' => shortcode_exists( 'acme_pc_greeting' ), ); @@ -139,8 +152,8 @@ static function (): never { $records['export_error'] = $exception->getMessage(); } $records['can_export_after_throw'] = current_user_can( $acme_capability ); - $records['relaxation_left'] = has_filter( 'acme_ca/exports/should_restrict', '__return_false' ); - $records['safeguard_attached_after_throw'] = has_filter( 'map_meta_cap', 'acme_ca_map_export_capability' ); + $records['relaxation_left'] = has_filter( 'acme_consumer_a/exports/should_restrict', '__return_false' ); + $records['safeguard_attached_after_throw'] = $acme_safeguard_priority(); } }, PHP_INT_MAX diff --git a/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php b/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php index af32e739..7440c0dd 100644 --- a/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php +++ b/tests/Integration/WooCommerce/EditScreen/Hosts/OrderMetaBoxTest.php @@ -55,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->identity, $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) )->register_hooks(); self::assertSame( 10, has_action( 'add_meta_boxes', array( $host, 'add_meta_box' ) ) ); } @@ -195,14 +195,14 @@ public function test_per_object_fields_receive_the_order_and_contributed_fields_ $box = $this->box( self::document( $request['body'] ) ); self::assertContains( array( 'fields', $order->get_id() ), $request['records'] ); - self::assertContains( array( 'contributed', $order->get_id() ), $request['records'] ); + self::assertContains( array( 'contributed', $order->get_id(), $this->id ), $request['records'] ); $this->control( $box, 'name' ); $this->control( $box, 'extra' ); } 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->identity, $this->id, new RecordedDefinition( static fn (): array => array() ), $host, new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) )->register_hooks(); self::assertSame( 35, has_action( 'woocommerce_process_shop_order_meta', array( $host, 'save_meta_box' ) ) ); } @@ -328,6 +328,7 @@ public function test_a_user_without_the_forms_capability_stores_nothing(): void public function test_an_order_saved_in_code_stores_nothing(): void { $order = $this->make_order( array( "_{$this->prefix}_name" => 'kept' ) ); $form = new ObjectForm( + $this->identity, $this->id, new RecordedDefinition( fn (): array => array( @@ -337,7 +338,7 @@ public function test_an_order_saved_in_code_stores_nothing(): void { ) ), new OrderMetaBox(), - new AdminNotices( $this->prefix, new ErrorLogLogger( 'acme' ) ) + new AdminNotices( $this->identity, new ErrorLogLogger( $this->identity ) ) ); $form->register_hooks(); wp_set_current_user( $this->admin ); @@ -349,7 +350,6 @@ public function test_an_order_saved_in_code_stores_nothing(): void { $order->save(); } finally { unset( $_POST[ "{$this->id}_nonce" ], $_POST[ $this->id ] ); - wp_set_current_user( 0 ); } self::assertSame( 'Updated in code', wc_get_order( $order->get_id() )->get_customer_note() ); @@ -400,6 +400,7 @@ public function test_on_the_posts_table_a_rejection_shows_after_the_redirect(): protected function spec( array $fields, array $form = array(), array $extra = array() ): array { return $extra + array( 'id' => $this->id, + 'slug' => $this->slug, 'host' => 'order', 'host_args' => array( 'order_types' => array( 'shop_order' ) ), 'form' => $form + array( diff --git a/tests/Integration/WooCommerce/Logging/WooCommerceLoggerTest.php b/tests/Integration/WooCommerce/Logging/WooCommerceLoggerTest.php index 63d373bc..c8547c6f 100644 --- a/tests/Integration/WooCommerce/Logging/WooCommerceLoggerTest.php +++ b/tests/Integration/WooCommerce/Logging/WooCommerceLoggerTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\Framework\Tests\Integration\WooCommerce\Logging; use Automattic\WooCommerce\Internal\Admin\Logging\LogHandlerFileV2; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Tests\Integration\Support\ProvidesLogMessagesTrait; use DeepWebSolutions\Framework\WooCommerce\Logging\WooCommerceLogger; use PHPUnit\Framework\Attributes\CoversClass; @@ -100,7 +101,7 @@ public function test_a_threshold_psr_3_does_not_define_is_rejected(): void { $this->expectException( PsrInvalidArgumentException::class ); $this->expectExceptionMessage( "Log level 'verbose' is not a PSR-3 level." ); - new WooCommerceLogger( 'acme', 'verbose' ); + new WooCommerceLogger( new PluginIdentity( __FILE__, 'acme', 'acme' ), 'verbose' ); } public function test_a_level_psr_3_does_not_define_is_rejected(): void { @@ -110,7 +111,7 @@ public function test_a_level_psr_3_does_not_define_is_rejected(): void { $this->logger()->log( 'verbose', 'Cache is cold.' ); } - public function test_every_record_carries_the_constructors_source_even_when_the_context_names_its_own(): void { + public function test_every_record_carries_the_plugins_slug_as_its_source_even_when_the_context_names_its_own(): void { $this->logger( null, 'dws-x' )->error( 'Cache is cold.', array( 'source' => 'other' ) ); self::assertSame( 'dws-x', $this->handler->records[0][2]['source'] ); @@ -229,7 +230,7 @@ public function test_a_record_below_error_never_reaches_the_error_log(): void { // WooCommerce's own logger applies its logging switch and threshold, unless the test pins the threshold to keep every record. protected function logger( ?string $threshold = null, string $source = 'acme', bool $keep_everything = true ): WooCommerceLogger { - return new WooCommerceLogger( $source, $threshold, new \WC_Logger( array( $this->handler ), $keep_everything ? PsrLogLevel::DEBUG : null ) ); + return new WooCommerceLogger( new PluginIdentity( __FILE__, $source, 'acme' ), $threshold, new \WC_Logger( array( $this->handler ), $keep_everything ? PsrLogLevel::DEBUG : null ) ); } protected function messages(): array { diff --git a/tests/Integration/WooCommerce/Logging/WooCommerceLoggerWithoutWooCommerceTest.php b/tests/Integration/WooCommerce/Logging/WooCommerceLoggerWithoutWooCommerceTest.php index a66fe9b4..767a9951 100644 --- a/tests/Integration/WooCommerce/Logging/WooCommerceLoggerWithoutWooCommerceTest.php +++ b/tests/Integration/WooCommerce/Logging/WooCommerceLoggerWithoutWooCommerceTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Framework\Tests\Integration\WooCommerce\Logging; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\WooCommerce\Logging\WooCommerceLogger; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; @@ -28,7 +29,7 @@ protected function tearDown(): void { public function test_without_woocommerce_errors_reach_the_error_log_and_lower_levels_go_nowhere(): void { self::assertFalse( \function_exists( 'wc_get_logger' ) ); - $logger = new WooCommerceLogger( 'acme' ); + $logger = new WooCommerceLogger( new PluginIdentity( __FILE__, 'acme', 'acme' ) ); $logger->warning( 'Cache is cold.' ); $logger->error( 'Payment {id} failed.', array( 'id' => 7 ) ); diff --git a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php index 76f73d51..1fe2d99a 100644 --- a/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php +++ b/tests/Integration/WooCommerce/ProductData/ProductDataTabTest.php @@ -66,19 +66,19 @@ public static function multi_value_selections(): array { public function test_an_invalid_tab_id_is_rejected( string $id ): void { $this->expectException( InvalidArgumentException::class ); - new ProductDataTab( $id, new RecordedDefinition( static fn (): ?array => null ) ); + new ProductDataTab( $this->identity, $id, new RecordedDefinition( static fn (): ?array => null ) ); } #[DataProvider( 'v1_helper_ids' )] public function test_every_v1_key_is_a_valid_tab_id( string $id ): void { $this->expectNotToPerformAssertions(); - new ProductDataTab( $id, new RecordedDefinition( static fn (): ?array => null ) ); + new ProductDataTab( $this->identity, $id, new RecordedDefinition( static fn (): ?array => null ) ); } public function test_the_definition_is_evaluated_only_when_the_edit_screen_needs_it(): void { $definition = new RecordedDefinition( static fn (): ?array => null ); - $tab = new ProductDataTab( $this->prefix, $definition ); + $tab = new ProductDataTab( $this->identity, $this->prefix, $definition ); $tab->register_hooks(); @@ -443,7 +443,7 @@ public function test_the_definition_receives_the_tabs_id_and_each_product_once() 'groups' => array(), ) ); - $tab = new ProductDataTab( $this->prefix, $definition ); + $tab = new ProductDataTab( $this->identity, $this->prefix, $definition ); $GLOBALS['product_object'] = $product; try { @@ -488,7 +488,7 @@ public function test_a_group_contributed_through_the_groups_filter_renders_for_t $request = $this->edit_screen( $spec, $product->get_id() ); self::assertSame( 'contributed', $this->control( self::document( $request['body'] ), 'extra' )->getAttribute( 'value' ) ); - self::assertSame( array( array( 'groups', $product->get_id() ) ), $request['records'] ); + self::assertSame( array( array( 'groups', $product->get_id(), $this->prefix ) ), $request['records'] ); } public function test_a_new_product_renders_the_tab_with_its_defaults(): void { @@ -511,7 +511,7 @@ public function test_labels_and_values_are_escaped(): void { } public function test_registering_hooks_the_save_onto_the_product_woocommerce_saves(): void { - $tab = new ProductDataTab( $this->prefix, new RecordedDefinition( static fn (): ?array => null ) ); + $tab = new ProductDataTab( $this->identity, $this->prefix, new RecordedDefinition( static fn (): ?array => null ) ); $tab->register_hooks(); @@ -875,7 +875,7 @@ public function test_a_group_contributed_through_the_groups_filter_saves_for_the self::assertSame( array( 'v!' ), $this->meta( $product, 'extra' ) ); self::assertSame( array( - array( 'groups', $product->get_id() ), + array( 'groups', $product->get_id(), $this->prefix ), array( 'validate', "_{$this->prefix}_extra", 'v' ), ), $saved['records'] @@ -905,6 +905,7 @@ public function test_a_new_product_saves_its_fields_when_published(): void { protected function spec( array $groups, array $extra = array() ): array { return array( 'id' => $this->prefix, + 'slug' => $this->slug, 'label' => 'Acme', 'groups' => $groups, ) + $extra; diff --git a/tests/Integration/WooCommerce/Setting/SettingsTest.php b/tests/Integration/WooCommerce/Setting/SettingsTest.php index 9a801101..45033b57 100644 --- a/tests/Integration/WooCommerce/Setting/SettingsTest.php +++ b/tests/Integration/WooCommerce/Setting/SettingsTest.php @@ -56,7 +56,6 @@ protected function tearDown(): void { SettingsSectionRegistry::get_instance()->unregister_all(); $this->reset_settings_pages(); $GLOBALS['wp_rest_server'] = null; - wp_set_current_user( 0 ); } public static function silent_and_worded_rejections(): array { diff --git a/tests/Integration/WooCommerce/Uninstall/MetaTypesTest.php b/tests/Integration/WooCommerce/Uninstall/MetaTypesTest.php index da0d179f..e128cad8 100644 --- a/tests/Integration/WooCommerce/Uninstall/MetaTypesTest.php +++ b/tests/Integration/WooCommerce/Uninstall/MetaTypesTest.php @@ -90,7 +90,7 @@ protected function uninstall( array $footprint ): void { update_option( "{$this->prefix}_remove_data", 'yes' ); Uninstaller::run( - self::PLUGIN_FILE, + $this->uninstalled_plugin(), $footprint, array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ), diff --git a/tests/Integration/WooCommerce/Uninstall/MetaTypesWithoutWooCommerceTest.php b/tests/Integration/WooCommerce/Uninstall/MetaTypesWithoutWooCommerceTest.php index b13c46b7..689e9a34 100644 --- a/tests/Integration/WooCommerce/Uninstall/MetaTypesWithoutWooCommerceTest.php +++ b/tests/Integration/WooCommerce/Uninstall/MetaTypesWithoutWooCommerceTest.php @@ -22,7 +22,7 @@ public function test_on_a_site_without_woocommerces_tables_a_consenting_uninstal update_option( "{$this->prefix}_remove_data", 'yes' ); Uninstaller::run( - self::PLUGIN_FILE, + $this->uninstalled_plugin(), $this->order_footprint(), array( 'consent' => array( 'option' => "{$this->prefix}_remove_data" ), diff --git a/tests/Integration/WooCommerce/WooCommerceRequirementTest.php b/tests/Integration/WooCommerce/WooCommerceRequirementTest.php index 634e1d5c..5073225c 100644 --- a/tests/Integration/WooCommerce/WooCommerceRequirementTest.php +++ b/tests/Integration/WooCommerce/WooCommerceRequirementTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Framework\Tests\Integration\WooCommerce; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; use DeepWebSolutions\Framework\WooCommerce\WooCommerceRequirement; use PHPUnit\Framework\Attributes\CoversClass; @@ -26,7 +27,7 @@ public static function met_floors(): array { 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 ); - self::assertTrue( WooCommerceRequirement::check( $this->write_plugin( $headers + array( 'Requires Plugins' => 'woocommerce' ) ) ) ); + self::assertTrue( WooCommerceRequirement::check( new PluginIdentity( $this->write_plugin( $headers + array( 'Requires Plugins' => 'woocommerce' ) ), 'acme-shop', 'acme' ) ) ); self::assertSame( '', $this->render_all_admin_notices( $this->make_user( 'acme_requirement_admin', 'administrator' ) ) ); } @@ -38,7 +39,7 @@ public function test_woocommerce_below_the_floor_fails_and_only_users_who_can_ac ) ); - self::assertFalse( WooCommerceRequirement::check( $plugin_file ) ); + self::assertFalse( WooCommerceRequirement::check( new PluginIdentity( $plugin_file, 'acme-shop', 'acme' ) ) ); $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 ); @@ -53,7 +54,7 @@ public function test_the_notice_prints_the_plugins_name_and_floor_as_text(): voi 'WC requires at least' => '99.1', ) ); - WooCommerceRequirement::check( $plugin_file ); + WooCommerceRequirement::check( new PluginIdentity( $plugin_file, 'acme-shop', 'acme' ) ); $notice = \Dom\HTMLDocument::createFromString( $this->render_all_admin_notices( $this->make_user( 'acme_requirement_admin', 'administrator' ) ), \LIBXML_NOERROR )->querySelector( '.notice.notice-error' ); @@ -75,7 +76,7 @@ static function ( string $translation ) use ( &$translations ): string { } ); - WooCommerceRequirement::check( $plugin_file ); + WooCommerceRequirement::check( new PluginIdentity( $plugin_file, 'acme-shop', 'acme' ) ); self::assertSame( 0, $translations ); $this->render_all_admin_notices( $this->make_user( 'acme_requirement_admin', 'administrator' ) ); diff --git a/tests/Integration/WooCommerce/WooCommerceRequirementWithoutWooCommerceTest.php b/tests/Integration/WooCommerce/WooCommerceRequirementWithoutWooCommerceTest.php index 9c808b64..ed8cce8a 100644 --- a/tests/Integration/WooCommerce/WooCommerceRequirementWithoutWooCommerceTest.php +++ b/tests/Integration/WooCommerce/WooCommerceRequirementWithoutWooCommerceTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\Framework\Tests\Integration\WooCommerce; +use DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\Framework\Tests\Integration\Support\CreatesUsersTrait; use DeepWebSolutions\Framework\WooCommerce\WooCommerceRequirement; use PHPUnit\Framework\Attributes\CoversClass; @@ -23,7 +24,7 @@ public function test_a_missing_woocommerce_fails_and_only_users_who_can_activate ) ); - self::assertFalse( WooCommerceRequirement::check( $plugin_file ) ); + self::assertFalse( WooCommerceRequirement::check( new PluginIdentity( $plugin_file, 'acme-shop', 'acme' ) ) ); // 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' ); diff --git a/tests/Php74/Support/php74-request.php b/tests/Php74/Support/php74-request.php index 70ea6317..35aa5058 100644 --- a/tests/Php74/Support/php74-request.php +++ b/tests/Php74/Support/php74-request.php @@ -9,7 +9,7 @@ wp_set_current_user( 1 ); set_current_screen( 'plugins' ); -add_filter( 'acme_ca/exports/should_restrict', '__return_false' ); +add_filter( 'acme_consumer_a/exports/should_restrict', '__return_false' ); ob_start(); do_action( 'admin_notices' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Fires WordPress's own action. diff --git a/tests/Unit/Core/Logging/ErrorLogLoggerTest.php b/tests/Unit/Core/Logging/ErrorLogLoggerTest.php index d91f99ae..8c27017b 100644 --- a/tests/Unit/Core/Logging/ErrorLogLoggerTest.php +++ b/tests/Unit/Core/Logging/ErrorLogLoggerTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\Framework\Tests\Unit\Core\Logging; use DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger; +use DeepWebSolutions\Framework\Core\PluginIdentity; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -14,7 +15,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( new PluginIdentity( __FILE__, 'acme', 'acme' ) )->info( 'Cache is cold.' ); $log = \file_get_contents( $log_file ); } finally { \ini_set( 'error_log', (string) $previous ); diff --git a/tests/Unit/Core/PluginIdentityTest.php b/tests/Unit/Core/PluginIdentityTest.php new file mode 100644 index 00000000..e559485e --- /dev/null +++ b/tests/Unit/Core/PluginIdentityTest.php @@ -0,0 +1,40 @@ +hook_root ); + } + + public static function invalid_prefixes(): array { + return array( + 'empty' => array( '' ), + 'uppercase' => array( 'Acme' ), + 'leading-digit' => array( '1acme' ), + 'hyphen' => array( 'ac-me' ), + 'trailing-newline' => array( "acme\n" ), + '33-characters' => array( 'a' . \str_repeat( 'b', 32 ) ), + ); + } + + #[DataProvider( 'invalid_prefixes' )] + public function test_a_prefix_outside_the_pattern_is_rejected( string $prefix ): void { + $this->expectException( InvalidArgumentException::class ); + + new PluginIdentity( '/plugins/acme/acme.php', 'acme', $prefix ); + } + + public function test_a_prefix_may_reach_32_characters(): void { + $prefix = 'a' . \str_repeat( 'b', 31 ); + + self::assertSame( $prefix, new PluginIdentity( '/plugins/acme/acme.php', 'acme', $prefix )->prefix ); + } +} diff --git a/tests/Unit/PackageManifestsTest.php b/tests/Unit/PackageManifestsTest.php index b1a27a55..88643189 100644 --- a/tests/Unit/PackageManifestsTest.php +++ b/tests/Unit/PackageManifestsTest.php @@ -43,7 +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-wc' => '11.3', 'requires-wp' => '7.1', ), );