From 1687acd6a85605aa4d15127e6083a39787d610cd Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 7 Oct 2026 18:22:35 +0200 Subject: [PATCH] feat: make #[Action] methods asynchronous with #[Async] --- CHANGELOG.md | 2 + rector.php | 2 + src/Attributes/Action.php | 27 ++- src/Attributes/Async.php | 53 +++++ .../Providers/HookServiceProvider.php | 4 +- .../Services/AsyncAttributeRegistrar.php | 186 ++++++++++++++++++ .../Infrastructure/Services/HookDiscovery.php | 28 ++- .../Feature/Hook/Async/AsyncAttributeTest.php | 185 +++++++++++++++++ .../Async/Fixtures/AsyncAttributeFixtures.php | 97 +++++++++ .../plugins/e2e-features/app/AsyncQueue.php | 42 ++++ tests/e2e/specs/async.spec.ts | 40 ++++ tests/e2e/support/site.ts | 18 ++ 12 files changed, 677 insertions(+), 7 deletions(-) create mode 100644 src/Attributes/Async.php create mode 100644 src/Hook/Infrastructure/Services/AsyncAttributeRegistrar.php create mode 100644 tests/Feature/Hook/Async/AsyncAttributeTest.php create mode 100644 tests/Feature/Hook/Async/Fixtures/AsyncAttributeFixtures.php create mode 100644 tests/e2e/fixtures/plugins/e2e-features/app/AsyncQueue.php create mode 100644 tests/e2e/specs/async.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 015eb606..19e2a55d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Asynchronous actions in the framework (pollora/hook 1.4): `Action::add(...)->async()` can queue handlers as Laravel jobs. The `queue` driver dispatches a `RunAsyncAction` job on `hooks.async.queue.connection` (the default connection when null) and on the action's `onQueue()` or `hooks.async.queue.queue`. `auto`, the default, now tries the Laravel queue first, then Action Scheduler, then WP-Cron; a `sync` or `null` connection is always skipped. The job is tried once: retries are new jobs queued after the backoff, and the last failure lands in the failed jobs table. It also clears WordPress's in-memory cache before each handler, since a worker lives across jobs - `config/hooks.php`, published with `php artisan vendor:publish --tag=pollora-hooks`: default driver (`HOOKS_ASYNC_DRIVER`, `sync` in a developer's `.env` runs every handler at once), attempts, backoff, `as_user`, queue connection and name +- `#[Async]`, next to `#[Action]`, makes a method asynchronous with the options of `->async()` as named parameters: `delay`, `via`, `onQueue`, `unique` (`true` or the lock duration), `tries`, `backoff`, `asUser`, `capture` and `when` (public methods of the class), `keepMissing`, `except`. On a class it applies to every `#[Action]` method, and a method's own `#[Async]` replaces it. A declaration that cannot be honoured (a hook in `except` the method does not declare, a missing or non-public `capture`/`when` method, invalid attempts, backoff or lock) is logged and the action runs synchronously; `#[Async]` on a `#[Filter]` or without `#[Action]` is logged - Async handlers in the framework: debug mode follows `app.debug`, incidents go to the Laravel log, closures are signed with the application key, Eloquent models travel by class and key and are reloaded at execution, and parameters typed with a service are resolved from the container ### Fixed +- Errors met while registering hooks discovered by attribute went to no logger: `HookDiscovery` now receives the application's logger - Login screen: the language switcher's label ("Language" and its icon, shown when a second language is installed) sat at the left edge of the window, far from its dropdown. The card style given to the login form also reached the switcher's form and made it a full-width block; it is an inline block again, as in WordPress's own sheet - Login screen: the rules for the buttons were written `.pollora-login .wp-core-ui …`, but both classes are on ``, so they never matched. The language switcher's button kept wp-admin's blue instead of the theme's primary colour, and the submit button kept WordPress's `button-large` padding diff --git a/rector.php b/rector.php index bfb229f9..79d51b3f 100644 --- a/rector.php +++ b/rector.php @@ -41,6 +41,8 @@ // Binding sources each broken on purpose (an untyped field, a private // one): fixing them would remove what the tests check is refused. __DIR__.'/tests/Unit/BlockBinding/Fixtures/Invalid.php', + // Hook classes with empty, unused or private methods on purpose: they carry the attributes under test. + __DIR__.'/tests/Feature/Hook/Async/Fixtures/AsyncAttributeFixtures.php', ]) ->withPreparedSets( deadCode: true, diff --git a/src/Attributes/Action.php b/src/Attributes/Action.php index 0bafe195..0e1eae4b 100755 --- a/src/Attributes/Action.php +++ b/src/Attributes/Action.php @@ -6,6 +6,8 @@ use Attribute; use Pollora\Hook\Domain\Contract\Action as ActionService; +use Pollora\Hook\Infrastructure\Services\AsyncAttributeRegistrar; +use Psr\Log\LoggerInterface; use ReflectionClass; use ReflectionMethod; @@ -38,11 +40,32 @@ public function handle( return; } - $actionService->add( + // #[Action] targets methods only + if (! $context instanceof ReflectionMethod) { + return; + } + + // Asynchronously when the method or its class carries #[Async] + (new AsyncAttributeRegistrar($actionService, $this->logger($serviceLocator)))->register( $attribute->hook, - [$instance, $context->getName()], + $instance, + $context, $attribute->priority, $context->getNumberOfParameters() ); } + + /** + * The application logger, when the service locator provides one. + */ + private function logger(object $serviceLocator): ?LoggerInterface + { + try { + $logger = $serviceLocator->get(LoggerInterface::class); + } catch (\Throwable) { + return null; + } + + return $logger instanceof LoggerInterface ? $logger : null; + } } diff --git a/src/Attributes/Async.php b/src/Attributes/Async.php new file mode 100644 index 00000000..fb36b91c --- /dev/null +++ b/src/Attributes/Async.php @@ -0,0 +1,53 @@ +async(): #[Action] keeps the hook and + * the priority, #[Async] carries the deferred-execution options. + * + * #[Action('save_post_event')] + * #[Async(delay: 60, unique: true, tries: 3)] + * public function syncToCrm(int $postId): void {} + * + * On a class, it applies to every #[Action] method; an #[Async] on a method + * replaces the class options for that method. With several #[Action] on a + * method, it applies to all of them, except the hooks listed in $except. + */ +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] +final readonly class Async +{ + /** + * @param int|\DateInterval|null $delay Minimum delay before execution, in seconds + * @param string|null $via Driver: queue, action-scheduler, wp-cron, sync + * @param string|null $onQueue Queue name, the group with Action Scheduler + * @param bool|int $unique Merge identical triggers until the first one runs; an integer sets how long the lock lasts, in seconds + * @param int|null $tries Attempts when the handler throws + * @param int|list|null $backoff Seconds before each retry + * @param bool|null $asUser Run as the user who fired the hook + * @param string|null $capture Public method of the class recording values at trigger time + * @param string|null $when Public method of the class deciding, at trigger time, whether to queue + * @param bool $keepMissing Run with null in place of a referenced object deleted in the meantime + * @param string|list $except Hooks of the method that stay synchronous + */ + public function __construct( + public int|\DateInterval|null $delay = null, + public ?string $via = null, + public ?string $onQueue = null, + public bool|int $unique = false, + public ?int $tries = null, + public int|array|null $backoff = null, + public ?bool $asUser = null, + public ?string $capture = null, + public ?string $when = null, + public bool $keepMissing = false, + public string|array $except = [], + ) {} +} diff --git a/src/Hook/Infrastructure/Providers/HookServiceProvider.php b/src/Hook/Infrastructure/Providers/HookServiceProvider.php index dcc08479..61a64ce6 100755 --- a/src/Hook/Infrastructure/Providers/HookServiceProvider.php +++ b/src/Hook/Infrastructure/Providers/HookServiceProvider.php @@ -16,6 +16,7 @@ use Pollora\Hook\Infrastructure\Services\HookDiscovery; use Pollora\Hook\UI\Console\ActionMakeCommand; use Pollora\Hook\UI\Console\FilterMakeCommand; +use Psr\Log\LoggerInterface; /** * Service provider for Hook feature (Infrastructure layer). @@ -76,7 +77,8 @@ public function register(): void // Register Hook Discovery $this->app->singleton(HookDiscovery::class, fn (Application $app): HookDiscovery => new HookDiscovery( $app->make(ActionContract::class), - $app->make(FilterContract::class) + $app->make(FilterContract::class), + $app->bound(LoggerInterface::class) ? $app->make(LoggerInterface::class) : null )); if ($this->consoleDetectionService->isConsole()) { diff --git a/src/Hook/Infrastructure/Services/AsyncAttributeRegistrar.php b/src/Hook/Infrastructure/Services/AsyncAttributeRegistrar.php new file mode 100644 index 00000000..4cc6e430 --- /dev/null +++ b/src/Hook/Infrastructure/Services/AsyncAttributeRegistrar.php @@ -0,0 +1,186 @@ +getAttributes(Async::class) ?: $method->getDeclaringClass()->getAttributes(Async::class); + + return $attributes === [] ? null : $attributes[0]->newInstance(); + } + + /** + * Whether the method itself carries #[Async]. + */ + public static function isDeclaredOn(ReflectionMethod $method): bool + { + return $method->getAttributes(Async::class) !== []; + } + + /** + * Register one hook of an #[Action] method. + * + * @param int|null $acceptedArgs Null to detect it from the method + */ + public function register(string $hook, object $instance, ReflectionMethod $method, int $priority, ?int $acceptedArgs = null): void + { + $this->actions->add($hook, [$instance, $method->getName()], $priority, $acceptedArgs); + + $attribute = self::attributeFor($method); + + if (! $attribute instanceof Async) { + return; + } + + $problem = $this->problem($method, $attribute); + + if ($problem !== null) { + $this->logger?->error(sprintf('#[Async] on %s::%s() is ignored, the action runs synchronously: %s', $method->getDeclaringClass()->getName(), $method->getName(), $problem)); + + return; + } + + if (in_array($hook, (array) $attribute->except, true)) { + return; + } + + $this->configure($this->actions->async(), $attribute, $instance); + } + + /** + * Why a declaration cannot be honoured, or null. + */ + private function problem(ReflectionMethod $method, Async $attribute): ?string + { + $declaredOnMethod = self::isDeclaredOn($method); + $hooks = $this->actionHooks($method, $declaredOnMethod); + + $unknown = array_values(array_diff((array) $attribute->except, $hooks)); + if ($unknown !== []) { + return sprintf("except names '%s', which is not a hook of its #[Action] (%s)", implode("', '", $unknown), implode(', ', $hooks)); + } + + foreach (['capture' => $attribute->capture, 'when' => $attribute->when] as $option => $name) { + if ($name !== null && ! $this->isPublicMethod($method->getDeclaringClass(), $name)) { + return sprintf("%s names '%s', which is not a public method of the class", $option, $name); + } + } + + try { + if ($attribute->tries !== null) { + PendingAsync::validTries($attribute->tries); + } + + if ($attribute->backoff !== null) { + PendingAsync::validBackoff($attribute->backoff); + } + } catch (\InvalidArgumentException $invalidArgumentException) { + return $invalidArgumentException->getMessage(); + } + + if (is_int($attribute->unique) && $attribute->unique < 1) { + return sprintf('unique expects true or a lock of at least 1 second, %d given', $attribute->unique); + } + + return null; + } + + /** + * Hooks of the #[Action] attributes of the method, or of the whole class for a class-level #[Async]. + * + * @return list + */ + private function actionHooks(ReflectionMethod $method, bool $declaredOnMethod): array + { + $methods = $declaredOnMethod ? [$method] : $method->getDeclaringClass()->getMethods(ReflectionMethod::IS_PUBLIC); + $hooks = []; + + foreach ($methods as $candidate) { + foreach ($candidate->getAttributes(Action::class) as $action) { + $hooks[] = $action->newInstance()->hook; + } + } + + return array_values(array_unique($hooks)); + } + + /** + * @param \ReflectionClass $class + */ + private function isPublicMethod(\ReflectionClass $class, string $name): bool + { + return $class->hasMethod($name) && $class->getMethod($name)->isPublic(); + } + + private function configure(PendingAsync $pending, Async $attribute, object $instance): void + { + if ($attribute->delay !== null) { + $pending->delay($attribute->delay); + } + + if ($attribute->via !== null) { + $pending->via($attribute->via); + } + + if ($attribute->onQueue !== null) { + $pending->onQueue($attribute->onQueue); + } + + if ($attribute->unique === true) { + $pending->unique(); + } elseif (is_int($attribute->unique)) { + $pending->unique($attribute->unique); + } + + if ($attribute->tries !== null) { + $pending->tries($attribute->tries); + } + + if ($attribute->backoff !== null) { + $pending->backoff($attribute->backoff); + } + + if ($attribute->asUser !== null) { + $pending->asUser($attribute->asUser); + } + + if ($attribute->capture !== null) { + $pending->capture([$instance, $attribute->capture]); + } + + if ($attribute->when !== null) { + $pending->when([$instance, $attribute->when]); + } + + if ($attribute->keepMissing) { + $pending->keepMissing(); + } + } +} diff --git a/src/Hook/Infrastructure/Services/HookDiscovery.php b/src/Hook/Infrastructure/Services/HookDiscovery.php index d03fef43..d765cd17 100644 --- a/src/Hook/Infrastructure/Services/HookDiscovery.php +++ b/src/Hook/Infrastructure/Services/HookDiscovery.php @@ -5,6 +5,7 @@ namespace Pollora\Hook\Infrastructure\Services; use Pollora\Attributes\Action; +use Pollora\Attributes\Async; use Pollora\Attributes\Filter; use Pollora\Discovery\Domain\Contracts\DiscoveryInterface; use Pollora\Discovery\Domain\Contracts\DiscoveryLocationInterface; @@ -88,6 +89,18 @@ public function discover(DiscoveryLocationInterface $location, DiscoveredStructu 'reflection_method' => $method, ]); } + + // An #[Async] with neither #[Action] nor #[Filter] has nothing to apply to + $asyncAttributes = $method->getAttributes(Async::class); + if ($asyncAttributes !== [] && $actionAttributes === [] && $filterAttributes === []) { + $this->getItems()->add($location, [ + 'type' => 'async', + 'class' => $className, + 'method' => $method->getName(), + 'attribute' => $asyncAttributes[0], + 'reflection_method' => $method, + ]); + } } } catch (\Throwable) { // Skip classes that can't be reflected @@ -118,17 +131,24 @@ public function apply(): void /** @var Action $action */ $action = $hookAttribute->newInstance(); - // Create instance and call method directly + // Create instance and call method directly; asynchronously when #[Async] says so $instance = $this->getInstanceFromPool($className); - $this->actionService->add( - hooks: $action->hook, - callback: [$instance, $methodName], + (new AsyncAttributeRegistrar($this->actionService, $this->logger))->register( + hook: $action->hook, + instance: $instance, + method: $reflectionMethod, priority: $action->priority ); + } elseif ($hookType === 'async') { + $this->logger?->error(sprintf('#[Async] on %s::%s() is ignored: the method has no #[Action] to make asynchronous.', ltrim($className, '\\'), $methodName)); } elseif ($hookType === 'filter') { /** @var Filter $filter */ $filter = $hookAttribute->newInstance(); + if (AsyncAttributeRegistrar::isDeclaredOn($reflectionMethod)) { + $this->logger?->error(sprintf('#[Async] on %s::%s() is ignored: a filter returns a value to its caller and cannot be deferred.', ltrim($className, '\\'), $methodName)); + } + // Create instance and call method directly $instance = $this->getInstanceFromPool($className); $this->filterService->add( diff --git a/tests/Feature/Hook/Async/AsyncAttributeTest.php b/tests/Feature/Hook/Async/AsyncAttributeTest.php new file mode 100644 index 00000000..a1c2a448 --- /dev/null +++ b/tests/Feature/Hook/Async/AsyncAttributeTest.php @@ -0,0 +1,185 @@ +fake = Async::fake(); + $this->actions = new Action; + $this->logger = new class extends AbstractLogger + { + /** @var list */ + public array $errors = []; + + public function log($level, string|Stringable $message, array $context = []): void + { + $this->errors[] = (string) $message; + } + }; + $this->registrar = new AsyncAttributeRegistrar($this->actions, $this->logger); + $this->register = function (string $class, string $method, ?string $hook = null): void { + $reflection = new ReflectionMethod($class, $method); + foreach ($reflection->getAttributes(ActionAttribute::class) as $action) { + $action = $action->newInstance(); + if ($hook === null || $hook === $action->hook) { + $this->registrar->register($action->hook, new $class, $reflection, $action->priority); + } + } + }; +}); + +afterEach(function (): void { + Async::flush(); +}); + +/** + * The callback registered on a hook, as WordPress would call it. + */ +function registeredCallback(Action $actions, string $hook): mixed +{ + return $actions->callbacks($hook)[0]['callback'] ?? null; +} + +it('makes an #[Action] method asynchronous with the options of its #[Async]', function (): void { + ($this->register)(AsyncAttributeHooks::class, 'syncToCrm'); + + $queued = registeredCallback($this->actions, 'save_post_event'); + expect($queued)->toBeInstanceOf(QueuedHandler::class) + ->and($queued->priority)->toBe(20); + + $queued(42, null); + + Async::assertDispatched(AsyncAttributeHooks::class.'@syncToCrm', fn (AsyncPayload $payload, int $delay): bool => $delay === 60 + && $payload->driver === 'queue' + && $payload->queue === 'integrations' + && $payload->uniqueKey !== null + && $payload->tries === 3 + && $payload->backoff === [5] + && $payload->asUser + && $payload->keepMissing); +}); + +it('keeps the hooks listed in except synchronous', function (): void { + ($this->register)(AsyncAttributeHooks::class, 'handle'); + + expect(registeredCallback($this->actions, 'save_post_event'))->toBeInstanceOf(QueuedHandler::class) + ->and(registeredCallback($this->actions, 'save_post_page'))->toBeArray() + ->and($this->logger->errors)->toBe([]); +}); + +it('records values with the capture method and queues only when the when method agrees', function (): void { + ($this->register)(AsyncAttributeHooks::class, 'notify'); + $queued = registeredCallback($this->actions, 'transition_post_status'); + + $queued('draft', 'pending'); + $queued('publish', 'draft'); + + Async::assertDispatchedTimes(AsyncAttributeHooks::class, 1); + Async::assertDispatched(AsyncAttributeHooks::class, fn (AsyncPayload $payload): bool => $payload->captured === ['from' => 'draft']); +}); + +it('registers a method without #[Async] synchronously', function (): void { + ($this->register)(AsyncAttributeHooks::class, 'synchronous'); + + expect(registeredCallback($this->actions, 'wp_loaded'))->toBeArray(); +}); + +it('applies a class #[Async] to every #[Action] method, a method #[Async] replacing it', function (): void { + ($this->register)(AsyncAttributeClassHooks::class, 'welcome'); + ($this->register)(AsyncAttributeClassHooks::class, 'audit'); + + registeredCallback($this->actions, 'user_register')(1); + registeredCallback($this->actions, 'profile_update')(1); + + Async::assertDispatched(AsyncAttributeClassHooks::class.'@welcome', fn (AsyncPayload $payload): bool => $payload->tries === 2); + Async::assertDispatched(AsyncAttributeClassHooks::class.'@audit', fn (AsyncPayload $payload): bool => $payload->tries === 5); +}); + +it('registers synchronously, and logs why, a declaration it cannot honour', function (string $method, string $reason): void { + ($this->register)(AsyncAttributeInvalidHooks::class, $method); + + expect(registeredCallback($this->actions, 'save_post'))->toBeArray() + ->and($this->logger->errors)->toHaveCount(1) + ->and($this->logger->errors[0])->toContain(sprintf('#[Async] on %s::%s() is ignored, the action runs synchronously', AsyncAttributeInvalidHooks::class, $method)) + ->and($this->logger->errors[0])->toContain($reason); +})->with([ + 'unknown hook in except' => ['typo', "except names 'save_psot', which is not a hook of its #[Action] (save_post)"], + 'missing capture method' => ['missingCapture', "capture names 'missing', which is not a public method of the class"], + 'private when method' => ['privateCondition', "when names 'hidden', which is not a public method of the class"], + 'no attempt' => ['noAttempt', 'at least 1 attempt'], + 'negative backoff' => ['negativeBackoff', 'one or more delays'], + 'empty lock' => ['emptyLock', 'unique expects true or a lock of at least 1 second, 0 given'], +]); + +describe('Discovery', function (): void { + beforeEach(function (): void { + $this->discovery = new HookDiscovery($this->actions, new Filter, $this->logger); + $this->discovery->discover(new DiscoveryLocation('', __DIR__), DiscoveredClass::fromReflection(new ReflectionClass(AsyncAttributeHooks::class)), new ReflectionCache); + $this->discovery->apply(); + }); + + it('registers #[Action] methods asynchronously or not, as declared', function (): void { + expect(registeredCallback($this->actions, 'transition_post_status'))->toBeInstanceOf(QueuedHandler::class) + ->and(registeredCallback($this->actions, 'wp_loaded'))->toBeArray(); + }); + + it('logs an #[Async] on a filter, which still registers, and an #[Async] without #[Action]', function (): void { + expect($this->logger->errors)->toContain(sprintf('#[Async] on %s::filterContent() is ignored: a filter returns a value to its caller and cannot be deferred.', AsyncAttributeHooks::class)) + ->and($this->logger->errors)->toContain(sprintf('#[Async] on %s::orphan() is ignored: the method has no #[Action] to make asynchronous.', AsyncAttributeHooks::class)); + }); +}); + +describe('Action::handle()', function (): void { + it('registers through the registrar, asynchronously when #[Async] says so', function (): void { + $locator = new readonly class($this->actions, $this->logger) + { + public function __construct(private ActionContract $actions, private AbstractLogger $logger) {} + + public function get(string $id): mixed + { + return $id === ActionContract::class ? $this->actions : $this->logger; + } + }; + $method = new ReflectionMethod(AsyncAttributeHooks::class, 'notify'); + $attribute = new ActionAttribute('transition_post_status'); + + $attribute->handle($locator, new AsyncAttributeHooks, $method, $attribute); + + expect(registeredCallback($this->actions, 'transition_post_status'))->toBeInstanceOf(QueuedHandler::class); + }); + + it('works without a logger in the service locator, and ignores a class context', function (): void { + $locator = new readonly class($this->actions) + { + public function __construct(private ActionContract $actions) {} + + public function get(string $id): mixed + { + return $id === ActionContract::class ? $this->actions : throw new RuntimeException('Not bound'); + } + }; + $attribute = new ActionAttribute('save_post'); + + $attribute->handle($locator, new AsyncAttributeInvalidHooks, new ReflectionMethod(AsyncAttributeInvalidHooks::class, 'typo'), $attribute); + $attribute->handle($locator, new AsyncAttributeHooks, new ReflectionClass(AsyncAttributeHooks::class), new ActionAttribute('init')); + + expect(registeredCallback($this->actions, 'save_post'))->toBeArray() + ->and($this->actions->callbacks('init'))->toBeNull(); + }); +}); diff --git a/tests/Feature/Hook/Async/Fixtures/AsyncAttributeFixtures.php b/tests/Feature/Hook/Async/Fixtures/AsyncAttributeFixtures.php new file mode 100644 index 00000000..43b40f79 --- /dev/null +++ b/tests/Feature/Hook/Async/Fixtures/AsyncAttributeFixtures.php @@ -0,0 +1,97 @@ + + */ + public function captureTransition(string $new, string $old): array + { + return ['from' => $old]; + } + + public function isPublished(string $new): bool + { + return $new === 'publish'; + } + + #[FilterAttribute('the_content')] + #[AsyncAttribute] + public function filterContent(string $content): string + { + return $content; + } + + #[AsyncAttribute] + public function orphan(): void {} +} + +#[AsyncAttribute(tries: 2)] +final class AsyncAttributeClassHooks +{ + #[ActionAttribute('user_register')] + public function welcome(int $userId): void {} + + #[ActionAttribute('profile_update')] + #[AsyncAttribute(tries: 5)] + public function audit(int $userId): void {} +} + +final class AsyncAttributeInvalidHooks +{ + #[ActionAttribute('save_post')] + #[AsyncAttribute(except: 'save_psot')] + public function typo(int $postId): void {} + + #[ActionAttribute('save_post')] + #[AsyncAttribute(capture: 'missing')] + public function missingCapture(int $postId): void {} + + #[ActionAttribute('save_post')] + #[AsyncAttribute(when: 'hidden')] + public function privateCondition(int $postId): void {} + + #[ActionAttribute('save_post')] + #[AsyncAttribute(tries: 0)] + public function noAttempt(int $postId): void {} + + #[ActionAttribute('save_post')] + #[AsyncAttribute(backoff: [-1])] + public function negativeBackoff(int $postId): void {} + + #[ActionAttribute('save_post')] + #[AsyncAttribute(unique: 0)] + public function emptyLock(int $postId): void {} + + private function hidden(): bool + { + return true; + } +} diff --git a/tests/e2e/fixtures/plugins/e2e-features/app/AsyncQueue.php b/tests/e2e/fixtures/plugins/e2e-features/app/AsyncQueue.php new file mode 100644 index 00000000..4c1c453c --- /dev/null +++ b/tests/e2e/fixtures/plugins/e2e-features/app/AsyncQueue.php @@ -0,0 +1,42 @@ + true]); + } + + #[Action('e2e_async_event')] + #[Async(via: 'queue')] + public function handle(int $id, AsyncContext $context, LoggerInterface $logger): void + { + update_option('e2e_async_result', [ + 'id' => $id, + 'hook' => $context->hook, + 'console' => app()->runningInConsole(), + 'injected' => $logger instanceof LoggerInterface, + ], false); + } +} diff --git a/tests/e2e/specs/async.spec.ts b/tests/e2e/specs/async.spec.ts new file mode 100644 index 00000000..88167e7a --- /dev/null +++ b/tests/e2e/specs/async.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@wordpress/e2e-test-utils-playwright'; +import { request as playwrightRequest } from '@playwright/test'; +import { artisan, wp } from '../support/site'; + +/** + * Asynchronous actions: an #[Action] made asynchronous by #[Async(via: 'queue')] + * is queued as a Laravel job when its hook fires, and runs in a queue worker. + */ +test.describe('Asynchronous actions', () => { + const result = (): unknown => { + try { + return JSON.parse(wp('option', 'get', 'e2e_async_result', '--format=json')); + } catch { + return null; + } + }; + + test.beforeEach(() => { + try { + wp('option', 'delete', 'e2e_async_result'); + } catch { + // Not there yet + } + // Leave no job of an earlier run behind + artisan('queue:clear', '--force'); + }); + + test('an #[Action] with #[Async] runs in a Laravel queue worker, not in the request', async () => { + const visitor = await playwrightRequest.newContext({ ignoreHTTPSErrors: true, storageState: { cookies: [], origins: [] } }); + const response = await visitor.get(new URL('wp-admin/admin-ajax.php?action=e2e_async_dispatch&id=42', process.env.WP_BASE_URL).toString()); + + expect(await response.json()).toEqual({ success: true, data: { queued: true } }); + expect(result()).toBeNull(); + await visitor.dispose(); + + artisan('queue:work', '--once', '--stop-when-empty'); + + expect(result()).toEqual({ id: 42, hook: 'e2e_async_event', console: true, injected: true }); + }); +}); diff --git a/tests/e2e/support/site.ts b/tests/e2e/support/site.ts index 099d04d4..3ee38b19 100644 --- a/tests/e2e/support/site.ts +++ b/tests/e2e/support/site.ts @@ -25,6 +25,24 @@ export function wp(...args: string[]): string { } } +/** + * Run an Artisan command against the site under test: E2E_ARTISAN ("ddev exec php artisan" by default), + * from E2E_SITE_DIR. + */ +export function artisan(...args: string[]): string { + const [command, ...prefix] = (process.env.E2E_ARTISAN ?? 'ddev exec php artisan').split(' '); + + try { + return execFileSync(command, [...prefix, ...args], { + cwd: process.env.E2E_SITE_DIR ?? process.cwd(), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + } catch (error) { + throw new Error(`Artisan failed: ${command} ${prefix.join(' ')} ${args.join(' ')}\n${(error as { stderr?: string }).stderr ?? ''}`); + } +} + /** * The template that rendered a page, from the marker the framework writes under WP_DEBUG. */