Commit 49a5d65ab91 for woocommerce
commit 49a5d65ab9125c880f8f62bf455591bf86566a6b
Author: Barry Hughes <3594411+barryhughes@users.noreply.github.com>
Date: Thu Oct 29 11:23:41 2026 -0700
Expand ActionSchedulerUtil into a compatibility layer over the loaded Action Scheduler (#69846)
* Expand ActionSchedulerUtil into a compatibility layer over the loaded Action Scheduler
Report the loaded copy's version, path and owner, probe the features core
relies on, expose readiness, and wrap async enqueue and scheduling with
unique or priority, degrading explicitly on older loaded copies. Tests
simulate older copies through LegacyProxy mocks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Correct the as_next_scheduled_action() fallback docblocks
The fallback reports in-progress actions from Action Scheduler 3.0.0, so
only copies before that see pending actions alone.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Name the Action Scheduler functions the util replaces in its class docblock
List the five functions the wrappers stand in for and the five that can still be called directly, and note that as_supports() only exists from 3.9.3.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Move the probed feature list out of the ActionSchedulerFeature enum
ALL changes whenever core comes to rely on another feature, and enum constants must keep their values. The list now lives in ActionSchedulerUtil as a private constant, and the test names the features itself.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Emulate unique on every copy whose unique check ignores args
Before 4.0.0 the store's unique insert matches by hook and group only, so native unique with empty args refused an action whenever one with other args was pending for the same hook and group. Native unique is now used only on copies that take args into account; everything else goes through the exact-args emulation.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Update plugins/woocommerce/changelog/wooplug-2435-action-scheduler-util-compat-layer
Co-authored-by: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
* Increase terseness of comments.
* Revise test coverage.
* Correct property docblocks and complete the class docblock table.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Say what a unique action is matched on in the UNIQUE feature docblock
Review feedback on #69846: the UNIQUE entry now states the hook-and-group match and that args only count from 4.0.0, as UNIQUE_ARGS already does.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Seal ActionSchedulerUtil as final
Review feedback on #69846. The class is all static and nothing extends or mocks it; sealing it now keeps the option to open it later, which the reverse would not.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Use str_starts_with() for the path prefix checks
Review feedback on #69846. WordPress has polyfilled it since 5.9, and the rest of src/ already uses it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Rename the ASYNC feature constant to ENQUEUE_ASYNC_ACTION
Review feedback on #69846: the function-backed features now both mirror their function names, as HAS_SCHEDULED_ACTION already did. The value follows the name; it is only a memoization key.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Memoize the as_schedule_single_action() parameter count
* Explain why the admin plugin include is loaded in any context
get_plugins() only ships in wp-admin/includes/plugin.php for historical
reasons and has no admin-only dependencies. resolve_source() runs from
the system status REST endpoint and WP-CLI too, so the include is loaded
on demand rather than guarded with is_admin().
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Look for running actions in has_scheduled_action() on copies below 3.0.0
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
diff --git a/plugins/woocommerce/changelog/wooplug-2435-action-scheduler-util-compat-layer b/plugins/woocommerce/changelog/wooplug-2435-action-scheduler-util-compat-layer
new file mode 100644
index 00000000000..91833d25be6
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-2435-action-scheduler-util-compat-layer
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Expand internal ActionSchedulerUtil into a broader compatibility layer through which to use potentially risky Action Scheduler functions.
diff --git a/plugins/woocommerce/src/Enums/ActionSchedulerFeature.php b/plugins/woocommerce/src/Enums/ActionSchedulerFeature.php
new file mode 100644
index 00000000000..22fa567e18c
--- /dev/null
+++ b/plugins/woocommerce/src/Enums/ActionSchedulerFeature.php
@@ -0,0 +1,51 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Enums;
+
+/**
+ * Enum class for the Action Scheduler features WooCommerce core relies on and that an older loaded copy may lack.
+ *
+ * Probed by {@see \Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil::supports()}.
+ */
+final class ActionSchedulerFeature {
+
+ /**
+ * The `$unique` argument of the scheduling functions. Added in Action Scheduler 3.5.0.
+ *
+ * A unique action is only stored when no pending or in-progress action has the same hook and group.
+ * Copies before 4.0.0 do not compare the args ({@see self::UNIQUE_ARGS}).
+ *
+ * @var string
+ */
+ public const UNIQUE = 'unique';
+
+ /**
+ * The `$priority` argument of the scheduling functions. Added in Action Scheduler 3.6.0.
+ *
+ * @var string
+ */
+ public const PRIORITY = 'priority';
+
+ /**
+ * Unique actions that take the action args into account, so actions that differ only by args can
+ * coexist. Added in Action Scheduler 4.0.0; earlier copies check uniqueness by hook and group alone.
+ *
+ * @var string
+ */
+ public const UNIQUE_ARGS = 'unique_args';
+
+ /**
+ * The `as_has_scheduled_action()` function, which sees pending and in-progress actions. Added in Action Scheduler 3.3.0.
+ *
+ * @var string
+ */
+ public const HAS_SCHEDULED_ACTION = 'has_scheduled_action';
+
+ /**
+ * The `as_enqueue_async_action()` function. Added in Action Scheduler 3.0.0.
+ *
+ * @var string
+ */
+ public const ENQUEUE_ASYNC_ACTION = 'enqueue_async_action';
+}
diff --git a/plugins/woocommerce/src/Enums/README.md b/plugins/woocommerce/src/Enums/README.md
index f331ba30a6d..10f09055565 100644
--- a/plugins/woocommerce/src/Enums/README.md
+++ b/plugins/woocommerce/src/Enums/README.md
@@ -6,6 +6,7 @@ The enum classes make it easier to reference string values and avoid typos. They
## Available Enumerators
+- [ActionSchedulerFeature](./ActionSchedulerFeature.php) - Enumerates the Action Scheduler features WooCommerce core relies on, probed on the loaded copy.
- [CatalogSortOrder](./CatalogSortOrder.php) - Enumerates the possible values of the `woocommerce_default_catalog_orderby` option.
- [CatalogVisibility](./CatalogVisibility.php) - Enumerates the possible catalog visibility options for a product.
- [CurrencyPosition](./CurrencyPosition.php) - Enumerates the possible values of the `woocommerce_currency_pos` option.
diff --git a/plugins/woocommerce/src/Internal/Utilities/ActionSchedulerUtil.php b/plugins/woocommerce/src/Internal/Utilities/ActionSchedulerUtil.php
index 3077ab1d8de..1c26444f0c8 100644
--- a/plugins/woocommerce/src/Internal/Utilities/ActionSchedulerUtil.php
+++ b/plugins/woocommerce/src/Internal/Utilities/ActionSchedulerUtil.php
@@ -3,23 +3,397 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Utilities;
+use Automattic\WooCommerce\Enums\ActionSchedulerFeature;
+use Automattic\WooCommerce\Proxies\LegacyProxy;
+
/**
- * A class of utilities for dealing with Action Scheduler across the versions of it that may be loaded.
+ * Compatibility layer over whichever copy of Action Scheduler is loaded.
+ *
+ * It is encouraged to use the following methods in place of their native Action Scheduler equivalents:
+ *
+ * ActionSchedulerUtil::enqueue_async() ↔ as_enqueue_async_action()
+ * ActionSchedulerUtil::schedule_single() ↔ as_schedule_single_action()
+ * ActionSchedulerUtil::schedule_recurring() ↔ as_schedule_recurring_action()
+ * ActionSchedulerUtil::schedule_cron() ↔ as_schedule_cron_action()
+ * ActionSchedulerUtil::has_scheduled_action() ↔ as_has_scheduled_action()
+ * ActionSchedulerUtil::supports() ↔ as_supports()
+ *
+ * This aims to reduce problems when an unexpectedly old version of Action Scheduler is loaded instead of
+ * the version actually bundled with WooCommerce (though unusual, it is known to happen). Additionally, this
+ * class can report on which version and feature set from Action Scheduler is actually available.
*/
-class ActionSchedulerUtil {
+final class ActionSchedulerUtil {
/**
- * Is a matching action currently scheduled (pending or in-progress)?
+ * The default priority for scheduled actions.
+ *
+ * @var int
+ */
+ private const DEFAULT_PRIORITY = 10;
+
+ /**
+ * The main file of the Action Scheduler copy bundled with WooCommerce, relative to WC_ABSPATH.
+ *
+ * @var string
+ */
+ private const BUNDLED_PLUGIN_FILE = 'packages/action-scheduler/action-scheduler.php';
+
+ /**
+ * The features {@see self::is_risky()} probes, in order.
+ *
+ * @var string[]
+ */
+ private const PROBED_FEATURES = array(
+ ActionSchedulerFeature::UNIQUE,
+ ActionSchedulerFeature::PRIORITY,
+ ActionSchedulerFeature::UNIQUE_ARGS,
+ ActionSchedulerFeature::HAS_SCHEDULED_ACTION,
+ ActionSchedulerFeature::ENQUEUE_ASYNC_ACTION,
+ );
+
+ /**
+ * Feature probe results, keyed by feature.
+ *
+ * @var array<string, bool>
+ */
+ private static array $supports = array();
+
+ /**
+ * The currently loaded Action Scheduler version (null if/while unknown).
+ *
+ * @var string|null
+ */
+ private static ?string $loaded_version = null;
+
+ /**
+ * The version of Action Scheduler actually bundled with WooCommerce.
+ *
+ * @var string|null
+ */
+ private static ?string $bundled_version = null;
+
+ /**
+ * The plugin, mu-plugin or theme that owns the loaded copy.
+ *
+ * @var array|null
+ */
+ private static ?array $loaded_source = null;
+
+ /**
+ * If a priority cannot be assigned, we track the relevant hooks here.
+ *
+ * @var array<string, true>
+ */
+ private static array $logged_priority_drops = array();
+
+ /**
+ * How many parameters the loaded `as_schedule_single_action()` declares (null until probed).
+ *
+ * @var int|null
+ */
+ private static ?int $scheduling_parameter_count = null;
+
+ /**
+ * Is the Action Scheduler procedural API loaded? This does not imply that Action Scheduler has
+ * actually initialized.
+ *
+ * @since 11.3.0
+ *
+ * @return bool
+ */
+ public static function is_loaded(): bool {
+ return self::function_exists( 'as_schedule_single_action' );
+ }
+
+ /**
+ * The version of the loaded Action Scheduler copy, read from its plugin file header.
+ *
+ * This is the copy answering calls, which can be older than the highest registered version.
+ *
+ * @since 11.3.0
+ *
+ * @return string|null The version, or null when no copy is loaded or its header cannot be read.
+ */
+ public static function get_loaded_version(): ?string {
+ if ( null === self::$loaded_version ) {
+ $path = self::get_loaded_path();
+ if ( null === $path ) {
+ return null;
+ }
+ self::$loaded_version = self::read_version_header( $path . 'action-scheduler.php' );
+ }
+
+ return self::$loaded_version;
+ }
+
+ /**
+ * The highest Action Scheduler version registered with the version registry. This may not be
+ * the loaded version.
+ *
+ * @since 11.3.0
+ *
+ * @return string|null The version, or null before `plugins_loaded` or when nothing registered.
+ */
+ public static function get_highest_registered_version(): ?string {
+ $registry = self::get_version_registry();
+ $latest = null === $registry ? null : $registry->latest_version();
+
+ return is_string( $latest ) && '' !== $latest ? $latest : null;
+ }
+
+ /**
+ * The version of the Action Scheduler copy bundled with WooCommerce, read from its plugin file header.
+ *
+ * @since 11.3.0
+ *
+ * @return string|null The version, or null when the header cannot be read.
+ */
+ public static function get_bundled_version(): ?string {
+ if ( null === self::$bundled_version ) {
+ self::$bundled_version = self::read_version_header( WC_ABSPATH . self::BUNDLED_PLUGIN_FILE );
+ }
+
+ return self::$bundled_version;
+ }
+
+ /**
+ * The directory of the loaded Action Scheduler copy, normalized and with a trailing slash.
+ *
+ * @since 11.3.0
+ *
+ * @return string|null The directory, or null when no copy has initialized.
+ */
+ public static function get_loaded_path(): ?string {
+ if ( ! self::has_initialized_copy() ) {
+ return null;
+ }
+
+ $path = self::proxy()->call_static( 'ActionScheduler', 'plugin_path', '' );
+
+ return is_string( $path ) && '' !== $path ? trailingslashit( wp_normalize_path( $path ) ) : null;
+ }
+
+ /**
+ * Which plugin, mu-plugin or theme the loaded Action Scheduler copy belongs to.
+ *
+ * @since 11.3.0
+ *
+ * @return array {
+ * @type string $type One of 'woocommerce', 'mu-plugin', 'plugin', 'theme' or 'other'.
+ * @type string $name The display name of the owner, or an empty string when unknown.
+ * @type string $basename The plugin basename, mu-plugin directory or theme slug, or an empty string.
+ * }
+ */
+ public static function get_loaded_source(): array {
+ if ( null === self::$loaded_source ) {
+ $path = self::get_loaded_path();
+ if ( null === $path ) {
+ return self::source( 'other', '', '' );
+ }
+ self::$loaded_source = self::resolve_source( $path );
+ }
+
+ return self::$loaded_source;
+ }
+
+ /**
+ * Every Action Scheduler copy that registered with the version registry, keyed by directory.
+ *
+ * @since 11.3.0
+ *
+ * @return array<string, string> Directory => version. Empty before Action Scheduler 3.9.1, which added the registry's source tracking.
+ */
+ public static function get_registered_sources(): array {
+ $registry = self::get_version_registry();
+ // PHPStan sees the bundled copy, where the method exists; copies before 3.9.1 lack it.
+ // @phpstan-ignore-next-line function.alreadyNarrowedType -- see comment above.
+ if ( null === $registry || ! method_exists( $registry, 'get_sources' ) ) {
+ return array();
+ }
+
+ $sources = $registry->get_sources();
+
+ return is_array( $sources ) ? $sources : array();
+ }
+
+ /**
+ * Does the loaded copy lack a feature core relies on? Also true if no copy is loaded.
+ *
+ * @since 11.3.0
+ *
+ * @return bool
+ */
+ public static function is_risky(): bool {
+ foreach ( self::PROBED_FEATURES as $feature ) {
+ if ( ! self::supports( $feature ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Ascertains if a given Action Scheduler feature is available and fully supported by the loaded
+ * version of the library.
+ *
+ * @since 11.3.0
+ *
+ * @param string $feature An ActionSchedulerFeature value.
+ *
+ * @return bool False for an unknown feature, and for every feature while no copy is loaded.
+ */
+ public static function supports( string $feature ): bool {
+ if ( ! self::is_loaded() ) {
+ return false;
+ }
+
+ if ( ! array_key_exists( $feature, self::$supports ) ) {
+ self::$supports[ $feature ] = self::probe( $feature );
+ }
+
+ return self::$supports[ $feature ];
+ }
+
+ /**
+ * Can the loaded copy accept scheduling calls right now?
+ *
+ * @since 11.3.0
+ *
+ * @return bool
+ */
+ public static function is_ready(): bool {
+ if ( ! self::is_loaded() ) {
+ return false;
+ }
+
+ if ( self::did_action( 'action_scheduler_init' ) ) {
+ return true;
+ }
+
+ if ( ! self::did_action( 'init' ) ) {
+ return false;
+ }
+
+ return ! self::proxy()->call_function( 'doing_action', 'init' ) || self::current_init_priority() > 1;
+ }
+
+ /**
+ * Run a callback now if the loaded copy is ready, otherwise once it becomes ready.
+ *
+ * @since 11.3.0
+ *
+ * @param callable $callback The callback to run. It receives no arguments.
+ *
+ * @return void
+ */
+ public static function when_ready( callable $callback ): void {
+ if ( self::is_ready() ) {
+ $callback();
+ return;
+ }
+
+ $pending = true;
+ $run_once = function () use ( &$pending, $callback ): void {
+ if ( $pending && self::is_ready() ) {
+ $pending = false;
+ $callback();
+ }
+ };
+
+ self::proxy()->call_function( 'add_action', 'action_scheduler_init', $run_once );
+
+ if ( ! self::did_action( 'init' ) || self::proxy()->call_function( 'doing_action', 'init' ) ) {
+ self::proxy()->call_function( 'add_action', 'init', $run_once, 2 );
+ }
+ }
+
+ /**
+ * Enqueue an action to run as soon as possible, like `as_enqueue_async_action()`.
+ *
+ * @since 11.3.0
+ *
+ * @param string $hook The hook to trigger.
+ * @param array $args Arguments to pass when the hook triggers.
+ * @param string $group The group to assign this job to.
+ * @param bool $unique Whether the action should be unique.
+ * @param int $priority Lower values take precedence over higher values. Defaults to 10, with acceptable values falling in the range 0-255.
+ *
+ * @return int The action ID. Zero if the action was not scheduled, including when a unique action already exists.
+ */
+ public static function enqueue_async( string $hook, array $args = array(), string $group = '', bool $unique = false, int $priority = self::DEFAULT_PRIORITY ): int {
+ if ( self::supports( ActionSchedulerFeature::ENQUEUE_ASYNC_ACTION ) ) {
+ return self::schedule( __FUNCTION__, 'as_enqueue_async_action', array(), $hook, $args, $group, $unique, $priority );
+ }
+
+ return self::schedule( __FUNCTION__, 'as_schedule_single_action', array( time() ), $hook, $args, $group, $unique, $priority );
+ }
+
+ /**
+ * Schedule an action to run once at some time in the future, like `as_schedule_single_action()`.
+ *
+ * In unusual cases where an older version of Action Scheduler has loaded, uniqueness may be emulated
+ * and priorities may be dropped (only if necessary).
+ *
+ * @since 11.3.0
+ *
+ * @param int $timestamp When the job will run.
+ * @param string $hook The hook to trigger.
+ * @param array $args Arguments to pass when the hook triggers.
+ * @param string $group The group to assign this job to.
+ * @param bool $unique Whether the action should be unique.
+ * @param int $priority Lower values take precedence over higher values. Defaults to 10, with acceptable values falling in the range 0-255.
+ *
+ * @return int The action ID. Zero if the action was not scheduled, including when a unique action already exists.
+ */
+ public static function schedule_single( int $timestamp, string $hook, array $args = array(), string $group = '', bool $unique = false, int $priority = self::DEFAULT_PRIORITY ): int {
+ return self::schedule( __FUNCTION__, 'as_schedule_single_action', array( $timestamp ), $hook, $args, $group, $unique, $priority );
+ }
+
+ /**
+ * Schedule a recurring action, like `as_schedule_recurring_action()`.
+ *
+ * See notes for {@see self::schedule_single()} regarding `$unique` and `$priority` handling.
+ *
+ * @since 11.3.0
+ *
+ * @param int $timestamp When the first instance of the job will run.
+ * @param int $interval How long to wait between runs, in seconds.
+ * @param string $hook The hook to trigger.
+ * @param array $args Arguments to pass when the hook triggers.
+ * @param string $group The group to assign this job to.
+ * @param bool $unique Whether the action should be unique.
+ * @param int $priority Lower values take precedence over higher values. Defaults to 10, with acceptable values falling in the range 0-255.
*
- * Prefers `as_has_scheduled_action`, which only exists since Action Scheduler 3.3.0: another plugin
- * can load an older copy early enough to win the version race against the one bundled with
- * WooCommerce, and a bare call is then a fatal. Falls back to the much older `as_next_scheduled_action`,
- * which on such a copy may report only pending actions, not in-progress ones - an accepted trade.
+ * @return int The action ID. Zero if the action was not scheduled, including when a unique action already exists.
+ */
+ public static function schedule_recurring( int $timestamp, int $interval, string $hook, array $args = array(), string $group = '', bool $unique = false, int $priority = self::DEFAULT_PRIORITY ): int {
+ return self::schedule( __FUNCTION__, 'as_schedule_recurring_action', array( $timestamp, $interval ), $hook, $args, $group, $unique, $priority );
+ }
+
+ /**
+ * Schedule an action that recurs on a cron-like schedule, like `as_schedule_cron_action()`.
+ *
+ * See notes for {@see self::schedule_single()} regarding `$unique` and `$priority` handling.
+ *
+ * @since 11.3.0
*
- * Reports false when Action Scheduler is not loaded at all, indistinguishable from "nothing is
- * scheduled", and raises a doing-it-wrong notice so the condition is visible under debugging.
- * A caller that treats a negative answer as licence to discard state should check
- * {@see self::can_check_scheduled_actions()} first.
+ * @param int $timestamp The first instance of the action will be scheduled on or after this time.
+ * @param string $schedule A cron-link schedule string.
+ * @param string $hook The hook to trigger.
+ * @param array $args Arguments to pass when the hook triggers.
+ * @param string $group The group to assign this job to.
+ * @param bool $unique Whether the action should be unique.
+ * @param int $priority Lower values take precedence over higher values. Defaults to 10, with acceptable values falling in the range 0-255.
+ *
+ * @return int The action ID. Zero if the action was not scheduled, including when a unique action already exists.
+ */
+ public static function schedule_cron( int $timestamp, string $schedule, string $hook, array $args = array(), string $group = '', bool $unique = false, int $priority = self::DEFAULT_PRIORITY ): int {
+ return self::schedule( __FUNCTION__, 'as_schedule_cron_action', array( $timestamp, $schedule ), $hook, $args, $group, $unique, $priority );
+ }
+
+ /**
+ * Is a matching action currently scheduled (pending or in-progress)?
*
* @since 11.2.0
*
@@ -30,23 +404,39 @@ class ActionSchedulerUtil {
* @return bool True if a matching action is scheduled, false otherwise.
*/
public static function has_scheduled_action( string $hook, ?array $args = null, string $group = '' ): bool {
- foreach ( array( 'as_has_scheduled_action', 'as_next_scheduled_action' ) as $function ) {
- // PHPStan sees the Action Scheduler copy bundled with WooCommerce and concludes both functions
- // always exist. The runtime case this guard exists for is precisely the one it cannot see.
- // @phpstan-ignore-next-line function.alreadyNarrowedType -- see comment above.
- if ( function_exists( $function ) ) {
- // `as_next_scheduled_action` returns the timestamp of the next pending action, hence the cast.
- return (bool) $function( $hook, $args, $group );
- }
+ if ( self::supports( ActionSchedulerFeature::HAS_SCHEDULED_ACTION ) ) {
+ return (bool) self::proxy()->call_function( 'as_has_scheduled_action', $hook, $args, $group );
+ }
+
+ if ( ! self::function_exists( 'as_next_scheduled_action' ) ) {
+ wc_doing_it_wrong(
+ __METHOD__,
+ 'Action Scheduler is not loaded, so scheduled actions cannot be checked. Call this after Action Scheduler has initialized.',
+ '11.2.0'
+ );
+
+ return false;
}
- wc_doing_it_wrong(
- __METHOD__,
- 'Action Scheduler is not loaded, so scheduled actions cannot be checked. Call this after Action Scheduler has initialized.',
- '11.2.0'
+ // Returns the timestamp of the next pending action, or true for an in-progress one, hence the cast.
+ if ( (bool) self::proxy()->call_function( 'as_next_scheduled_action', $hook, $args, $group ) ) {
+ return true;
+ }
+
+ // Before 3.0.0 that lookup covers pending actions only, so look for a running one as well.
+ $running = self::proxy()->call_function(
+ 'as_get_scheduled_actions',
+ array(
+ 'hook' => $hook,
+ 'args' => $args,
+ 'group' => $group,
+ 'status' => \ActionScheduler_Store::STATUS_RUNNING,
+ 'per_page' => 1,
+ ),
+ 'ids'
);
- return false;
+ return ! empty( $running );
}
/**
@@ -58,6 +448,335 @@ class ActionSchedulerUtil {
* @return bool True if a scheduled-action check can be answered, false otherwise.
*/
public static function can_check_scheduled_actions(): bool {
- return function_exists( 'as_has_scheduled_action' ) || function_exists( 'as_next_scheduled_action' );
+ return self::function_exists( 'as_has_scheduled_action' ) || self::function_exists( 'as_next_scheduled_action' );
+ }
+
+ /**
+ * Forget every memoized answer, so the next call probes the loaded copy again.
+ *
+ * @internal For tests that simulate different loaded copies within one request.
+ *
+ * @since 11.3.0
+ *
+ * @return void
+ */
+ public static function reset_cache(): void {
+ self::$supports = array();
+ self::$loaded_version = null;
+ self::$bundled_version = null;
+ self::$loaded_source = null;
+ self::$logged_priority_drops = array();
+ self::$scheduling_parameter_count = null;
+ }
+
+ /**
+ * Schedule through the loaded copy, applying the degradations documented on {@see self::schedule_single()}.
+ *
+ * @param string $method The public wrapper being served, for the doing-it-wrong notice.
+ * @param string $function_name The Action Scheduler function to call.
+ * @param array $leading_args The arguments the function takes before the hook (timestamp, interval, schedule).
+ * @param string $hook The hook to trigger.
+ * @param array $args Arguments to pass when the hook triggers.
+ * @param string $group The group to assign this job to.
+ * @param bool $unique Whether the action should be unique.
+ * @param int $priority The requested priority.
+ *
+ * @return int The action ID, or 0.
+ */
+ private static function schedule( string $method, string $function_name, array $leading_args, string $hook, array $args, string $group, bool $unique, int $priority ): int {
+ if ( ! self::is_loaded() ) {
+ wc_doing_it_wrong(
+ __CLASS__ . '::' . $method,
+ 'Action Scheduler is not loaded, so the action cannot be scheduled. Call this after Action Scheduler has initialized.',
+ '11.3.0'
+ );
+
+ return 0;
+ }
+
+ $native_unique = $unique && self::supports( ActionSchedulerFeature::UNIQUE_ARGS );
+
+ if ( $unique && ! $native_unique && self::has_scheduled_action( $hook, $args, $group ) ) {
+ return 0;
+ }
+
+ $call_args = array_merge( $leading_args, array( $hook, $args, $group ) );
+
+ if ( self::supports( ActionSchedulerFeature::UNIQUE ) ) {
+ $call_args[] = $native_unique;
+ }
+
+ if ( self::supports( ActionSchedulerFeature::PRIORITY ) ) {
+ $call_args[] = $priority;
+ } elseif ( self::DEFAULT_PRIORITY !== $priority ) {
+ self::log_dropped_priority( $hook, $priority );
+ }
+
+ return (int) self::proxy()->call_function( $function_name, ...$call_args );
+ }
+
+ /**
+ * Record, once per request per hook, that a requested priority could not be applied.
+ *
+ * @param string $hook The hook of the action.
+ * @param int $priority The priority that was requested.
+ *
+ * @return void
+ */
+ private static function log_dropped_priority( string $hook, int $priority ): void {
+ if ( isset( self::$logged_priority_drops[ $hook ] ) ) {
+ return;
+ }
+ self::$logged_priority_drops[ $hook ] = true;
+
+ self::proxy()->call_function( 'wc_get_logger' )->debug(
+ sprintf(
+ 'Priority %1$d for action "%2$s" was dropped: the loaded Action Scheduler (%3$s) does not support priorities, so the action was scheduled at the default priority.',
+ $priority,
+ $hook,
+ self::get_loaded_version() ?? 'unknown version'
+ ),
+ array( 'source' => 'action-scheduler' )
+ );
+ }
+
+ /**
+ * Probe one feature on the loaded copy.
+ *
+ * @param string $feature An ActionSchedulerFeature value.
+ *
+ * @return bool
+ */
+ private static function probe( string $feature ): bool {
+ switch ( $feature ) {
+ case ActionSchedulerFeature::UNIQUE:
+ return self::count_scheduling_parameters() >= 5;
+ case ActionSchedulerFeature::PRIORITY:
+ return self::count_scheduling_parameters() >= 6;
+ case ActionSchedulerFeature::UNIQUE_ARGS:
+ $version = self::get_loaded_version();
+ return self::supports( ActionSchedulerFeature::UNIQUE ) && null !== $version && version_compare( $version, '4.0.0', '>=' );
+ case ActionSchedulerFeature::HAS_SCHEDULED_ACTION:
+ return self::function_exists( 'as_has_scheduled_action' );
+ case ActionSchedulerFeature::ENQUEUE_ASYNC_ACTION:
+ return self::function_exists( 'as_enqueue_async_action' );
+ }
+
+ return false;
+ }
+
+ /**
+ * How many parameters the loaded `as_schedule_single_action()` declares: 4 before 3.5.0, 5 from 3.5.0, 6 from 3.6.0.
+ *
+ * @return int
+ */
+ private static function count_scheduling_parameters(): int {
+ if ( null === self::$scheduling_parameter_count ) {
+ self::$scheduling_parameter_count = self::proxy()->get_instance_of( \ReflectionFunction::class, 'as_schedule_single_action' )->getNumberOfParameters();
+ }
+
+ return self::$scheduling_parameter_count;
+ }
+
+ /**
+ * Has any copy run `ActionScheduler::init()`, which sets the plugin path and loads the functions?
+ *
+ * A copy older than 2.1.0 defines the `wc_*()` functions instead of the `as_*()` ones; it still
+ * has a version and a path worth reporting.
+ *
+ * @return bool
+ */
+ private static function has_initialized_copy(): bool {
+ return self::function_exists( 'as_schedule_single_action' ) || self::function_exists( 'wc_schedule_single_action' );
+ }
+
+ /**
+ * The version registry, when its class is loaded and `plugins_loaded` has run, so that it is complete.
+ *
+ * @return \ActionScheduler_Versions|null
+ */
+ private static function get_version_registry(): ?\ActionScheduler_Versions {
+ if ( ! self::did_action( 'plugins_loaded' ) || ! self::proxy()->call_function( 'class_exists', 'ActionScheduler_Versions' ) ) {
+ return null;
+ }
+
+ $registry = self::proxy()->call_static( 'ActionScheduler_Versions', 'instance' );
+
+ return $registry instanceof \ActionScheduler_Versions ? $registry : null;
+ }
+
+ /**
+ * Read the `Version` header of a plugin file.
+ *
+ * @param string $file The absolute path of the file.
+ *
+ * @return string|null The version, or null when the file is unreadable or has no version header.
+ */
+ private static function read_version_header( string $file ): ?string {
+ if ( ! is_readable( $file ) ) {
+ return null;
+ }
+
+ $data = get_file_data( $file, array( 'Version' => 'Version' ) );
+ $version = trim( (string) ( $data['Version'] ?? '' ) );
+
+ return '' === $version ? null : $version;
+ }
+
+ /**
+ * Resolve which plugin, mu-plugin or theme a directory belongs to.
+ *
+ * @param string $path The loaded copy's directory, normalized and with a trailing slash.
+ *
+ * @return array See {@see self::get_loaded_source()}.
+ */
+ private static function resolve_source( string $path ): array {
+ if ( null !== self::relative_to( $path, WC_ABSPATH . 'packages/action-scheduler' ) ) {
+ return self::source( 'woocommerce', 'WooCommerce', plugin_basename( WC_PLUGIN_FILE ) );
+ }
+
+ $path = self::map_real_path( $path );
+
+ $relative = self::relative_to( $path, WPMU_PLUGIN_DIR );
+ if ( null !== $relative ) {
+ $directory = (string) strtok( $relative, '/' );
+
+ return self::source( 'mu-plugin', $directory, $directory );
+ }
+
+ $relative = self::relative_to( $path, WP_PLUGIN_DIR );
+ if ( null !== $relative ) {
+ $directory = (string) strtok( $relative, '/' );
+ // get_plugins() ships in an admin include but has no admin-only dependencies, so loading
+ // it is safe in any context. This path also runs outside wp-admin, for example from the
+ // system status REST endpoint and WP-CLI.
+ if ( ! function_exists( 'get_plugins' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ }
+ foreach ( (array) self::proxy()->call_function( 'get_plugins', '/' . $directory ) as $file => $data ) {
+ if ( ! empty( $data['Name'] ) ) {
+ return self::source( 'plugin', (string) $data['Name'], $directory . '/' . $file );
+ }
+ }
+
+ return self::source( 'plugin', $directory, $directory );
+ }
+
+ $themes = array(
+ get_stylesheet() => get_stylesheet_directory(),
+ get_template() => get_template_directory(),
+ );
+ foreach ( $themes as $slug => $directory ) {
+ if ( null !== self::relative_to( $path, $directory ) ) {
+ $theme = wp_get_theme( $slug );
+
+ return self::source( 'theme', $theme->exists() ? (string) $theme->get( 'Name' ) : $slug, $slug );
+ }
+ }
+
+ return self::source( 'other', '', '' );
+ }
+
+ /**
+ * Map a real path back to the symlinked plugin path WordPress knows it by, as plugin_basename() does.
+ *
+ * `ActionScheduler::plugin_path()` comes from `__FILE__`, which PHP resolves to the real path, while
+ * the plugin directory constants are not resolved. `$wp_plugin_paths` records the mapping for every
+ * active plugin and mu-plugin.
+ *
+ * @param string $path A normalized path with a trailing slash.
+ *
+ * @return string
+ */
+ private static function map_real_path( string $path ): string {
+ $plugin_paths = self::proxy()->get_global( 'wp_plugin_paths' );
+ if ( ! is_array( $plugin_paths ) ) {
+ return $path;
+ }
+
+ arsort( $plugin_paths );
+ foreach ( $plugin_paths as $dir => $real_dir ) {
+ $real_dir = trailingslashit( wp_normalize_path( (string) $real_dir ) );
+ if ( str_starts_with( $path, $real_dir ) ) {
+ return trailingslashit( wp_normalize_path( (string) $dir ) ) . substr( $path, strlen( $real_dir ) );
+ }
+ }
+
+ return $path;
+ }
+
+ /**
+ * The priority `init` is currently running at, or 0 when it is not running.
+ *
+ * @return int
+ */
+ private static function current_init_priority(): int {
+ $wp_filter = self::proxy()->get_global( 'wp_filter' );
+ $hook = is_array( $wp_filter ) && isset( $wp_filter['init'] ) ? $wp_filter['init'] : null;
+ $priority = is_object( $hook ) && is_callable( array( $hook, 'current_priority' ) ) ? $hook->current_priority() : false;
+
+ return is_numeric( $priority ) ? (int) $priority : 0;
+ }
+
+ /**
+ * The part of a path below a directory, or null when the path is not inside it.
+ *
+ * @param string $path A normalized path with a trailing slash.
+ * @param string $directory The directory, in any form.
+ *
+ * @return string|null
+ */
+ private static function relative_to( string $path, string $directory ): ?string {
+ $directory = trailingslashit( wp_normalize_path( $directory ) );
+
+ return str_starts_with( $path, $directory ) ? (string) substr( $path, strlen( $directory ) ) : null;
+ }
+
+ /**
+ * Build a source descriptor.
+ *
+ * @param string $type The source type.
+ * @param string $name The display name.
+ * @param string $basename The plugin basename, mu-plugin directory or theme slug.
+ *
+ * @return array
+ */
+ private static function source( string $type, string $name, string $basename ): array {
+ return array(
+ 'type' => $type,
+ 'name' => $name,
+ 'basename' => $basename,
+ );
+ }
+
+ /**
+ * Has an action fired, asked through the proxy so tests can simulate request phases?
+ *
+ * @param string $hook The action hook.
+ *
+ * @return bool
+ */
+ private static function did_action( string $hook ): bool {
+ return (bool) self::proxy()->call_function( 'did_action', $hook );
+ }
+
+ /**
+ * Does a function exist, asked through the proxy so tests can simulate an older copy?
+ *
+ * @param string $function_name The function name.
+ *
+ * @return bool
+ */
+ private static function function_exists( string $function_name ): bool {
+ return (bool) self::proxy()->call_function( 'function_exists', $function_name );
+ }
+
+ /**
+ * The legacy proxy every probe and Action Scheduler call goes through.
+ *
+ * @return LegacyProxy
+ */
+ private static function proxy(): LegacyProxy {
+ return wc_get_container()->get( LegacyProxy::class );
}
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Utilities/ActionSchedulerUtilTest.php b/plugins/woocommerce/tests/php/src/Internal/Utilities/ActionSchedulerUtilTest.php
index 329527ebd64..4934c0454c2 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Utilities/ActionSchedulerUtilTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Utilities/ActionSchedulerUtilTest.php
@@ -3,25 +3,765 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\Utilities;
+use Automattic\WooCommerce\Enums\ActionSchedulerFeature;
use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;
use WC_Unit_Test_Case;
/**
* Tests for the Internal\Utilities\ActionSchedulerUtil class.
+ *
+ * The bundled Action Scheduler is always loaded in the test suite. Older loaded copies are simulated
+ * through the legacy proxy mocks: which functions and methods exist, how many parameters the loaded
+ * `as_schedule_single_action()` declares, the loaded path, and the Action Scheduler calls themselves.
*/
class ActionSchedulerUtilTest extends WC_Unit_Test_Case {
+ /**
+ * Every feature core relies on, in the order the util probes them.
+ *
+ * @var string[]
+ */
+ private const FEATURES = array(
+ ActionSchedulerFeature::UNIQUE,
+ ActionSchedulerFeature::PRIORITY,
+ ActionSchedulerFeature::UNIQUE_ARGS,
+ ActionSchedulerFeature::HAS_SCHEDULED_ACTION,
+ ActionSchedulerFeature::ENQUEUE_ASYNC_ACTION,
+ );
+
private const HOOK = 'woocommerce_action_scheduler_util_test';
private const GROUP = 'woocommerce_action_scheduler_util_test_group';
+ /**
+ * Directories of the fake Action Scheduler copies created by simulate_loaded_copy().
+ *
+ * @var string[]
+ */
+ private array $fake_copies = array();
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ ActionSchedulerUtil::reset_cache();
+ }
+
/**
* Tear down test fixtures.
*/
public function tearDown(): void {
as_unschedule_all_actions( self::HOOK );
+ ActionSchedulerUtil::reset_cache();
+ $this->reset_legacy_proxy_mocks();
+
+ foreach ( $this->fake_copies as $dir ) {
+ wp_delete_file( $dir . '/action-scheduler.php' );
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
+ rmdir( $dir );
+ }
+ $this->fake_copies = array();
+
parent::tearDown();
}
+ /**
+ * @testdox The bundled copy is reported as loaded, current, ready and fully featured.
+ */
+ public function test_reports_the_bundled_copy_when_it_is_the_loaded_one(): void {
+ $this->assertTrue( ActionSchedulerUtil::is_loaded() );
+ $this->assertTrue( ActionSchedulerUtil::is_ready() );
+ $this->assertTrue( ActionSchedulerUtil::can_check_scheduled_actions() );
+
+ $this->assertMatchesRegularExpression( '/^\d+\.\d+\.\d+/', (string) ActionSchedulerUtil::get_bundled_version() );
+ $this->assertSame( ActionSchedulerUtil::get_bundled_version(), ActionSchedulerUtil::get_loaded_version() );
+ $this->assertSame(
+ array(
+ 'type' => 'woocommerce',
+ 'name' => 'WooCommerce',
+ 'basename' => plugin_basename( WC_PLUGIN_FILE ),
+ ),
+ ActionSchedulerUtil::get_loaded_source()
+ );
+ $this->assertGreaterThanOrEqual( 0, version_compare( (string) ActionSchedulerUtil::get_highest_registered_version(), (string) ActionSchedulerUtil::get_loaded_version() ) );
+ $this->assertContains( ActionSchedulerUtil::get_loaded_version(), ActionSchedulerUtil::get_registered_sources() );
+
+ $this->assertFalse( ActionSchedulerUtil::is_risky() );
+ foreach ( self::FEATURES as $feature ) {
+ $this->assertTrue( ActionSchedulerUtil::supports( $feature ), "The bundled copy should support $feature" );
+ }
+ $this->assertFalse( ActionSchedulerUtil::supports( 'no_such_feature' ) );
+ }
+
+ /**
+ * @testdox The registry answers come from `ActionScheduler_Versions`, complete only after `plugins_loaded`.
+ */
+ public function test_registry_answers_come_from_the_version_registry(): void {
+ $registry = new \ActionScheduler_Versions();
+ $registry->register( '3.5.4', '__return_null' );
+ $registry->register( '4.0.0', '__return_null' );
+ $this->register_legacy_proxy_static_mocks( array( 'ActionScheduler_Versions' => array( 'instance' => fn() => $registry ) ) );
+
+ $this->assertSame( '4.0.0', ActionSchedulerUtil::get_highest_registered_version() );
+ $this->assertSame( array( __FILE__ => '4.0.0' ), ActionSchedulerUtil::get_registered_sources() );
+
+ $this->register_legacy_proxy_function_mocks( array( 'did_action' => fn( string $hook ) => 'plugins_loaded' === $hook ? 0 : did_action( $hook ) ) );
+ $this->assertNull( ActionSchedulerUtil::get_highest_registered_version() );
+ $this->assertSame( array(), ActionSchedulerUtil::get_registered_sources() );
+ }
+
+ /**
+ * @testdox A loaded copy is probed for each feature and is risky when one is missing.
+ * @dataProvider provider_loaded_copies
+ *
+ * @param string $version The simulated loaded version.
+ * @param string[] $expected_missing The features that version lacks.
+ */
+ public function test_probes_each_feature_of_a_loaded_copy( string $version, array $expected_missing ): void {
+ $this->simulate_loaded_copy( $version );
+
+ $this->assertTrue( ActionSchedulerUtil::is_loaded() );
+ $this->assertSame( $version, ActionSchedulerUtil::get_loaded_version() );
+ foreach ( self::FEATURES as $feature ) {
+ $this->assertSame( ! in_array( $feature, $expected_missing, true ), ActionSchedulerUtil::supports( $feature ), "Support for $feature on $version" );
+ }
+ $this->assertSame( ! empty( $expected_missing ), ActionSchedulerUtil::is_risky() );
+ }
+
+ /**
+ * Data provider for test_probes_each_feature_of_a_loaded_copy.
+ *
+ * @return array
+ */
+ public function provider_loaded_copies(): array {
+ return array(
+ '2.1.0: the floor' => array( '2.1.0', self::FEATURES ),
+ '3.0.0: async only' => array( '3.0.0', array( ActionSchedulerFeature::UNIQUE, ActionSchedulerFeature::PRIORITY, ActionSchedulerFeature::UNIQUE_ARGS, ActionSchedulerFeature::HAS_SCHEDULED_ACTION ) ),
+ '3.2.0: async only, lab fixture' => array( '3.2.0', array( ActionSchedulerFeature::UNIQUE, ActionSchedulerFeature::PRIORITY, ActionSchedulerFeature::UNIQUE_ARGS, ActionSchedulerFeature::HAS_SCHEDULED_ACTION ) ),
+ '3.5.4: unique by hook, no priority' => array( '3.5.4', array( ActionSchedulerFeature::PRIORITY, ActionSchedulerFeature::UNIQUE_ARGS ) ),
+ '3.9.3: unique by hook' => array( '3.9.3', array( ActionSchedulerFeature::UNIQUE_ARGS ) ),
+ '4.1.0: everything' => array( '4.1.0', array() ),
+ );
+ }
+
+ /**
+ * @testdox A copy older than 2.1.0 is identified but reported as unusable.
+ */
+ public function test_a_copy_older_than_the_procedural_api_is_identified_but_unusable(): void {
+ $this->simulate_loaded_copy( '1.5' );
+
+ $this->assertFalse( ActionSchedulerUtil::is_loaded() );
+ $this->assertSame( '1.5', ActionSchedulerUtil::get_loaded_version() );
+ $this->assertTrue( ActionSchedulerUtil::is_risky() );
+ $this->assertFalse( ActionSchedulerUtil::supports( ActionSchedulerFeature::UNIQUE ) );
+ $this->assertFalse( ActionSchedulerUtil::is_ready() );
+ }
+
+ /**
+ * @testdox Nothing is reported or scheduled while Action Scheduler is not loaded.
+ */
+ public function test_reports_nothing_and_schedules_nothing_when_action_scheduler_is_not_loaded(): void {
+ $this->register_legacy_proxy_function_mocks(
+ array( 'function_exists' => fn( string $name ) => 0 !== strpos( $name, 'as_' ) && 0 !== strpos( $name, 'wc_schedule' ) && function_exists( $name ) )
+ );
+ $this->setExpectedIncorrectUsage( ActionSchedulerUtil::class . '::has_scheduled_action' );
+ $this->setExpectedIncorrectUsage( ActionSchedulerUtil::class . '::schedule_single' );
+
+ $this->assertFalse( ActionSchedulerUtil::is_loaded() );
+ $this->assertFalse( ActionSchedulerUtil::is_ready() );
+ $this->assertFalse( ActionSchedulerUtil::can_check_scheduled_actions() );
+ $this->assertNull( ActionSchedulerUtil::get_loaded_version() );
+ $this->assertNull( ActionSchedulerUtil::get_loaded_path() );
+ $this->assertSame( 'other', ActionSchedulerUtil::get_loaded_source()['type'] );
+ $this->assertTrue( ActionSchedulerUtil::is_risky() );
+ $this->assertFalse( ActionSchedulerUtil::supports( ActionSchedulerFeature::ENQUEUE_ASYNC_ACTION ) );
+ $this->assertFalse( ActionSchedulerUtil::has_scheduled_action( self::HOOK ) );
+ $this->assertSame( 0, ActionSchedulerUtil::schedule_single( time() + HOUR_IN_SECONDS, self::HOOK, array(), self::GROUP, true, 5 ) );
+ }
+
+ /**
+ * @testdox Probes are memoized until the cache is reset.
+ */
+ public function test_probes_are_memoized_until_reset(): void {
+ $this->simulate_loaded_copy( '3.2.0' );
+ $this->assertFalse( ActionSchedulerUtil::supports( ActionSchedulerFeature::UNIQUE ) );
+
+ $this->register_legacy_proxy_class_mocks( array( \ReflectionFunction::class => fn() => $this->reflection_with_parameters( 6 ) ) );
+ $this->assertFalse( ActionSchedulerUtil::supports( ActionSchedulerFeature::UNIQUE ), 'The first answer should be memoized' );
+
+ ActionSchedulerUtil::reset_cache();
+ $this->assertTrue( ActionSchedulerUtil::supports( ActionSchedulerFeature::UNIQUE ), 'After a reset the loaded copy should be probed again' );
+ }
+
+ /**
+ * @testdox The loaded copy is resolved to the plugin that bundles it.
+ */
+ public function test_resolves_the_loaded_copy_to_the_plugin_that_bundles_it(): void {
+ $this->simulate_loaded_copy( '3.5.4', WP_PLUGIN_DIR . '/some-plugin/vendor/woocommerce/action-scheduler' );
+ $folders = array();
+ $this->register_legacy_proxy_function_mocks(
+ array(
+ 'get_plugins' => function ( string $folder ) use ( &$folders ) {
+ $folders[] = $folder;
+ return array( 'some-plugin.php' => array( 'Name' => 'Some Plugin' ) );
+ },
+ )
+ );
+
+ $source = ActionSchedulerUtil::get_loaded_source();
+ $this->assertSame( array( '/some-plugin' ), $folders, 'Only the one plugin folder should be scanned' );
+ $this->assertSame(
+ array(
+ 'type' => 'plugin',
+ 'name' => 'Some Plugin',
+ 'basename' => 'some-plugin/some-plugin.php',
+ ),
+ $source
+ );
+ }
+
+ /**
+ * @testdox A plugin directory with no known plugin file is reported by its directory name.
+ */
+ public function test_resolves_an_unknown_plugin_directory_by_its_name(): void {
+ $this->simulate_loaded_copy( '3.5.4', WP_PLUGIN_DIR . '/some-plugin/vendor/woocommerce/action-scheduler' );
+ $this->register_legacy_proxy_function_mocks( array( 'get_plugins' => fn() => array() ) );
+
+ $this->assertSame(
+ array(
+ 'type' => 'plugin',
+ 'name' => 'some-plugin',
+ 'basename' => 'some-plugin',
+ ),
+ ActionSchedulerUtil::get_loaded_source()
+ );
+ }
+
+ /**
+ * @testdox A copy inside a symlinked plugin directory is resolved through the plugin's registered real path.
+ */
+ public function test_resolves_a_symlinked_plugin_through_its_registered_real_path(): void {
+ $this->simulate_loaded_copy( '3.5.4', '/real/location/linked-plugin/vendor/action-scheduler' );
+ $this->register_legacy_proxy_global_mocks(
+ array( 'wp_plugin_paths' => array( wp_normalize_path( WP_PLUGIN_DIR ) . '/linked-plugin' => '/real/location/linked-plugin' ) )
+ );
+ $this->register_legacy_proxy_function_mocks( array( 'get_plugins' => fn( string $folder ) => '/linked-plugin' === $folder ? array( 'linked-plugin.php' => array( 'Name' => 'Linked Plugin' ) ) : array() ) );
+
+ $this->assertSame( '/real/location/linked-plugin/vendor/action-scheduler/', ActionSchedulerUtil::get_loaded_path(), 'The loaded path stays the real path' );
+ $this->assertSame(
+ array(
+ 'type' => 'plugin',
+ 'name' => 'Linked Plugin',
+ 'basename' => 'linked-plugin/linked-plugin.php',
+ ),
+ ActionSchedulerUtil::get_loaded_source()
+ );
+ }
+
+ /**
+ * @testdox The loaded copy is resolved to the mu-plugin that bundles it.
+ */
+ public function test_resolves_the_loaded_copy_to_the_mu_plugin_that_bundles_it(): void {
+ $this->simulate_loaded_copy( '3.5.4', WPMU_PLUGIN_DIR . '/loader/lib/action-scheduler' );
+
+ $this->assertSame(
+ array(
+ 'type' => 'mu-plugin',
+ 'name' => 'loader',
+ 'basename' => 'loader',
+ ),
+ ActionSchedulerUtil::get_loaded_source()
+ );
+ }
+
+ /**
+ * @testdox The loaded copy is resolved to the active theme that bundles it.
+ */
+ public function test_resolves_the_loaded_copy_to_the_active_theme_that_bundles_it(): void {
+ $theme = wp_get_theme();
+ if ( ! $theme->exists() ) {
+ $this->markTestSkipped( 'No theme is installed in the test environment.' );
+ }
+
+ $this->simulate_loaded_copy( '3.5.4', get_stylesheet_directory() . '/vendor/action-scheduler' );
+
+ $this->assertSame(
+ array(
+ 'type' => 'theme',
+ 'name' => (string) $theme->get( 'Name' ),
+ 'basename' => get_stylesheet(),
+ ),
+ ActionSchedulerUtil::get_loaded_source()
+ );
+ }
+
+ /**
+ * @testdox A loaded copy outside every known location is reported as "other".
+ */
+ public function test_resolves_an_unknown_location_to_other(): void {
+ $this->simulate_loaded_copy( '3.5.4', '/somewhere/else/action-scheduler' );
+
+ $this->assertSame(
+ array(
+ 'type' => 'other',
+ 'name' => '',
+ 'basename' => '',
+ ),
+ ActionSchedulerUtil::get_loaded_source()
+ );
+ }
+
+ /**
+ * @testdox On the bundled copy, unique and priority are honoured natively.
+ */
+ public function test_schedules_a_unique_prioritized_action_on_the_bundled_copy(): void {
+ $id = ActionSchedulerUtil::schedule_single( time() + HOUR_IN_SECONDS, self::HOOK, array( 'a' ), self::GROUP, true, 5 );
+
+ $this->assertGreaterThan( 0, $id );
+ $this->assertSame( 5, \ActionScheduler::store()->fetch_action( $id )->get_priority() );
+ $this->assertSame(
+ 0,
+ ActionSchedulerUtil::schedule_single( time() + HOUR_IN_SECONDS, self::HOOK, array( 'a' ), self::GROUP, true, 5 ),
+ 'A second unique action with the same hook, args and group should be rejected'
+ );
+ $this->assertGreaterThan(
+ 0,
+ ActionSchedulerUtil::schedule_single( time() + HOUR_IN_SECONDS, self::HOOK, array( 'b' ), self::GROUP, true, 5 ),
+ 'A unique action that differs by args should be accepted on a copy that honours args'
+ );
+ }
+
+ /**
+ * @testdox On the bundled copy, every wrapper schedules through its Action Scheduler counterpart.
+ */
+ public function test_every_wrapper_schedules_on_the_bundled_copy(): void {
+ $store = \ActionScheduler::store();
+
+ $id = ActionSchedulerUtil::enqueue_async( self::HOOK, array( 'async' ), self::GROUP );
+ $this->assertGreaterThan( 0, $id );
+ $this->assertInstanceOf( \ActionScheduler_NullSchedule::class, $store->fetch_action( $id )->get_schedule() );
+
+ $id = ActionSchedulerUtil::schedule_recurring( time() + HOUR_IN_SECONDS, HOUR_IN_SECONDS, self::HOOK, array( 'recurring' ), self::GROUP );
+ $this->assertGreaterThan( 0, $id );
+ $this->assertInstanceOf( \ActionScheduler_IntervalSchedule::class, $store->fetch_action( $id )->get_schedule() );
+
+ $id = ActionSchedulerUtil::schedule_cron( time() + HOUR_IN_SECONDS, '0 0 * * *', self::HOOK, array( 'cron' ), self::GROUP );
+ $this->assertGreaterThan( 0, $id );
+ $this->assertInstanceOf( \ActionScheduler_CronSchedule::class, $store->fetch_action( $id )->get_schedule() );
+ }
+
+ /**
+ * @testdox On a copy with unique and priority, both are passed through.
+ */
+ public function test_passes_unique_and_priority_natively_on_a_copy_that_supports_both(): void {
+ $this->simulate_loaded_copy( '4.1.0' );
+ $calls = $this->capture_scheduling_calls();
+
+ $this->assertSame( 123, ActionSchedulerUtil::schedule_single( 1000, self::HOOK, array( 'a' ), self::GROUP, true, 5 ) );
+ $this->assertSame( 123, ActionSchedulerUtil::schedule_recurring( 1000, 60, self::HOOK, array(), self::GROUP, false, 200 ) );
+ $this->assertSame( 123, ActionSchedulerUtil::schedule_cron( 1000, '0 0 * * *', self::HOOK, array(), self::GROUP ) );
+ $this->assertSame( 123, ActionSchedulerUtil::enqueue_async( self::HOOK, array( 'b' ), self::GROUP, true, 1 ) );
+
+ $this->assertSame(
+ array(
+ array( 'as_schedule_single_action', 1000, self::HOOK, array( 'a' ), self::GROUP, true, 5 ),
+ array( 'as_schedule_recurring_action', 1000, 60, self::HOOK, array(), self::GROUP, false, 200 ),
+ array( 'as_schedule_cron_action', 1000, '0 0 * * *', self::HOOK, array(), self::GROUP, false, 10 ),
+ array( 'as_enqueue_async_action', self::HOOK, array( 'b' ), self::GROUP, true, 1 ),
+ ),
+ $calls()
+ );
+ }
+
+ /**
+ * @testdox On a copy whose unique check ignores args, unique is emulated with and without args.
+ */
+ public function test_emulates_unique_on_a_copy_whose_unique_check_ignores_args(): void {
+ $this->simulate_loaded_copy( '3.9.3' );
+ $calls = $this->capture_scheduling_calls();
+
+ ActionSchedulerUtil::schedule_single( 1000, self::HOOK, array( 'a' ), self::GROUP, true, 5 );
+ ActionSchedulerUtil::schedule_single( 1000, self::HOOK, array(), self::GROUP, true );
+
+ $this->assertSame(
+ array(
+ array( 'as_has_scheduled_action', self::HOOK, array( 'a' ), self::GROUP ),
+ array( 'as_schedule_single_action', 1000, self::HOOK, array( 'a' ), self::GROUP, false, 5 ),
+ array( 'as_has_scheduled_action', self::HOOK, array(), self::GROUP ),
+ array( 'as_schedule_single_action', 1000, self::HOOK, array(), self::GROUP, false, 10 ),
+ ),
+ $calls(),
+ 'Unique should be emulated (checked, then passed as false) with and without args'
+ );
+ }
+
+ /**
+ * @testdox Emulated unique declines to schedule when a matching action exists.
+ */
+ public function test_emulated_unique_declines_to_schedule_when_a_matching_action_exists(): void {
+ $this->simulate_loaded_copy( '3.9.3' );
+ $calls = $this->capture_scheduling_calls( true );
+
+ $this->assertSame( 0, ActionSchedulerUtil::schedule_single( 1000, self::HOOK, array( 'a' ), self::GROUP, true ) );
+ $this->assertSame(
+ array( array( 'as_has_scheduled_action', self::HOOK, array( 'a' ), self::GROUP ) ),
+ $calls(),
+ 'Nothing should be scheduled when the emulated unique check finds a match'
+ );
+
+ $this->assertSame( 123, ActionSchedulerUtil::schedule_single( 1000, self::HOOK, array( 'a' ), self::GROUP ) );
+ $this->assertSame( 'as_schedule_single_action', $calls()[1][0], 'A non-unique request should be scheduled without a check' );
+ }
+
+ /**
+ * @testdox On a copy without unique, unique is emulated and the argument is not passed.
+ */
+ public function test_emulates_unique_on_a_copy_without_it(): void {
+ $this->simulate_loaded_copy( '3.2.0' );
+ $calls = $this->capture_scheduling_calls();
+
+ $this->assertSame( 123, ActionSchedulerUtil::schedule_single( 1000, self::HOOK, array(), self::GROUP, true ) );
+ $this->assertSame(
+ array(
+ array( 'as_next_scheduled_action', self::HOOK, array(), self::GROUP ),
+ array( 'as_schedule_single_action', 1000, self::HOOK, array(), self::GROUP ),
+ ),
+ $calls(),
+ 'The check should use as_next_scheduled_action on a copy without as_has_scheduled_action, and unique should not be passed'
+ );
+
+ $this->capture_scheduling_calls( true );
+ $this->assertSame( 0, ActionSchedulerUtil::schedule_single( 1000, self::HOOK, array(), self::GROUP, true ) );
+ }
+
+ /**
+ * @testdox On a copy without priority, the priority is dropped and logged once per hook.
+ */
+ public function test_drops_priority_on_a_copy_without_it_and_logs_once_per_hook(): void {
+ $this->simulate_loaded_copy( '3.5.4' );
+ $calls = $this->capture_scheduling_calls();
+ $entries = array();
+ $this->register_legacy_proxy_function_mocks(
+ array(
+ 'wc_get_logger' => function () use ( &$entries ) {
+ return new class( $entries ) {
+ /**
+ * Collected entries.
+ *
+ * @var array
+ */
+ private array $entries;
+
+ /**
+ * Constructor.
+ *
+ * @param array $entries Collected entries, by reference.
+ */
+ public function __construct( array &$entries ) {
+ $this->entries = &$entries;
+ }
+
+ /**
+ * Record a debug entry.
+ *
+ * @param string $message The message.
+ * @param array $context The context.
+ */
+ public function debug( string $message, array $context = array() ): void {
+ $this->entries[] = array( $message, $context );
+ }
+ };
+ },
+ )
+ );
+
+ ActionSchedulerUtil::schedule_single( 1000, self::HOOK, array(), self::GROUP, false, 5 );
+ ActionSchedulerUtil::schedule_single( 1000, self::HOOK, array(), self::GROUP, false, 3 );
+ ActionSchedulerUtil::schedule_recurring( 1000, 60, self::HOOK . '_other', array(), self::GROUP, false, 5 );
+ ActionSchedulerUtil::schedule_single( 1000, self::HOOK . '_default', array(), self::GROUP, false, 10 );
+
+ $this->assertSame( array( 'as_schedule_single_action', 1000, self::HOOK, array(), self::GROUP, false ), $calls()[0], 'The priority should not be passed to a copy without it' );
+ $this->assertCount( 2, $entries, 'One entry per hook with a non-default priority' );
+ $this->assertStringContainsString( 'Priority 5 for action "' . self::HOOK . '"', $entries[0][0] );
+ $this->assertStringContainsString( '3.5.4', $entries[0][0] );
+ $this->assertSame( array( 'source' => 'action-scheduler' ), $entries[0][1] );
+ $this->assertStringContainsString( self::HOOK . '_other', $entries[1][0] );
+ }
+
+ /**
+ * @testdox On a copy without async actions, enqueue_async schedules a single action for now.
+ */
+ public function test_enqueue_async_falls_back_to_a_single_action_for_now_on_a_copy_without_async(): void {
+ $this->simulate_loaded_copy( '2.1.0' );
+ $calls = $this->capture_scheduling_calls();
+ $before = time();
+
+ $this->assertSame( 123, ActionSchedulerUtil::enqueue_async( self::HOOK, array( 'a' ), self::GROUP ) );
+
+ $call = $calls()[0];
+ $this->assertSame( 'as_schedule_single_action', $call[0] );
+ $this->assertEqualsWithDelta( $before, $call[1], 2 );
+ $this->assertSame( array( self::HOOK, array( 'a' ), self::GROUP ), array_slice( $call, 2 ) );
+ }
+
+ /**
+ * @testdox On a copy with async actions but nothing newer, enqueue_async passes only hook, args and group.
+ */
+ public function test_enqueue_async_uses_the_async_function_when_available(): void {
+ $this->simulate_loaded_copy( '3.0.0' );
+ $calls = $this->capture_scheduling_calls();
+
+ $this->assertSame( 123, ActionSchedulerUtil::enqueue_async( self::HOOK, array(), self::GROUP ) );
+ $this->assertSame( array( array( 'as_enqueue_async_action', self::HOOK, array(), self::GROUP ) ), $calls() );
+ }
+
+ /**
+ * @testdox On a copy without as_has_scheduled_action, has_scheduled_action falls back to as_next_scheduled_action.
+ */
+ public function test_has_scheduled_action_falls_back_to_as_next_scheduled_action(): void {
+ $this->simulate_loaded_copy( '3.2.0' );
+ $calls = array();
+ $this->register_legacy_proxy_function_mocks(
+ array(
+ 'as_has_scheduled_action' => function () {
+ $this->fail( 'as_has_scheduled_action should not be called on a copy that lacks it' );
+ },
+ 'as_next_scheduled_action' => function ( ...$args ) use ( &$calls ) {
+ $calls[] = $args;
+ return time() + HOUR_IN_SECONDS;
+ },
+ )
+ );
+
+ $this->assertTrue( ActionSchedulerUtil::has_scheduled_action( self::HOOK, array( 'a' ), self::GROUP ) );
+ $this->assertSame( array( array( self::HOOK, array( 'a' ), self::GROUP ) ), $calls );
+ }
+
+ /**
+ * @testdox has_scheduled_action also looks for a running action when as_next_scheduled_action() finds nothing.
+ */
+ public function test_has_scheduled_action_falls_back_to_a_running_action_lookup(): void {
+ $this->simulate_loaded_copy( '2.2.0' );
+ $queries = array();
+ $this->register_legacy_proxy_function_mocks(
+ array(
+ 'as_next_scheduled_action' => fn() => false,
+ 'as_get_scheduled_actions' => function ( array $query, string $return_format ) use ( &$queries ) {
+ $queries[] = array( $query, $return_format );
+ return array( 123 );
+ },
+ )
+ );
+
+ $this->assertTrue( ActionSchedulerUtil::has_scheduled_action( self::HOOK, array( 'a' ), self::GROUP ) );
+ $this->assertSame(
+ array(
+ array(
+ array(
+ 'hook' => self::HOOK,
+ 'args' => array( 'a' ),
+ 'group' => self::GROUP,
+ 'status' => \ActionScheduler_Store::STATUS_RUNNING,
+ 'per_page' => 1,
+ ),
+ 'ids',
+ ),
+ ),
+ $queries
+ );
+ }
+
+ /**
+ * @testdox has_scheduled_action is false when neither a pending nor a running action exists.
+ */
+ public function test_has_scheduled_action_is_false_without_pending_or_running_actions(): void {
+ $this->simulate_loaded_copy( '2.2.0' );
+ $this->register_legacy_proxy_function_mocks(
+ array(
+ 'as_next_scheduled_action' => fn() => false,
+ 'as_get_scheduled_actions' => fn() => array(),
+ )
+ );
+
+ $this->assertFalse( ActionSchedulerUtil::has_scheduled_action( self::HOOK, array( 'a' ), self::GROUP ) );
+ }
+
+ /**
+ * @testdox when_ready runs the callback at once on a ready copy.
+ */
+ public function test_when_ready_runs_the_callback_at_once_on_a_ready_copy(): void {
+ $ran = 0;
+ ActionSchedulerUtil::when_ready(
+ function () use ( &$ran ) {
+ ++$ran;
+ }
+ );
+
+ $this->assertSame( 1, $ran );
+ }
+
+ /**
+ * @testdox Readiness follows the request phase: the Action Scheduler init hook, or init past priority 1 on older copies.
+ * @dataProvider provider_request_phases
+ *
+ * @param string $version The simulated loaded version.
+ * @param array $phase The simulated request phase, see simulate_request_phase().
+ * @param bool $expected Whether the copy should be reported as ready.
+ */
+ public function test_is_ready_follows_the_request_phase( string $version, array $phase, bool $expected ): void {
+ $this->simulate_loaded_copy( $version );
+ $this->simulate_request_phase( $phase );
+
+ $this->assertSame( $expected, ActionSchedulerUtil::is_ready() );
+ }
+
+ /**
+ * Data provider for test_is_ready_follows_the_request_phase.
+ *
+ * @return array
+ */
+ public function provider_request_phases(): array {
+ $before_init = array(
+ 'init_started' => false,
+ 'doing_init' => false,
+ 'init_priority' => false,
+ 'as_init_fired' => false,
+ );
+ $init_priority_0 = array(
+ 'init_started' => true,
+ 'doing_init' => true,
+ 'init_priority' => 0,
+ 'as_init_fired' => false,
+ );
+ $init_priority_1 = array( 'init_priority' => 1 ) + $init_priority_0;
+ $init_priority_2 = array( 'init_priority' => 2 ) + $init_priority_0;
+ $after_init = array(
+ 'init_started' => true,
+ 'doing_init' => false,
+ 'init_priority' => false,
+ 'as_init_fired' => false,
+ );
+ $as_init_fired = array( 'as_init_fired' => true ) + $init_priority_1;
+
+ return array(
+ '3.2.0 on plugins_loaded' => array( '3.2.0', $before_init, false ),
+ '3.2.0 during init priority 0' => array( '3.2.0', $init_priority_0, false ),
+ '3.2.0 during init priority 1' => array( '3.2.0', $init_priority_1, false ),
+ '3.2.0 during init priority 2' => array( '3.2.0', $init_priority_2, true ),
+ '3.2.0 after init' => array( '3.2.0', $after_init, true ),
+ '3.5.4 during init priority 0' => array( '3.5.4', $init_priority_0, false ),
+ '3.5.4 after init' => array( '3.5.4', $after_init, true ),
+ '4.1.0 during init priority 0, hook pending' => array( '4.1.0', $init_priority_0, false ),
+ '4.1.0 during init priority 1, hook fired' => array( '4.1.0', $as_init_fired, true ),
+ );
+ }
+
+ /**
+ * @testdox when_ready defers on both hooks before init, runs once the copy is ready, and runs only once.
+ */
+ public function test_when_ready_defers_until_the_copy_is_ready_and_runs_once(): void {
+ $this->simulate_loaded_copy( '3.5.4' );
+ $phase = array(
+ 'init_started' => false,
+ 'doing_init' => false,
+ 'init_priority' => false,
+ 'as_init_fired' => false,
+ );
+ $this->simulate_request_phase( $phase );
+ $hooks = $this->capture_add_action_calls();
+
+ $ran = 0;
+ ActionSchedulerUtil::when_ready(
+ function () use ( &$ran ) {
+ ++$ran;
+ }
+ );
+
+ $this->assertSame( 0, $ran );
+ $this->assertSame( array( 'action_scheduler_init', 'init' ), array_keys( $hooks() ) );
+ $this->assertSame( 2, $hooks()['init']['priority'], 'The fallback should run after the data store init at priority 1' );
+
+ $phase['init_started'] = true;
+ $phase['doing_init'] = true;
+ $phase['init_priority'] = 1;
+ $hooks()['init']['callback']();
+ $this->assertSame( 0, $ran, 'The init fallback should not run the callback while the copy is still not ready' );
+
+ $phase['init_priority'] = 2;
+ $hooks()['init']['callback']();
+ $this->assertSame( 1, $ran, 'The init fallback should run the callback once the copy is ready' );
+
+ $hooks()['action_scheduler_init']['callback']();
+ $this->assertSame( 1, $ran, 'The callback should run only once' );
+ }
+
+ /**
+ * @testdox While init is running at priority 0 or 1, when_ready still registers the init fallback.
+ */
+ public function test_when_ready_registers_the_init_fallback_while_init_is_running(): void {
+ $this->simulate_loaded_copy( '3.2.0' );
+ $phase = array(
+ 'init_started' => true,
+ 'doing_init' => true,
+ 'init_priority' => 0,
+ 'as_init_fired' => false,
+ );
+ $this->simulate_request_phase( $phase );
+ $hooks = $this->capture_add_action_calls();
+
+ $ran = 0;
+ ActionSchedulerUtil::when_ready(
+ function () use ( &$ran ) {
+ ++$ran;
+ }
+ );
+
+ $this->assertSame( 0, $ran );
+ $this->assertSame( array( 'action_scheduler_init', 'init' ), array_keys( $hooks() ) );
+ $this->assertSame( 2, $hooks()['init']['priority'] );
+
+ $phase['init_priority'] = 2;
+ $hooks()['init']['callback']();
+ $this->assertSame( 1, $ran );
+ }
+
+ /**
+ * @testdox When Action Scheduler is not loaded after init, when_ready waits for its init hook alone.
+ */
+ public function test_when_ready_waits_for_the_init_hook_when_action_scheduler_loads_late(): void {
+ $loaded = false;
+ $this->register_legacy_proxy_function_mocks(
+ array(
+ 'function_exists' => function ( string $name ) use ( &$loaded ) {
+ return 0 === strpos( $name, 'as_' ) ? $loaded : function_exists( $name );
+ },
+ )
+ );
+ $phase = array(
+ 'init_started' => true,
+ 'doing_init' => false,
+ 'init_priority' => false,
+ 'as_init_fired' => false,
+ );
+ $this->simulate_request_phase( $phase );
+ $hooks = $this->capture_add_action_calls();
+
+ $ran = 0;
+ ActionSchedulerUtil::when_ready(
+ function () use ( &$ran ) {
+ ++$ran;
+ }
+ );
+
+ $this->assertSame( 0, $ran );
+ $this->assertSame( array( 'action_scheduler_init' ), array_keys( $hooks() ) );
+
+ $loaded = true;
+ $phase['as_init_fired'] = true;
+ $hooks()['action_scheduler_init']['callback']();
+ $this->assertSame( 1, $ran );
+ }
+
/**
* @testdox `has_scheduled_action` should return false when no matching action exists.
*/
@@ -107,22 +847,8 @@ class ActionSchedulerUtilTest extends WC_Unit_Test_Case {
}
/**
- * @testdox `can_check_scheduled_actions` should be true when Action Scheduler is loaded.
- */
- public function test_can_check_scheduled_actions_when_action_scheduler_is_loaded(): void {
- $this->assertTrue(
- ActionSchedulerUtil::can_check_scheduled_actions(),
- 'Action Scheduler is loaded in the test suite, so scheduled-action checks should be possible'
- );
- }
-
- /**
- * The helper falls back to `as_next_scheduled_action` when `as_has_scheduled_action` is missing.
- * Neither that branch nor the false branch of `can_check_scheduled_actions` can be exercised here:
- * Action Scheduler is always fully loaded in the test suite, and the function-mocking seam
- * (CodeHacker) only rewrites files under `includes/`, not `src/`. So pin the property the fallback
- * relies on instead: with the bundled Action Scheduler the two functions answer identically, so
- * routing a call site through the fallback is a no-op for every store not running an ancient copy.
+ * With the bundled Action Scheduler the two functions answer identically, so the fallback the
+ * degrade path relies on is a no-op for every store running a current copy.
*
* @testdox The `as_next_scheduled_action` fallback agrees with `as_has_scheduled_action`.
* @dataProvider provider_fallback_equivalence
@@ -161,4 +887,201 @@ class ActionSchedulerUtilTest extends WC_Unit_Test_Case {
'null args as wildcard' => array( array( 'a' ), null, true ),
);
}
+
+ /**
+ * Make the util see a loaded Action Scheduler copy of the given version.
+ *
+ * Writes a plugin file with that version header into a temp directory and mocks, through the
+ * legacy proxy, every probe the util makes: which functions exist, how many parameters the
+ * loaded `as_schedule_single_action()` declares, and the loaded path.
+ *
+ * @param string $version The version to simulate.
+ * @param string|null $path The directory to report as the loaded path, or null for the temp directory.
+ */
+ private function simulate_loaded_copy( string $version, ?string $path = null ): void {
+ $dir = $this->make_fake_copy( $version );
+ $path = $path ?? $dir;
+ $functions = array(
+ 'as_schedule_single_action' => version_compare( $version, '2.1.0', '>=' ),
+ 'as_next_scheduled_action' => version_compare( $version, '2.1.0', '>=' ),
+ 'wc_schedule_single_action' => true,
+ 'as_enqueue_async_action' => version_compare( $version, '3.0.0', '>=' ),
+ 'as_has_scheduled_action' => version_compare( $version, '3.3.0', '>=' ),
+ );
+ $parameters = 4;
+ if ( version_compare( $version, '3.6.0', '>=' ) ) {
+ $parameters = 6;
+ } elseif ( version_compare( $version, '3.5.0', '>=' ) ) {
+ $parameters = 5;
+ }
+
+ $this->register_legacy_proxy_function_mocks( array( 'function_exists' => fn( string $name ) => $functions[ $name ] ?? function_exists( $name ) ) );
+ $this->register_legacy_proxy_class_mocks( array( \ReflectionFunction::class => fn() => $this->reflection_with_parameters( $parameters ) ) );
+ $this->register_legacy_proxy_static_mocks( array( 'ActionScheduler' => array( 'plugin_path' => fn( string $relative ) => $relative ? "$path/$relative" : $path ) ) );
+ ActionSchedulerUtil::reset_cache();
+ }
+
+ /**
+ * Mock, through the legacy proxy, the request phase the util reads to answer is_ready().
+ *
+ * @param array $phase By reference, so tests can advance it. Keys: `init_started` (did_action('init')),
+ * `doing_init` (doing_action('init')), `init_priority` (WP_Hook::current_priority()
+ * for init, int or false) and `as_init_fired` (did_action('action_scheduler_init')).
+ */
+ private function simulate_request_phase( array &$phase ): void {
+ $this->register_legacy_proxy_function_mocks(
+ array(
+ 'did_action' => function ( string $hook ) use ( &$phase ) {
+ if ( 'action_scheduler_init' === $hook ) {
+ return (int) $phase['as_init_fired'];
+ }
+ return 'init' === $hook ? (int) $phase['init_started'] : did_action( $hook );
+ },
+ 'doing_action' => function ( string $hook ) use ( &$phase ) {
+ return 'init' === $hook ? $phase['doing_init'] : doing_action( $hook );
+ },
+ )
+ );
+ $this->register_legacy_proxy_global_mocks(
+ array(
+ 'wp_filter' => array(
+ 'init' => new class( $phase ) {
+ /**
+ * The simulated phase.
+ *
+ * @var array
+ */
+ private array $phase;
+
+ /**
+ * Constructor.
+ *
+ * @param array $phase The simulated phase, by reference.
+ */
+ public function __construct( array &$phase ) {
+ $this->phase = &$phase;
+ }
+
+ /**
+ * Mirror of WP_Hook::current_priority().
+ *
+ * @return int|false
+ */
+ public function current_priority() {
+ return $this->phase['init_priority'];
+ }
+ },
+ ),
+ )
+ );
+ }
+
+ /**
+ * Mock add_action() through the legacy proxy so registrations are recorded instead of made.
+ *
+ * @return callable Returns the recorded registrations: hook => array( 'callback' => callable, 'priority' => int ).
+ */
+ private function capture_add_action_calls(): callable {
+ $hooks = array();
+ $this->register_legacy_proxy_function_mocks(
+ array(
+ 'add_action' => function ( string $hook, callable $callback, int $priority = 10 ) use ( &$hooks ) {
+ $hooks[ $hook ] = array(
+ 'callback' => $callback,
+ 'priority' => $priority,
+ );
+ return true;
+ },
+ )
+ );
+
+ return function () use ( &$hooks ) {
+ return $hooks;
+ };
+ }
+
+ /**
+ * A stand-in for the reflection of the loaded `as_schedule_single_action()`.
+ *
+ * @param int $count The number of parameters to report.
+ *
+ * @return object
+ */
+ private function reflection_with_parameters( int $count ): object {
+ return new class( $count ) {
+ /**
+ * The number of parameters to report.
+ *
+ * @var int
+ */
+ private int $count;
+
+ /**
+ * Constructor.
+ *
+ * @param int $count The number of parameters to report.
+ */
+ public function __construct( int $count ) {
+ $this->count = $count;
+ }
+
+ /**
+ * Mirror of ReflectionFunction::getNumberOfParameters().
+ *
+ * @return int
+ */
+ public function getNumberOfParameters(): int { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
+ return $this->count;
+ }
+ };
+ }
+
+ /**
+ * Write a fake Action Scheduler plugin file with the given version header.
+ *
+ * @param string $version The version header value.
+ *
+ * @return string The directory containing the file, without a trailing slash.
+ */
+ private function make_fake_copy( string $version ): string {
+ $dir = untrailingslashit( get_temp_dir() ) . '/wc-as-util-' . wp_generate_password( 8, false );
+ wp_mkdir_p( $dir );
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+ file_put_contents( $dir . '/action-scheduler.php', "<?php\n/**\n * Plugin Name: Action Scheduler\n * Version: {$version}\n */\n" );
+ $this->fake_copies[] = $dir;
+
+ return $dir;
+ }
+
+ /**
+ * Mock the Action Scheduler scheduling functions so calls are recorded instead of executed.
+ *
+ * @param bool $already_scheduled What the scheduled-action checks should report.
+ *
+ * @return callable Returns the recorded calls, each as the function name followed by its arguments.
+ */
+ private function capture_scheduling_calls( bool $already_scheduled = false ): callable {
+ $calls = array();
+ $mocks = array(
+ 'as_has_scheduled_action' => function ( ...$args ) use ( &$calls, $already_scheduled ) {
+ $calls[] = array_merge( array( 'as_has_scheduled_action' ), $args );
+ return $already_scheduled;
+ },
+ 'as_next_scheduled_action' => function ( ...$args ) use ( &$calls, $already_scheduled ) {
+ $calls[] = array_merge( array( 'as_next_scheduled_action' ), $args );
+ return $already_scheduled ? time() + HOUR_IN_SECONDS : false;
+ },
+ );
+ foreach ( array( 'as_enqueue_async_action', 'as_schedule_single_action', 'as_schedule_recurring_action', 'as_schedule_cron_action' ) as $function ) {
+ $mocks[ $function ] = function ( ...$args ) use ( &$calls, $function ) {
+ $calls[] = array_merge( array( $function ), $args );
+ return 123;
+ };
+ }
+ $this->register_legacy_proxy_function_mocks( $mocks );
+
+ return function () use ( &$calls ) {
+ return $calls;
+ };
+ }
}