From 81d331514a8ae30fb55a8141ae7210430164a9e9 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 21 Oct 2022 12:32:46 +0200 Subject: [PATCH 01/90] Implement exception if missing Queue config key. Fixes #102 --- src/Plugin.php | 7 +++++ tests/TestCase/PluginTest.php | 55 +++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 tests/TestCase/PluginTest.php diff --git a/src/Plugin.php b/src/Plugin.php index 20cfd91..d761d4b 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -50,6 +50,13 @@ class Plugin extends BasePlugin */ public function bootstrap(PluginApplicationInterface $app): void { + if (!Configure::read('Queue')) { + throw new \InvalidArgumentException( + 'Missing `Queue` configuration key, please check the CakePHP Queue documentation' . + ' to complete the plugin setup.' + ); + } + foreach (Configure::read('Queue') as $key => $data) { if (QueueManager::getConfig($key) === null) { QueueManager::setConfig($key, $data); diff --git a/tests/TestCase/PluginTest.php b/tests/TestCase/PluginTest.php new file mode 100644 index 0000000..9a8eb87 --- /dev/null +++ b/tests/TestCase/PluginTest.php @@ -0,0 +1,55 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Missing `Queue` configuration key, please check the CakePHP Queue documentation to complete the plugin setup'); + Configure::delete('Queue'); + $plugin = new Plugin(); + $app = $this->getMockBuilder(Application::class)->disableOriginalConstructor()->getMock(); + $plugin->bootstrap($app); + } + + /** + * Test Plugin bootstrap with config + * + * @return void + */ + public function testBootstrapWithConfig() + { + $queueConfig = [ + 'url' => 'null:', + 'queue' => 'default', + 'logger' => 'stdout', + ]; + Configure::write('Queue', ['default' => $queueConfig]); + $plugin = new Plugin(); + $app = $this->getMockBuilder(Application::class)->disableOriginalConstructor()->getMock(); + $plugin->bootstrap($app); + $queueConfig['url'] = [ + 'transport' => 'null:', + 'client' => [ + 'router_topic' => 'default', + 'router_queue' => 'default', + 'default_queue' => 'default', + ], + ]; + $this->assertEquals($queueConfig, QueueManager::getConfig('default')); + } +} From 5646a4e2ab5ca613ebbd0faa7e74b760447a1f0e Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 28 Oct 2022 11:30:45 +0200 Subject: [PATCH 02/90] Implement QueueTransport to enqueue emails automatically Fixes #64 --- src/Job/SendMailJob.php | 44 ++++++++++++++++ src/Mailer/Transport/QueueTransport.php | 67 +++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 src/Job/SendMailJob.php create mode 100644 src/Mailer/Transport/QueueTransport.php diff --git a/src/Job/SendMailJob.php b/src/Job/SendMailJob.php new file mode 100644 index 0000000..4273ac9 --- /dev/null +++ b/src/Job/SendMailJob.php @@ -0,0 +1,44 @@ +getArgument('transport'); + $config = $message->getArgument('config'); + $emailMessage = unserialize($message->getArgument('emailMessage')); + try { + /** @var \Cake\Mailer\AbstractTransport $transport */ + $transport = new $transportClassName($config); + $result = $transport->send($emailMessage); + } catch (\Exception $e) { + return Processor::REJECT; + } + + return Processor::ACK; + } +} diff --git a/src/Mailer/Transport/QueueTransport.php b/src/Mailer/Transport/QueueTransport.php new file mode 100644 index 0000000..2db1755 --- /dev/null +++ b/src/Mailer/Transport/QueueTransport.php @@ -0,0 +1,67 @@ + + */ + protected $_defaultConfig = [ + 'options' => [], + 'transport' => MailTransport::class, + ]; + + /** + * @inheritDoc + */ + public function send(Message $message): array + { + QueueManager::push( + [SendMailJob::class, 'execute'], + [ + 'transport' => $this->getConfig('transport'), + 'config' => $this->getConfig(), + 'emailMessage' => serialize($message), + ], + $this->getConfig('options') + ); + + $headers = $message->getHeadersString( + [ + 'from', + 'to', + 'subject', + 'sender', + 'replyTo', + 'readReceipt', + 'returnPath', + 'cc', + 'bcc', + ], + ); + + return ['headers' => $headers, 'message' => 'Message has been enqueued']; + } +} From 04da6389879bd5d85917d1e856ba105ff5fb0fd2 Mon Sep 17 00:00:00 2001 From: Umer Salman Date: Mon, 31 Oct 2022 21:42:32 -0500 Subject: [PATCH 03/90] Config documentation update (typo and dsn example) (#107) * Fix typo (overriden --> overridden) * Clarify DSN/update example. Fix confusing language for thinking 'url' only contains the protocol, not the full DSN, through a more extensive example. * Update example with a full DSN --- docs/en/index.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/en/index.rst b/docs/en/index.rst index 0387c25..40f088e 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -41,10 +41,11 @@ The following configuration should be present in the config array of your **conf 'Queue' => [ 'default' => [ // A DSN for your configured backend. default: null - 'url' => 'redis:', + // Can contain protocol/port/username/password or be null if the backend defaults to localhost + 'url' => 'redis://myusername:mypassword@example.com:1000', // The queue that will be used for sending messages. default: default - // This can be overriden when queuing or processing messages + // This can be overridden when queuing or processing messages 'queue' => 'default', // The name of a configured logger, default: null From ad2d3c73716efd24caf9d654fbf9e5c38faeafff Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 4 Nov 2022 13:01:11 +0100 Subject: [PATCH 04/90] Implement unit tests and do some refactor to new QueueTransport and SendMailJob --- src/Job/SendMailJob.php | 45 ++++++- src/Mailer/Transport/QueueTransport.php | 45 +++++-- tests/TestCase/Job/SendMailJobTest.php | 112 ++++++++++++++++++ .../Mailer/Transport/QueueTransportTest.php | 70 +++++++++++ 4 files changed, 257 insertions(+), 15 deletions(-) create mode 100644 tests/TestCase/Job/SendMailJobTest.php create mode 100644 tests/TestCase/Mailer/Transport/QueueTransportTest.php diff --git a/src/Job/SendMailJob.php b/src/Job/SendMailJob.php index 4273ac9..cdfe60b 100644 --- a/src/Job/SendMailJob.php +++ b/src/Job/SendMailJob.php @@ -16,6 +16,8 @@ */ namespace Cake\Queue\Job; +use Cake\Log\Log; +use Cake\Mailer\AbstractTransport; use Cake\Queue\Queue\Processor; /** @@ -28,17 +30,50 @@ class SendMailJob implements JobInterface */ public function execute(Message $message): ?string { - $transportClassName = $message->getArgument('transport'); - $config = $message->getArgument('config'); - $emailMessage = unserialize($message->getArgument('emailMessage')); + $result = false; try { + $transportClassName = $message->getArgument('transport'); + $config = $message->getArgument('config', []); /** @var \Cake\Mailer\AbstractTransport $transport */ - $transport = new $transportClassName($config); + $transport = $this->getTransport($transportClassName, $config); + + $emailMessage = unserialize($message->getArgument('emailMessage')); $result = $transport->send($emailMessage); } catch (\Exception $e) { - return Processor::REJECT; + Log::error(sprintf('An error has occurred processing message: %s', $e->getMessage())); + } finally { + if (!$result) { + return Processor::REJECT; + } } return Processor::ACK; } + + /** + * Initialize transport + * + * @param string $transportClassName Transport class name + * @param array $config Transport config + * @return \Cake\Mailer\AbstractTransport + * @throws \InvalidArgumentException if empty transport class name, class does not exist or send method is not defined for class + */ + protected function getTransport(string $transportClassName, array $config): AbstractTransport + { + if ( + empty($transportClassName) || + !class_exists($transportClassName) || + !method_exists($transportClassName, 'send') + ) { + throw new \InvalidArgumentException(sprintf('Transport class name is not valid: %s', $transportClassName)); + } + + $transport = new $transportClassName($config); + + if (!($transport instanceof AbstractTransport)) { + throw new \InvalidArgumentException('Provided class does not extend AbstractTransport.'); + } + + return $transport; + } } diff --git a/src/Mailer/Transport/QueueTransport.php b/src/Mailer/Transport/QueueTransport.php index 2db1755..bda2b06 100644 --- a/src/Mailer/Transport/QueueTransport.php +++ b/src/Mailer/Transport/QueueTransport.php @@ -38,15 +38,9 @@ class QueueTransport extends \Cake\Mailer\AbstractTransport */ public function send(Message $message): array { - QueueManager::push( - [SendMailJob::class, 'execute'], - [ - 'transport' => $this->getConfig('transport'), - 'config' => $this->getConfig(), - 'emailMessage' => serialize($message), - ], - $this->getConfig('options') - ); + $data = $this->prepareData($message); + $options = $this->getConfig('options'); + $this->enqueueJob($data, $options); $headers = $message->getHeadersString( [ @@ -59,9 +53,40 @@ public function send(Message $message): array 'returnPath', 'cc', 'bcc', - ], + ] ); return ['headers' => $headers, 'message' => 'Message has been enqueued']; } + + /** + * Add job to queue + * + * @param array $data Data to be sent to job + * @param array $options Job options + * @return void + */ + protected function enqueueJob(array $data, array $options): void + { + QueueManager::push( + [SendMailJob::class, 'execute'], + $data, + $options + ); + } + + /** + * Prepare data for job + * + * @param \Cake\Mailer\Message $message Email message + * @return array + */ + protected function prepareData(Message $message): array + { + return [ + 'transport' => $this->getConfig('transport'), + 'config' => $this->getConfig(), + 'emailMessage' => serialize($message), + ]; + } } diff --git a/tests/TestCase/Job/SendMailJobTest.php b/tests/TestCase/Job/SendMailJobTest.php new file mode 100644 index 0000000..4f56d41 --- /dev/null +++ b/tests/TestCase/Job/SendMailJobTest.php @@ -0,0 +1,112 @@ +job = new SendMailJob(); + $this->message = (new \Cake\Mailer\Message()) + ->setFrom('from@example.com') + ->setTo('to@example.com') + ->setSubject('Sample Subject'); + } + + /** + * Test execute method + * + * @return void + */ + public function testExecute() + { + $message = $this->createMessage(DebugTransport::class, [], $this->message); + $actual = $this->job->execute($message); + $this->assertSame(Processor::ACK, $actual); + } + + /** + * Test execute method with invalid transport + * + * @return void + */ + public function testExecuteInvalidTransport() + { + $message = $this->createMessage('WrongTransport', [], $this->message); + $actual = $this->job->execute($message); + $this->assertSame(Processor::REJECT, $actual); + } + + /** + * Test execute method with unserializable message + * + * @return void + */ + public function testExecuteUnserializableMessage() + { + $message = $this->createMessage(DebugTransport::class, [], 'unserializable'); + $actual = $this->job->execute($message); + $this->assertSame(Processor::REJECT, $actual); + } + + /** + * Create a simple message for testing. + * + * @return \Cake\Queue\Job\Message + */ + protected function createMessage($transport, $config, $emailMessage): Message + { + $messageBody = [ + 'class' => ['Queue\\Job\\SendMailJob', 'execute'], + 'data' => [ + 'transport' => $transport, + 'config' => $config, + 'emailMessage' => serialize($emailMessage), + + ], + ]; + $connectionFactory = new NullConnectionFactory(); + $context = $connectionFactory->createContext(); + $originalMessage = new NullMessage(json_encode($messageBody)); + $message = new Message($originalMessage, $context); + + return $message; + } +} diff --git a/tests/TestCase/Mailer/Transport/QueueTransportTest.php b/tests/TestCase/Mailer/Transport/QueueTransportTest.php new file mode 100644 index 0000000..c2858ba --- /dev/null +++ b/tests/TestCase/Mailer/Transport/QueueTransportTest.php @@ -0,0 +1,70 @@ +setFrom('from@example.com') + ->setTo('to@example.com') + ->setSubject('Sample Subject'); + + $transport = $this + ->getMockBuilder(QueueTransport::class)->onlyMethods(['enqueueJob']) + ->getMock(); + $expectedData = [ + 'transport' => 'Cake\Mailer\Transport\MailTransport', + 'config' => [ + 'options' => [], + 'transport' => 'Cake\Mailer\Transport\MailTransport', + ], + 'emailMessage' => serialize($message), + ]; + $expectedOptions = []; + $transport->expects($this->once()) + ->method('enqueueJob') + ->with($expectedData, $expectedOptions); + $result = $transport->send($message); + + $headers = $message->getHeadersString( + [ + 'from', + 'to', + 'subject', + 'sender', + 'replyTo', + 'readReceipt', + 'returnPath', + 'cc', + 'bcc', + ] + ); + + $expected = ['headers' => $headers, 'message' => 'Message has been enqueued']; + $this->assertEquals($expected, $result); + } +} From 2271a55015492114239441d841d3ba7024b1970f Mon Sep 17 00:00:00 2001 From: Sheldon Reiff Date: Mon, 17 Oct 2022 15:14:43 -0400 Subject: [PATCH 05/90] Add failed jobs and requeue/delete commands --- composer.json | 3 +- .../20221007202459_CreateFailedJobs.php | 58 +++++ docs/en/index.rst | 74 +++++++ phpunit.xml.dist | 7 + src/Command/PurgeFailedCommand.php | 128 +++++++++++ src/Command/RequeueCommand.php | 161 ++++++++++++++ src/Command/WorkerCommand.php | 7 +- src/Consumption/LimitAttemptsExtension.php | 15 +- src/Job/Message.php | 2 +- src/Listener/FailedJobsListener.php | 101 +++++++++ src/Model/Entity/FailedJob.php | 52 +++++ src/Model/Table/FailedJobsTable.php | 96 +++++++++ src/Plugin.php | 6 +- src/Queue/Processor.php | 3 +- src/QueueManager.php | 5 + tests/Fixture/FailedJobsFixture.php | 85 ++++++++ .../Command/PurgeFailedCommandTest.php | 120 +++++++++++ tests/TestCase/Command/RequeueCommandTest.php | 204 ++++++++++++++++++ .../LimitAttemptsExtensionTest.php | 20 ++ tests/TestCase/DebugLogTrait.php | 2 +- .../Listener/FailedJobsListenerTest.php | 95 ++++++++ tests/TestCase/Queue/ProcessorTest.php | 2 +- tests/bootstrap.php | 20 ++ .../src/Job/MaxAttemptsIsThreeJob.php | 3 +- 24 files changed, 1260 insertions(+), 9 deletions(-) create mode 100644 config/Migrations/20221007202459_CreateFailedJobs.php create mode 100644 src/Command/PurgeFailedCommand.php create mode 100644 src/Command/RequeueCommand.php create mode 100644 src/Listener/FailedJobsListener.php create mode 100644 src/Model/Entity/FailedJob.php create mode 100644 src/Model/Table/FailedJobsTable.php create mode 100644 tests/Fixture/FailedJobsFixture.php create mode 100644 tests/TestCase/Command/PurgeFailedCommandTest.php create mode 100644 tests/TestCase/Command/RequeueCommandTest.php create mode 100644 tests/TestCase/Listener/FailedJobsListenerTest.php diff --git a/composer.json b/composer.json index 0702200..7e788c3 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,8 @@ } }, "suggest": { - "cakephp/bake": "Required if you want to generate jobs." + "cakephp/bake": "Required if you want to generate jobs.", + "cakephp/migrations": "Needed for running the migrations necessary for using Failed Jobs." }, "scripts": { "check": [ diff --git a/config/Migrations/20221007202459_CreateFailedJobs.php b/config/Migrations/20221007202459_CreateFailedJobs.php new file mode 100644 index 0000000..a8ae8dc --- /dev/null +++ b/config/Migrations/20221007202459_CreateFailedJobs.php @@ -0,0 +1,58 @@ +table('queue_failed_jobs'); + $table->addColumn('class', 'string', [ + 'length' => 255, + 'null' => false, + 'default' => null, + ]) + ->addColumn('method', 'string', [ + 'length' => 255, + 'null' => false, + 'default' => null, + ]) + ->addColumn('data', 'text', [ + 'null' => false, + 'default' => null, + ]) + ->addColumn('config', 'string', [ + 'length' => 255, + 'null' => false, + 'default' => null, + ]) + ->addColumn('priority', 'string', [ + 'length' => 255, + 'null' => true, + 'default' => null, + ]) + ->addColumn('queue', 'string', [ + 'length' => 255, + 'null' => false, + 'default' => null, + ]) + ->addColumn('exception', 'text', [ + 'null' => true, + 'default' => null, + ]) + ->addColumn('created', 'datetime', [ + 'default' => null, + 'limit' => null, + 'null' => true, + ]) + ->create(); + } +} diff --git a/docs/en/index.rst b/docs/en/index.rst index 0387c25..7c4b58d 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -55,6 +55,9 @@ The following configuration should be present in the config array of your **conf // The amount of time in milliseconds to sleep if no jobs are currently available. default: 10000 'receiveTimeout' => 10000, + + // Whether to store failed jobs in the queue_failed_jobs table. default: false + 'storeFailedJobs' => true, ] ], // ... @@ -62,6 +65,21 @@ The following configuration should be present in the config array of your **conf The ``Queue`` config key can contain one or more queue configurations. Each of these is used for interacting with a different queuing backend. +If ``storeFailedJobs`` is set to ``true``, make sure to run the plugin migrations to create the ``queue_failed_jobs`` table. + +Install the migrations plugin: + +.. code-block:: bash + + composer require cakephp/migrations:"^3.1" + +Run the migrations: + +.. code-block:: bash + + bin/cake migrations migrate --plugin Cake/Queue + + Usage ===== @@ -266,6 +284,62 @@ This shell can take a few different options: - Max Runtime - Runtime: Time since the worker started, the worker will finish when Runtime is over Max Runtime value +Failed Jobs +=========== + +By default, jobs that throw an exception are requeued indefinitely. However, if +``maxAttempts`` is configured on the job class or via a command line argument, a +job will be considered failed if a ``Processor::REQUEUE`` response is received +after processing (typically due to an exception being thrown) and there are no +remaining attempts. The job will then be rejected and added to the +``queue_failed_jobs`` table and can be requeued manually. + +Your chosen transport may offer a dead-letter queue feature. While Failed Jobs +has a similar purpose, it specifically captures jobs that return a +``Processor::REQUEUE`` response and does not handle other failure cases. It is +agnostic of transport and only supports database persistence. + +The following options passed when originally queueing the job will be preserved: +``config``, ``queue``, and ``priority``. + +Requeue Failed Jobs +------------------- + +Push jobs back onto the queue and remove them from the ``queue_failed_jobs`` +table. If a job fails to requeue it is not guaranteed that the job was not run. + +.. code-block:: bash + + bin/cake queue requeue + +Optional filters: + +- ``--id``: Requeue job by the ID of the ``FailedJob`` +- ``--class``: Requeue jobs by the job class +- ``--queue``: Requeue jobs by the queue the job was received on +- ``--config``: Requeue jobs by the config used to queue the job + +If no filters are provided then all failed jobs will be requeued. + +Purge Failed Jobs +------------------ + +Delete jobs from the ``queue_failed_jobs`` table. + +.. code-block:: bash + + bin/cake queue purge_failed + +Optional filters: + +- ``--id``: Purge job by the ID of the ``FailedJob`` +- ``--class``: Purge jobs by the job class +- ``--queue``: Purge jobs by the queue the job was received on +- ``--config``: Purge jobs by the config used to queue the job + +If no filters are provided then all failed jobs will be purged. + + Worker Events ============= diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 5ab59dd..3481e82 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -15,4 +15,11 @@ tests/TestCase + + + + + + + diff --git a/src/Command/PurgeFailedCommand.php b/src/Command/PurgeFailedCommand.php new file mode 100644 index 0000000..a6b7602 --- /dev/null +++ b/src/Command/PurgeFailedCommand.php @@ -0,0 +1,128 @@ +setDescription('Delete failed jobs.'); + + $parser->addArgument('ids', [ + 'required' => false, + 'help' => 'Delete jobs by the FailedJob ID (comma-separated).', + ]); + $parser->addOption('class', [ + 'help' => 'Delete jobs by the job class.', + ]); + $parser->addOption('queue', [ + 'help' => 'Delete jobs by the queue the job was received on.', + ]); + $parser->addOption('config', [ + 'help' => 'Delete jobs by the config used to queue the job.', + ]); + $parser->addOption('force', [ + 'help' => 'Automatically assume yes in response to confirmation prompt.', + 'short' => 'f', + 'boolean' => true, + ]); + + return $parser; + } + + /** + * @param \Cake\Console\Arguments $args Arguments + * @param \Cake\Console\ConsoleIo $io ConsoleIo + * @return void + */ + public function execute(Arguments $args, ConsoleIo $io) + { + /** @var \Cake\Queue\Model\Table\FailedJobsTable $failedJobsTable */ + $failedJobsTable = $this->getTableLocator()->get('Cake/Queue.FailedJobs'); + + $jobsToDelete = $failedJobsTable->find(); + + if ($args->hasArgument('ids')) { + $idsArg = $args->getArgument('ids'); + + if ($idsArg !== null) { + $ids = explode(',', $idsArg); + + $jobsToDelete->whereInList($failedJobsTable->aliasField('id'), $ids); + } + } + + if ($args->hasOption('class')) { + $jobsToDelete->where(['class' => $args->getOption('class')]); + } + + if ($args->hasOption('queue')) { + $jobsToDelete->where(['queue' => $args->getOption('queue')]); + } + + if ($args->hasOption('config')) { + $jobsToDelete->where(['config' => $args->getOption('config')]); + } + + $deletingCount = $jobsToDelete->count(); + + if (!$deletingCount) { + $io->out('0 jobs found.'); + + return; + } + + if (!$args->getOption('force')) { + $confirmed = $io->askChoice("Delete {$deletingCount} jobs?", ['y', 'n'], 'n'); + + if ($confirmed !== 'y') { + return; + } + } + + $io->out("Deleting {$deletingCount} jobs."); + + $failedJobsTable->deleteManyOrFail($jobsToDelete); + + $io->success("{$deletingCount} jobs deleted."); + } +} diff --git a/src/Command/RequeueCommand.php b/src/Command/RequeueCommand.php new file mode 100644 index 0000000..ea76d94 --- /dev/null +++ b/src/Command/RequeueCommand.php @@ -0,0 +1,161 @@ +setDescription('Requeue failed jobs.'); + + $parser->addArgument('ids', [ + 'required' => false, + 'help' => 'Requeue jobs by the FailedJob ID (comma-separated).', + ]); + $parser->addOption('class', [ + 'help' => 'Requeue jobs by the job class.', + ]); + $parser->addOption('queue', [ + 'help' => 'Requeue jobs by the queue the job was received on.', + ]); + $parser->addOption('config', [ + 'help' => 'Requeue jobs by the config used to queue the job.', + ]); + $parser->addOption('force', [ + 'help' => 'Automatically assume yes in response to confirmation prompt.', + 'short' => 'f', + 'boolean' => true, + ]); + + return $parser; + } + + /** + * @param \Cake\Console\Arguments $args Arguments + * @param \Cake\Console\ConsoleIo $io ConsoleIo + * @return void + */ + public function execute(Arguments $args, ConsoleIo $io) + { + /** @var \Cake\Queue\Model\Table\FailedJobsTable $failedJobsTable */ + $failedJobsTable = $this->getTableLocator()->get('Cake/Queue.FailedJobs'); + + $jobsToRequeue = $failedJobsTable->find(); + + if ($args->hasArgument('ids')) { + $idsArg = $args->getArgument('ids'); + + if ($idsArg !== null) { + $ids = explode(',', $idsArg); + + $jobsToRequeue->whereInList('id', $ids); + } + } + + if ($args->hasOption('class')) { + $jobsToRequeue->where(['class' => $args->getOption('class')]); + } + + if ($args->hasOption('queue')) { + $jobsToRequeue->where(['queue' => $args->getOption('queue')]); + } + + if ($args->hasOption('config')) { + $jobsToRequeue->where(['config' => $args->getOption('config')]); + } + + $requeueingCount = $jobsToRequeue->count(); + + if (!$requeueingCount) { + $io->out('0 jobs found.'); + + return; + } + + if (!$args->getOption('force')) { + $confirmed = $io->askChoice("Requeue {$requeueingCount} jobs?", ['y', 'n'], 'n'); + + if ($confirmed !== 'y') { + return; + } + } + + $io->out("Requeueing {$requeueingCount} jobs."); + + $succeededCount = 0; + $failedCount = 0; + + foreach ($jobsToRequeue as $failedJob) { + $io->verbose("Requeueing FailedJob with ID {$failedJob->id}."); + try { + QueueManager::push( + [$failedJob->class, $failedJob->method], + $failedJob->decoded_data, + [ + 'config' => $failedJob->config, + 'priority' => $failedJob->priority, + 'queue' => $failedJob->queue, + ] + ); + + $failedJobsTable->deleteOrFail($failedJob); + + $succeededCount++; + } catch (Exception $e) { + $io->err("Exception occurred while requeueing FailedJob with ID {$failedJob->id}"); + $io->err((string)$e); + + $failedCount++; + } + } + + if ($failedCount) { + $io->err("Failed to requeue {$failedCount} jobs."); + } + + if ($succeededCount) { + $io->success("{$succeededCount} jobs requeued."); + } + } +} diff --git a/src/Command/WorkerCommand.php b/src/Command/WorkerCommand.php index 5e95860..ed335ff 100644 --- a/src/Command/WorkerCommand.php +++ b/src/Command/WorkerCommand.php @@ -24,6 +24,7 @@ use Cake\Log\Log; use Cake\Queue\Consumption\LimitAttemptsExtension; use Cake\Queue\Consumption\LimitConsumedMessagesExtension; +use Cake\Queue\Listener\FailedJobsListener; use Cake\Queue\Queue\Processor; use Cake\Queue\QueueManager; use DateTime; @@ -109,9 +110,13 @@ public function getOptionParser(): ConsoleOptionParser */ protected function getQueueExtension(Arguments $args, LoggerInterface $logger): ExtensionInterface { + $limitAttempsExtension = new LimitAttemptsExtension((int)$args->getOption('max-attempts') ?: null); + + $limitAttempsExtension->getEventManager()->on(new FailedJobsListener()); + $extensions = [ new LoggerExtension($logger), - new LimitAttemptsExtension((int)$args->getOption('max-attempts') ?: null), + $limitAttempsExtension, ]; if (!is_null($args->getOption('max-jobs'))) { diff --git a/src/Consumption/LimitAttemptsExtension.php b/src/Consumption/LimitAttemptsExtension.php index bc85281..c7d19f9 100644 --- a/src/Consumption/LimitAttemptsExtension.php +++ b/src/Consumption/LimitAttemptsExtension.php @@ -3,6 +3,7 @@ namespace Cake\Queue\Consumption; +use Cake\Event\EventDispatcherTrait; use Cake\Queue\Job\Message; use Enqueue\Consumption\Context\MessageResult; use Enqueue\Consumption\MessageResultExtensionInterface; @@ -11,6 +12,8 @@ class LimitAttemptsExtension implements MessageResultExtensionInterface { + use EventDispatcherTrait; + /** * The property key used to set the number of times a message was attempted. * @@ -41,7 +44,7 @@ public function __construct(?int $maxAttempts = null) */ public function onResult(MessageResult $context): void { - if ($context->getResult() !== Processor::REQUEUE) { + if ($context->getResult() != Processor::REQUEUE) { return; } @@ -58,10 +61,20 @@ public function onResult(MessageResult $context): void $attemptNumber = $message->getProperty(self::ATTEMPTS_PROPERTY, 0) + 1; if ($attemptNumber >= $maxAttempts) { + $originalResult = $context->getResult(); + $context->changeResult( Result::reject(sprintf('The maximum number of %d allowed attempts was reached.', $maxAttempts)) ); + $exception = $originalResult instanceof Result ? $originalResult->getReason() : null; + + $this->dispatchEvent( + 'Consumption.LimitAttemptsExtension.failed', + ['exception' => $exception, 'logger' => $context->getLogger()], + $jobMessage + ); + return; } diff --git a/src/Job/Message.php b/src/Job/Message.php index 200dcf6..de73f81 100644 --- a/src/Job/Message.php +++ b/src/Job/Message.php @@ -106,7 +106,7 @@ public function getCallable() * * @return array */ - protected function getTarget(): array + public function getTarget(): array { $target = $this->parsedBody['class'] ?? null; diff --git a/src/Listener/FailedJobsListener.php b/src/Listener/FailedJobsListener.php new file mode 100644 index 0000000..7e26fd2 --- /dev/null +++ b/src/Listener/FailedJobsListener.php @@ -0,0 +1,101 @@ + + */ + public function implementedEvents(): array + { + return [ + 'Consumption.LimitAttemptsExtension.failed' => 'storeFailedJob', + ]; + } + + /** + * @param \Cake\Event\EventInterface $event EventInterface + * @return void + */ + public function storeFailedJob(EventInterface $event): void + { + /** @var \Cake\Queue\Job\Message $jobMessage */ + $jobMessage = $event->getSubject(); + + [$class, $method] = $jobMessage->getTarget(); + + $originalMessage = $jobMessage->getOriginalMessage(); + + $originalMessageBody = json_decode($originalMessage->getBody(), true); + + ['data' => $data, 'requeueOptions' => $requeueOptions] = $originalMessageBody; + + $config = QueueManager::getConfig($requeueOptions['config']); + + if (!($config['storeFailedJobs'] ?? false)) { + return; + } + + /** @var \Cake\Queue\Model\Table\FailedJobsTable $failedJobsTable */ + $failedJobsTable = $this->getTableLocator()->get('Cake/Queue.FailedJobs'); + + $failedJob = $failedJobsTable->newEntity([ + 'class' => $class, + 'method' => $method, + 'data' => json_encode($data), + 'config' => $requeueOptions['config'], + 'priority' => $requeueOptions['priority'], + 'queue' => $requeueOptions['queue'], + 'exception' => $event->getData('exception'), + ]); + + try { + $failedJobsTable->saveOrFail($failedJob); + } catch (PersistenceFailedException $e) { + $logger = $event->getData('logger'); + + if (!$logger) { + throw new RuntimeException( + sprintf('`logger` was not defined on %s event.', $event->getName()), + 0, + $e + ); + } + + if (!($logger instanceof LoggerInterface)) { + throw new RuntimeException( + sprintf('`logger` is not an instance of `LoggerInterface` on %s event.', $event->getName()), + 0, + $e + ); + } + + $logger->error((string)$e); + } + } +} diff --git a/src/Model/Entity/FailedJob.php b/src/Model/Entity/FailedJob.php new file mode 100644 index 0000000..dcf6b90 --- /dev/null +++ b/src/Model/Entity/FailedJob.php @@ -0,0 +1,52 @@ + true` + * means that any field not defined in the map will be accessible by default + * + * @var array + */ + protected $_accessible = [ + 'class' => true, + 'method' => true, + 'data' => true, + 'config' => true, + 'priority' => true, + 'queue' => true, + 'exception' => true, + 'created' => true, + ]; + + /** + * @return array + */ + protected function _getDecodedData(): array + { + return json_decode($this->data, true); + } +} diff --git a/src/Model/Table/FailedJobsTable.php b/src/Model/Table/FailedJobsTable.php new file mode 100644 index 0000000..4358a0d --- /dev/null +++ b/src/Model/Table/FailedJobsTable.php @@ -0,0 +1,96 @@ +setTable('queue_failed_jobs'); + $this->setDisplayField('id'); + $this->setPrimaryKey('id'); + + $this->addBehavior('Timestamp'); + } + + /** + * Default validation rules. + * + * @param \Cake\Validation\Validator $validator Validator instance. + * @return \Cake\Validation\Validator + */ + public function validationDefault(Validator $validator): Validator + { + $validator + ->integer('id') + ->allowEmptyString('id', null, 'create'); + + $validator + ->scalar('class') + ->maxLength('class', 255) + ->requirePresence('class', 'create') + ->notEmptyString('class'); + + $validator + ->scalar('method') + ->maxLength('method', 255) + ->requirePresence('method', 'create') + ->notEmptyString('method'); + + $validator + ->scalar('data') + ->requirePresence('data', 'create') + ->notEmptyString('data'); + + $validator + ->scalar('config') + ->maxLength('config', 255) + ->notEmptyString('config'); + + $validator + ->scalar('priority') + ->maxLength('priority', 255) + ->allowEmptyString('priority'); + + $validator + ->scalar('queue') + ->maxLength('queue', 255) + ->notEmptyString('queue'); + + $validator + ->scalar('exception') + ->allowEmptyString('exception'); + + return $validator; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 20cfd91..ee938da 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -21,6 +21,8 @@ use Cake\Core\Configure; use Cake\Core\PluginApplicationInterface; use Cake\Queue\Command\JobCommand; +use Cake\Queue\Command\PurgeFailedCommand; +use Cake\Queue\Command\RequeueCommand; use Cake\Queue\Command\WorkerCommand; /** @@ -71,6 +73,8 @@ public function console(CommandCollection $commands): CommandCollection return $commands ->add('queue worker', WorkerCommand::class) - ->add('worker', WorkerCommand::class); + ->add('worker', WorkerCommand::class) + ->add('queue requeue', RequeueCommand::class) + ->add('queue purge_failed', PurgeFailedCommand::class); } } diff --git a/src/Queue/Processor.php b/src/Queue/Processor.php index b092f57..89d01a4 100644 --- a/src/Queue/Processor.php +++ b/src/Queue/Processor.php @@ -18,6 +18,7 @@ use Cake\Event\EventDispatcherTrait; use Cake\Queue\Job\Message; +use Enqueue\Consumption\Result; use Error; use Exception; use Interop\Queue\Context; @@ -78,7 +79,7 @@ public function process(QueueMessage $message, Context $context) 'exception' => $e, ]); - return InteropProcessor::REQUEUE; + return Result::requeue(sprintf('Exception occurred while processing message: %s', (string)$e)); } if ($response === InteropProcessor::ACK) { diff --git a/src/QueueManager.php b/src/QueueManager.php index 2c1c6b0..8f9de11 100644 --- a/src/QueueManager.php +++ b/src/QueueManager.php @@ -216,6 +216,11 @@ public static function push($className, array $data = [], array $options = []): 'class' => [$class, $method], 'args' => [$data], 'data' => $data, + 'requeueOptions' => [ + 'config' => $name, + 'priority' => $options['priority'] ?? null, + 'queue' => $queue, + ], ]); if (isset($options['delay'])) { diff --git a/tests/Fixture/FailedJobsFixture.php b/tests/Fixture/FailedJobsFixture.php new file mode 100644 index 0000000..2f0da38 --- /dev/null +++ b/tests/Fixture/FailedJobsFixture.php @@ -0,0 +1,85 @@ + ['type' => 'integer', 'length' => null, 'unsigned' => false, 'null' => false, 'default' => null, 'comment' => '', 'autoIncrement' => true, 'precision' => null], + 'class' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], + 'method' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], + 'data' => ['type' => 'text', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], + 'config' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], + 'priority' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], + 'queue' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], + 'exception' => ['type' => 'text', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], + 'created' => ['type' => 'datetime', 'length' => null, 'precision' => null, 'null' => true, 'default' => null, 'comment' => ''], + '_constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id'], 'length' => []], + ], + '_options' => [ + 'engine' => 'InnoDB', + ], + ]; + // phpcs:enable + /** + * Init method + * + * @return void + */ + public function init(): void + { + $this->records = [ + [ + 'id' => 1, + 'class' => LogToDebugJob::class, + 'method' => 'execute', + 'data' => '{"sample_data_1": "sample value", "sample_data_2": 1}', + 'config' => 'default', + 'priority' => null, + 'queue' => 'default', + 'exception' => 'Lorem ipsum dolor sit amet, aliquet feugiat. Convallis morbi fringilla gravida, phasellus feugiat dapibus velit nunc, pulvinar eget sollicitudin venenatis cum nullam, vivamus ut a sed, mollitia lectus. Nulla vestibulum massa neque ut et, id hendrerit sit, feugiat in taciti enim proin nibh, tempor dignissim, rhoncus duis vestibulum nunc mattis convallis.', + 'created' => '2022-10-11 18:42:29', + ], + [ + 'id' => 2, + 'class' => MaxAttemptsIsThreeJob::class, + 'method' => 'execute', + 'data' => '{"sample_data_1": "sample value", "sample_data_2": 1}', + 'config' => 'default', + 'priority' => null, + 'queue' => 'default', + 'exception' => 'Lorem ipsum dolor sit amet, aliquet feugiat. Convallis morbi fringilla gravida, phasellus feugiat dapibus velit nunc, pulvinar eget sollicitudin venenatis cum nullam, vivamus ut a sed, mollitia lectus. Nulla vestibulum massa neque ut et, id hendrerit sit, feugiat in taciti enim proin nibh, tempor dignissim, rhoncus duis vestibulum nunc mattis convallis.', + 'created' => '2022-10-11 18:42:29', + ], + [ + 'id' => 3, + 'class' => LogToDebugJob::class, + 'method' => 'execute', + 'data' => '{"sample_data_1": "sample value", "sample_data_2": 1}', + 'config' => 'alternate_config', + 'priority' => null, + 'queue' => 'alternate_queue', + 'exception' => 'Lorem ipsum dolor sit amet, aliquet feugiat. Convallis morbi fringilla gravida, phasellus feugiat dapibus velit nunc, pulvinar eget sollicitudin venenatis cum nullam, vivamus ut a sed, mollitia lectus. Nulla vestibulum massa neque ut et, id hendrerit sit, feugiat in taciti enim proin nibh, tempor dignissim, rhoncus duis vestibulum nunc mattis convallis.', + 'created' => '2022-10-11 18:42:29', + ], + ]; + parent::init(); + } +} diff --git a/tests/TestCase/Command/PurgeFailedCommandTest.php b/tests/TestCase/Command/PurgeFailedCommandTest.php new file mode 100644 index 0000000..af7a1b2 --- /dev/null +++ b/tests/TestCase/Command/PurgeFailedCommandTest.php @@ -0,0 +1,120 @@ +useCommandRunner(); + } + + public function testFailedJobsAreDeleted() + { + $this->exec('queue purge_failed', ['y']); + + $this->assertOutputContains('Deleting 3 jobs.'); + $this->assertOutputContains('3 jobs deleted.'); + + $results = $this->getTableLocator()->get('Cake/Queue.FailedJobs')->find()->all(); + + $this->assertCount(0, $results); + } + + public function testFailedJobsAreNotDeletedIfNotConfirmed() + { + $this->exec('queue purge_failed', ['n']); + + $this->assertOutputNotContains('Deleting'); + $this->assertOutputNotContains('deleted.'); + + $results = $this->getTableLocator()->get('Cake/Queue.FailedJobs')->find()->all(); + + $this->assertCount(3, $results); + } + + public function testFailedJobsAreDeletedById() + { + $this->exec('queue purge_failed 1,2 -f'); + + $this->assertOutputContains('Deleting 2 jobs.'); + $this->assertOutputContains('2 jobs deleted.'); + + $results = $this->getTableLocator()->get('Cake/Queue.FailedJobs')->find()->toArray(); + + $this->assertCount(1, $results); + $this->assertSame(3, $results[0]->id); + } + + public function testFailedJobsAreDeletedByClass() + { + $class = LogToDebugJob::class; + $this->exec("queue purge_failed --class {$class} -f"); + + $this->assertOutputContains('Deleting 2 jobs.'); + $this->assertOutputContains('2 jobs deleted.'); + + $results = $this->getTableLocator()->get('Cake/Queue.FailedJobs')->find()->toArray(); + + $this->assertCount(1, $results); + $this->assertSame(2, $results[0]->id); + } + + public function testFailedJobsAreDeletedByQueue() + { + $this->exec('queue purge_failed --queue alternate_queue -f'); + + $this->assertOutputContains('Deleting 1 jobs.'); + $this->assertOutputContains('1 jobs deleted.'); + + $results = $this->getTableLocator()->get('Cake/Queue.FailedJobs')->find()->toArray(); + + $this->assertCount(2, $results); + $this->assertSame(1, $results[0]->id); + $this->assertSame(2, $results[1]->id); + } + + public function testFailedJobsAreDeletedByConfig() + { + $this->exec('queue purge_failed --config alternate_config -f'); + + $this->assertOutputContains('Deleting 1 jobs.'); + $this->assertOutputContains('1 jobs deleted.'); + + $results = $this->getTableLocator()->get('Cake/Queue.FailedJobs')->find()->toArray(); + + $this->assertCount(2, $results); + $this->assertSame(1, $results[0]->id); + $this->assertSame(2, $results[1]->id); + } +} diff --git a/tests/TestCase/Command/RequeueCommandTest.php b/tests/TestCase/Command/RequeueCommandTest.php new file mode 100644 index 0000000..e4bc6a0 --- /dev/null +++ b/tests/TestCase/Command/RequeueCommandTest.php @@ -0,0 +1,204 @@ +useCommandRunner(); + + Log::setConfig('debug', [ + 'className' => 'Array', + 'levels' => ['notice', 'info', 'debug'], + ]); + + $config = [ + 'queue' => 'default', + 'url' => 'file:///' . TMP . DS . uniqid('queue'), + 'receiveTimeout' => 100, + ]; + Configure::write('Queue', ['default' => $config]); + } + + public function tearDown(): void + { + parent::tearDown(); + + Log::reset(); + + QueueManager::drop('default'); + } + + public function testJobsAreRequeued() + { + $this->exec('queue worker --max-jobs=3 --logger=debug --verbose'); + QueueManager::drop('default'); + + $this->assertDebugLogContainsExactly('Debug job was run', 0); + $this->assertDebugLogContainsExactly('MaxAttemptsIsThreeJob is requeueing', 0); + + $this->cleanupConsoleTrait(); + $this->useCommandRunner(); + $this->exec('queue requeue --queue default', ['y']); + + $this->assertOutputContains('Requeueing 2 jobs.'); + $this->assertOutputContains('2 jobs requeued.'); + + $this->exec('queue worker --max-jobs=3 --logger=debug --verbose'); + + $this->assertDebugLogContainsExactly('Debug job was run', 1); + $this->assertDebugLogContains('MaxAttemptsIsThreeJob is requeueing'); + } + + public function testJobsAreNotRequeuedIfNotConfirmed() + { + $this->exec('queue worker --max-jobs=3 --logger=debug --verbose'); + QueueManager::drop('default'); + + $this->assertDebugLogContainsExactly('Debug job was run', 0); + $this->assertDebugLogContainsExactly('MaxAttemptsIsThreeJob is requeueing', 0); + + $this->cleanupConsoleTrait(); + $this->useCommandRunner(); + $this->exec('queue requeue --queue default', ['n']); + + $this->assertOutputNotContains('Requeueing'); + $this->assertOutputNotContains('requeued.'); + + $this->exec('queue worker --max-jobs=3 --logger=debug --verbose'); + + $this->assertDebugLogContainsExactly('Debug job was run', 0); + $this->assertDebugLogContainsExactly('MaxAttemptsIsThreeJob is requeueing', 0); + } + + public function testJobsAreRequeuedById() + { + $this->exec('queue worker --max-jobs=3 --logger=debug --verbose'); + QueueManager::drop('default'); + + $this->assertDebugLogContainsExactly('Debug job was run', 0); + $this->assertDebugLogContainsExactly('MaxAttemptsIsThreeJob was run', 0); + + $this->cleanupConsoleTrait(); + $this->useCommandRunner(); + $this->exec('queue requeue 1,2 -f'); + + $this->assertOutputContains('Requeueing 2 jobs.'); + $this->assertOutputContains('2 jobs requeued.'); + + $this->exec('queue worker --max-jobs=3 --logger=debug --verbose'); + + $this->assertDebugLogContainsExactly('Debug job was run', 1); + $this->assertDebugLogContains('MaxAttemptsIsThreeJob is requeueing'); + } + + public function testJobsAreRequeuedByClass() + { + $this->exec('queue worker --max-jobs=3 --logger=debug --verbose'); + QueueManager::drop('default'); + + $this->assertDebugLogContainsExactly('Debug job was run', 0); + + $this->cleanupConsoleTrait(); + $this->useCommandRunner(); + $class = LogToDebugJob::class; + $this->exec("queue requeue --class {$class} --queue default -f"); + + $this->assertOutputContains('Requeueing 1 jobs.'); + $this->assertOutputContains('1 jobs requeued.'); + + $this->exec('queue worker --max-jobs=3 --logger=debug --verbose'); + + $this->assertDebugLogContainsExactly('Debug job was run', 1); + } + + public function testJobsAreRequeuedByQueue() + { + $config = [ + 'queue' => 'alternate_queue', + 'url' => 'file:///' . TMP . DS . 'queue' . uniqid('queue'), + 'receiveTimeout' => 100, + ]; + Configure::write('Queue', ['alternate_config' => $config]); + + $this->exec('queue worker --max-jobs=3 --logger=debug --config alternate_config --verbose'); + QueueManager::drop('alternate_config'); + + $this->assertDebugLogContainsExactly('Debug job was run', 0); + + $this->cleanupConsoleTrait(); + $this->useCommandRunner(); + $this->exec('queue requeue --queue alternate_queue -f'); + + $this->assertOutputContains('Requeueing 1 jobs.'); + $this->assertOutputContains('1 jobs requeued.'); + + $this->exec('queue worker --max-jobs=3 --logger=debug --config alternate_config --verbose'); + QueueManager::drop('alternate_config'); + + $this->assertDebugLogContains('Debug job was run'); + } + + public function testJobsAreRequeuedByConfig() + { + $config = [ + 'queue' => 'alternate_queue', + 'url' => 'file:///' . TMP . DS . 'queue' . uniqid('queue'), + 'receiveTimeout' => 100, + ]; + Configure::write('Queue', ['alternate_config' => $config]); + + $this->exec('queue worker --max-jobs=3 --logger=debug --config alternate_config --verbose'); + QueueManager::drop('alternate_config'); + + $this->assertDebugLogContainsExactly('Debug job was run', 0); + + $this->cleanupConsoleTrait(); + $this->useCommandRunner(); + $this->exec('queue requeue --config alternate_config -f'); + + $this->assertOutputContains('Requeueing 1 jobs.'); + $this->assertOutputContains('1 jobs requeued.'); + + $this->exec('queue worker --max-jobs=3 --logger=debug --config alternate_config --verbose'); + QueueManager::drop('alternate_config'); + + $this->assertDebugLogContains('Debug job was run'); + } +} diff --git a/tests/TestCase/Consumption/LimitAttemptsExtensionTest.php b/tests/TestCase/Consumption/LimitAttemptsExtensionTest.php index 41525d0..14f963f 100644 --- a/tests/TestCase/Consumption/LimitAttemptsExtensionTest.php +++ b/tests/TestCase/Consumption/LimitAttemptsExtensionTest.php @@ -3,6 +3,8 @@ namespace Cake\Queue\Test\TestCase\Job; +use Cake\Event\EventList; +use Cake\Event\EventManager; use Cake\Log\Log; use Cake\Queue\Consumption\LimitAttemptsExtension; use Cake\Queue\Consumption\LimitConsumedMessagesExtension; @@ -19,6 +21,13 @@ class LimitAttemptsExtensionTest extends TestCase { use DebugLogTrait; + public function setUp(): void + { + parent::setUp(); + + EventManager::instance()->setEventList(new EventList()); + } + /** * @beforeClass * @after @@ -41,6 +50,17 @@ public function testMessageShouldBeRequeuedIfMaxAttemptsIsNotSet() $this->assertGreaterThanOrEqual(10, $count); } + public function testFailedEventIsFiredWhenMaxAttemptsIsExceeded() + { + $consume = $this->setupQeueue(); + + QueueManager::push(MaxAttemptsIsThreeJob::class, []); + + $consume(); + + $this->assertEventFired('Consumption.LimitAttemptsExtension.failed'); + } + public function testMessageShouldBeRequeuedUntilMaxAttemptsIsReached() { $consume = $this->setupQeueue(); diff --git a/tests/TestCase/DebugLogTrait.php b/tests/TestCase/DebugLogTrait.php index d479f9e..e50006d 100644 --- a/tests/TestCase/DebugLogTrait.php +++ b/tests/TestCase/DebugLogTrait.php @@ -7,7 +7,7 @@ trait DebugLogTrait { - protected function assertDebugLogContains($expected, $times = null): void + protected function assertDebugLogContains($expected): void { $found = $this->debugLogCount($expected); diff --git a/tests/TestCase/Listener/FailedJobsListenerTest.php b/tests/TestCase/Listener/FailedJobsListenerTest.php new file mode 100644 index 0000000..182ad35 --- /dev/null +++ b/tests/TestCase/Listener/FailedJobsListenerTest.php @@ -0,0 +1,95 @@ + 'null:', + 'storeFailedJobs' => true, + ]); + } + + public function tearDown(): void + { + parent::tearDown(); + + QueueManager::drop('example_config'); + } + + public function testFailedJobIsAddedWhenEventIsFired() + { + $parsedBody = [ + 'class' => [LogToDebugJob::class, 'execute'], + 'data' => ['example_key' => 'example_value'], + 'requeueOptions' => [ + 'config' => 'example_config', + 'priority' => 'example_priority', + 'queue' => 'example_queue', + ], + ]; + $messageBody = json_encode($parsedBody); + $connectionFactory = new NullConnectionFactory(); + + $context = $connectionFactory->createContext(); + $originalMessage = new NullMessage($messageBody); + $message = new Message($originalMessage, $context); + + $event = new Event( + 'Consumption.LimitAttemptsExtension.failed', + $message, + ['exception' => 'some message'] + ); + + /** @var \Cake\Queue\Model\Table\FailedJobsTable $failedJobsTable */ + $failedJobsTable = $this->getTableLocator()->get('Cake/Queue.FailedJobs'); + $failedJobsTable->deleteAll(['1=1']); + + EventManager::instance()->on(new FailedJobsListener()); + EventManager::instance()->dispatch($event); + + $this->assertSame(1, $failedJobsTable->find()->count()); + + $failedJob = $failedJobsTable->find()->first(); + + $this->assertSame(LogToDebugJob::class, $failedJob->class); + $this->assertSame('execute', $failedJob->method); + $this->assertSame(json_encode(['example_key' => 'example_value']), $failedJob->data); + $this->assertSame('example_config', $failedJob->config); + $this->assertSame('example_priority', $failedJob->priority); + $this->assertSame('example_queue', $failedJob->queue); + $this->assertStringContainsString('some message', $failedJob->exception); + } +} diff --git a/tests/TestCase/Queue/ProcessorTest.php b/tests/TestCase/Queue/ProcessorTest.php index e586157..7f76af8 100644 --- a/tests/TestCase/Queue/ProcessorTest.php +++ b/tests/TestCase/Queue/ProcessorTest.php @@ -153,7 +153,7 @@ public function testProcessWillRequeueOnException() $processor->getEventManager()->setEventList($events); $result = $processor->process($queueMessage, $context); - $this->assertSame(InteropProcessor::REQUEUE, $result); + $this->assertEquals(InteropProcessor::REQUEUE, $result); } /** diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 4eae28d..66530a9 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -15,6 +15,7 @@ * @license http://www.opensource.org/licenses/mit-license.php MIT License */ +use Cake\Cache\Cache; use Cake\Core\Configure; use Cake\Routing\Router; @@ -45,6 +46,25 @@ define('CONFIG', ROOT . DS . 'config' . DS); } +// phpcs:disable +@mkdir(CACHE); +@mkdir(CACHE . 'views'); +@mkdir(CACHE . 'models'); +// phpcs:enable + +Cache::setConfig([ + '_cake_core_' => [ + 'engine' => 'File', + 'prefix' => 'cake_core_', + 'serialize' => true, + ], + '_cake_model_' => [ + 'engine' => 'File', + 'prefix' => 'cake_model_', + 'serialize' => true, + ], +]); + Configure::write('debug', true); Configure::write('App', [ 'namespace' => 'TestApp', diff --git a/tests/test_app/src/Job/MaxAttemptsIsThreeJob.php b/tests/test_app/src/Job/MaxAttemptsIsThreeJob.php index 7aa60fc..030dea8 100644 --- a/tests/test_app/src/Job/MaxAttemptsIsThreeJob.php +++ b/tests/test_app/src/Job/MaxAttemptsIsThreeJob.php @@ -8,6 +8,7 @@ use Cake\Queue\Job\JobInterface; use Cake\Queue\Job\Message; use Interop\Queue\Processor; +use RuntimeException; class MaxAttemptsIsThreeJob implements JobInterface { @@ -23,7 +24,7 @@ public function execute(Message $message): ?string if (!$succeedAt || $succeedAt > $attemptNumber) { Log::debug('MaxAttemptsIsThreeJob is requeueing'); - return Processor::REQUEUE; + throw new RuntimeException('example MaxAttemptsIsThreeJob exception message'); } Log::debug('MaxAttemptsIsThreeJob was run'); From f07844eb10635eae07bb5759609900a823152ec8 Mon Sep 17 00:00:00 2001 From: Sheldon Reiff Date: Fri, 4 Nov 2022 12:18:03 -0400 Subject: [PATCH 06/90] Ignore dead catch error phpstan v1.9.1 returns an error with message "Dead catch - Cake\ORM\Exception\PersistenceFailedException is never thrown in the try block.". this error was not returned on v1.8.11. --- src/Listener/FailedJobsListener.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Listener/FailedJobsListener.php b/src/Listener/FailedJobsListener.php index 7e26fd2..01325a5 100644 --- a/src/Listener/FailedJobsListener.php +++ b/src/Listener/FailedJobsListener.php @@ -76,6 +76,7 @@ public function storeFailedJob(EventInterface $event): void try { $failedJobsTable->saveOrFail($failedJob); + /** @phpstan-ignore-next-line */ } catch (PersistenceFailedException $e) { $logger = $event->getData('logger'); From 96ef1728890555e6cecbd83bb2abeeb43244ee8a Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Fri, 25 Nov 2022 12:11:13 +0100 Subject: [PATCH 07/90] add DI container support --- src/Command/WorkerCommand.php | 17 +++++++++- src/Job/Message.php | 19 +++++++++-- src/Plugin.php | 15 ++++++++ src/Queue/Processor.php | 14 ++++++-- src/Queue/ServicesTrait.php | 34 +++++++++++++++++++ tests/TestCase/Command/WorkerCommandTest.php | 29 ++++++++++++++++ tests/test_app/src/Application.php | 6 ++++ .../src/Job/LogToDebugWithServiceJob.php | 25 ++++++++++++++ tests/test_app/src/TestService.php | 12 +++++++ 9 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 src/Queue/ServicesTrait.php create mode 100644 tests/test_app/src/Job/LogToDebugWithServiceJob.php create mode 100644 tests/test_app/src/TestService.php diff --git a/src/Command/WorkerCommand.php b/src/Command/WorkerCommand.php index ed335ff..80e3b15 100644 --- a/src/Command/WorkerCommand.php +++ b/src/Command/WorkerCommand.php @@ -21,6 +21,7 @@ use Cake\Console\ConsoleIo; use Cake\Console\ConsoleOptionParser; use Cake\Core\Configure; +use Cake\Core\ContainerInterface; use Cake\Log\Log; use Cake\Queue\Consumption\LimitAttemptsExtension; use Cake\Queue\Consumption\LimitConsumedMessagesExtension; @@ -40,6 +41,20 @@ */ class WorkerCommand extends Command { + /** + * @var \Cake\Core\ContainerInterface|null + */ + protected $container; + + /** + * @param \Cake\Core\ContainerInterface|null $container DI container instance + */ + public function __construct(?ContainerInterface $container = null) + { + parent::__construct(); + $this->container = $container; + } + /** * Get the command name. * @@ -156,7 +171,7 @@ protected function getLogger(Arguments $args): LoggerInterface public function execute(Arguments $args, ConsoleIo $io) { $logger = $this->getLogger($args); - $processor = new Processor($logger); + $processor = new Processor($logger, $this->container); $extension = $this->getQueueExtension($args, $logger); $config = (string)$args->getOption('config'); diff --git a/src/Job/Message.php b/src/Job/Message.php index de73f81..3aa5865 100644 --- a/src/Job/Message.php +++ b/src/Job/Message.php @@ -16,6 +16,8 @@ */ namespace Cake\Queue\Job; +use Cake\Core\ContainerInterface; +use Cake\Queue\Queue\ServicesTrait; use Cake\Utility\Hash; use Closure; use Interop\Queue\Context; @@ -45,15 +47,22 @@ class Message implements JsonSerializable */ protected $callable; + /** + * @var \Cake\Core\ContainerInterface|null + */ + protected $container; + /** * @param \Interop\Queue\Message $originalMessage Queue message. * @param \Interop\Queue\Context $context Context. + * @param \Cake\Core\ContainerInterface|null $container DI container instance */ - public function __construct(QueueMessage $originalMessage, Context $context) + public function __construct(QueueMessage $originalMessage, Context $context, ?ContainerInterface $container = null) { $this->context = $context; $this->originalMessage = $originalMessage; $this->parsedBody = json_decode($originalMessage->getBody(), true); + $this->container = $container; } /** @@ -95,8 +104,14 @@ public function getCallable() } $target = $this->getTarget(); + $object = new $target[0](); + + $traits = class_uses($object); + if ($this->container && $traits && in_array(ServicesTrait::class, $traits, true)) { + $object->setContainer($this->container); + } - $this->callable = Closure::fromCallable([new $target[0](), $target[1]]); + $this->callable = Closure::fromCallable([$object, $target[1]]); return $this->callable; } diff --git a/src/Plugin.php b/src/Plugin.php index 307e5c3..010ef1a 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -19,6 +19,7 @@ use Cake\Console\CommandCollection; use Cake\Core\BasePlugin; use Cake\Core\Configure; +use Cake\Core\ContainerInterface; use Cake\Core\PluginApplicationInterface; use Cake\Queue\Command\JobCommand; use Cake\Queue\Command\PurgeFailedCommand; @@ -84,4 +85,18 @@ public function console(CommandCollection $commands): CommandCollection ->add('queue requeue', RequeueCommand::class) ->add('queue purge_failed', PurgeFailedCommand::class); } + + /** + * Add DI container to Worker command + * + * @param \Cake\Core\ContainerInterface $container The DI container + * @return void + */ + public function services(ContainerInterface $container): void + { + $container->add(ContainerInterface::class, $container); + $container + ->add(WorkerCommand::class) + ->addArgument(ContainerInterface::class); + } } diff --git a/src/Queue/Processor.php b/src/Queue/Processor.php index 89d01a4..5051079 100644 --- a/src/Queue/Processor.php +++ b/src/Queue/Processor.php @@ -16,6 +16,7 @@ */ namespace Cake\Queue\Queue; +use Cake\Core\ContainerInterface; use Cake\Event\EventDispatcherTrait; use Cake\Queue\Job\Message; use Enqueue\Consumption\Result; @@ -37,14 +38,21 @@ class Processor implements InteropProcessor */ protected $logger; + /** + * @var \Cake\Core\ContainerInterface|null + */ + protected $container; + /** * Processor constructor * - * @param \Psr\Log\LoggerInterface $logger Logger instance. + * @param \Psr\Log\LoggerInterface|null $logger Logger instance. + * @param \Cake\Core\ContainerInterface|null $container DI container instance */ - public function __construct(?LoggerInterface $logger = null) + public function __construct(?LoggerInterface $logger = null, ?ContainerInterface $container = null) { $this->logger = $logger ?: new NullLogger(); + $this->container = $container; } /** @@ -58,7 +66,7 @@ public function process(QueueMessage $message, Context $context) { $this->dispatchEvent('Processor.message.seen', ['queueMessage' => $message]); - $jobMessage = new Message($message, $context); + $jobMessage = new Message($message, $context, $this->container); try { $jobMessage->getCallable(); } catch (RuntimeException | Error $e) { diff --git a/src/Queue/ServicesTrait.php b/src/Queue/ServicesTrait.php new file mode 100644 index 0000000..1dc4b85 --- /dev/null +++ b/src/Queue/ServicesTrait.php @@ -0,0 +1,34 @@ +container->get($id); + } + + /** + * @param \Cake\Core\ContainerInterface $container DI container instance + * @return void + */ + public function setContainer(ContainerInterface $container) + { + $this->container = $container; + } +} diff --git a/tests/TestCase/Command/WorkerCommandTest.php b/tests/TestCase/Command/WorkerCommandTest.php index 0d3666c..3034bb6 100644 --- a/tests/TestCase/Command/WorkerCommandTest.php +++ b/tests/TestCase/Command/WorkerCommandTest.php @@ -19,6 +19,7 @@ use Cake\Core\Configure; use Cake\Log\Log; use Cake\Queue\QueueManager; +use Cake\Queue\Test\test_app\src\Job\LogToDebugWithServiceJob; use Cake\Queue\Test\TestCase\DebugLogTrait; use Cake\TestSuite\ConsoleIntegrationTestTrait; use Cake\TestSuite\TestCase; @@ -270,4 +271,32 @@ public function testQueueProcessesJobWithMaxAttempts() $this->assertDebugLogContainsExactly('RequeueJob is requeueing', 3); } + + /** + * Test DI service injection works in tasks + * + * @runInSeparateProcess + */ + public function testQueueProcessesJobWithDIService() + { + $this->skipIf(version_compare(Configure::version(), '4.2', '<'), 'DI Container is only available since CakePHP 4.2'); + $config = [ + 'queue' => 'default', + 'url' => 'file:///' . TMP . DS . 'queue', + 'receiveTimeout' => 100, + ]; + Configure::write('Queue', ['default' => $config]); + Log::setConfig('debug', [ + 'className' => 'Array', + 'levels' => ['notice', 'info', 'debug'], + ]); + + QueueManager::setConfig('default', $config); + QueueManager::push(LogToDebugWithServiceJob::class); + QueueManager::drop('default'); + + $this->exec('queue worker --max-jobs=1 --processor=processor-name --logger=debug --verbose'); + + $this->assertDebugLogContains('Debug job was run with service infotext'); + } } diff --git a/tests/test_app/src/Application.php b/tests/test_app/src/Application.php index 0f94dc2..61f1c96 100644 --- a/tests/test_app/src/Application.php +++ b/tests/test_app/src/Application.php @@ -3,6 +3,7 @@ namespace TestApp; +use Cake\Core\ContainerInterface; use Cake\Http\BaseApplication; use Cake\Http\MiddlewareQueue; use Cake\Routing\RouteBuilder; @@ -23,4 +24,9 @@ public function bootstrap(): void $this->addPlugin('Cake/Queue'); $this->addPlugin('Bake'); } + + public function services(ContainerInterface $container): void + { + $container->add(TestService::class); + } } diff --git a/tests/test_app/src/Job/LogToDebugWithServiceJob.php b/tests/test_app/src/Job/LogToDebugWithServiceJob.php new file mode 100644 index 0000000..03afe8c --- /dev/null +++ b/tests/test_app/src/Job/LogToDebugWithServiceJob.php @@ -0,0 +1,25 @@ +getService(TestService::class); + Log::debug('Debug job was run ' . $service->info()); + + return Processor::ACK; + } +} diff --git a/tests/test_app/src/TestService.php b/tests/test_app/src/TestService.php new file mode 100644 index 0000000..71fd68a --- /dev/null +++ b/tests/test_app/src/TestService.php @@ -0,0 +1,12 @@ + Date: Sat, 26 Nov 2022 19:34:53 +0100 Subject: [PATCH 08/90] add constructor injection support --- src/Job/Message.php | 10 +++--- src/Queue/ServicesTrait.php | 34 ------------------- tests/test_app/src/Application.php | 2 ++ .../src/Job/LogToDebugWithServiceJob.php | 15 +++++--- 4 files changed, 16 insertions(+), 45 deletions(-) delete mode 100644 src/Queue/ServicesTrait.php diff --git a/src/Job/Message.php b/src/Job/Message.php index 3aa5865..dc8526f 100644 --- a/src/Job/Message.php +++ b/src/Job/Message.php @@ -17,7 +17,6 @@ namespace Cake\Queue\Job; use Cake\Core\ContainerInterface; -use Cake\Queue\Queue\ServicesTrait; use Cake\Utility\Hash; use Closure; use Interop\Queue\Context; @@ -104,11 +103,10 @@ public function getCallable() } $target = $this->getTarget(); - $object = new $target[0](); - - $traits = class_uses($object); - if ($this->container && $traits && in_array(ServicesTrait::class, $traits, true)) { - $object->setContainer($this->container); + if ($this->container && $this->container->has($target[0])) { + $object = $this->container->get($target[0]); + } else { + $object = new $target[0](); } $this->callable = Closure::fromCallable([$object, $target[1]]); diff --git a/src/Queue/ServicesTrait.php b/src/Queue/ServicesTrait.php deleted file mode 100644 index 1dc4b85..0000000 --- a/src/Queue/ServicesTrait.php +++ /dev/null @@ -1,34 +0,0 @@ -container->get($id); - } - - /** - * @param \Cake\Core\ContainerInterface $container DI container instance - * @return void - */ - public function setContainer(ContainerInterface $container) - { - $this->container = $container; - } -} diff --git a/tests/test_app/src/Application.php b/tests/test_app/src/Application.php index 61f1c96..e8119b2 100644 --- a/tests/test_app/src/Application.php +++ b/tests/test_app/src/Application.php @@ -6,6 +6,7 @@ use Cake\Core\ContainerInterface; use Cake\Http\BaseApplication; use Cake\Http\MiddlewareQueue; +use Cake\Queue\Test\test_app\src\Job\LogToDebugWithServiceJob; use Cake\Routing\RouteBuilder; class Application extends BaseApplication @@ -28,5 +29,6 @@ public function bootstrap(): void public function services(ContainerInterface $container): void { $container->add(TestService::class); + $container->add(LogToDebugWithServiceJob::class)->addArgument(TestService::class); } } diff --git a/tests/test_app/src/Job/LogToDebugWithServiceJob.php b/tests/test_app/src/Job/LogToDebugWithServiceJob.php index 03afe8c..a71b324 100644 --- a/tests/test_app/src/Job/LogToDebugWithServiceJob.php +++ b/tests/test_app/src/Job/LogToDebugWithServiceJob.php @@ -6,19 +6,24 @@ use Cake\Log\Log; use Cake\Queue\Job\JobInterface; use Cake\Queue\Job\Message; -use Cake\Queue\Queue\ServicesTrait; use Interop\Queue\Processor; use TestApp\TestService; class LogToDebugWithServiceJob implements JobInterface { - use ServicesTrait; + /** + * @var \TestApp\TestService $testService + */ + private $testService; + + public function __construct(TestService $testService) + { + $this->testService = $testService; + } public function execute(Message $message): ?string { - /** @var TestService $service */ - $service = $this->getService(TestService::class); - Log::debug('Debug job was run ' . $service->info()); + Log::debug('Debug job was run ' . $this->testService->info()); return Processor::ACK; } From 6c85ef3f32af7af2de78811c89fa0bf023b56104 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Tue, 29 Nov 2022 10:28:38 -0500 Subject: [PATCH 09/90] Add docs for #109 --- docs/en/index.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/en/index.rst b/docs/en/index.rst index 6f58e90..41dd4d7 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -87,7 +87,10 @@ Usage Defining Jobs ------------- -Create a Job class:: +Workloads are defined as 'jobs'. Job classes can recieve dependencies from your +application's dependency injection container in their constructor just like +Controllers or Commands. Jobs are responsible for processing queue messages. +A simple job that logs received messages would look like:: Date: Fri, 4 Nov 2022 14:42:39 -0400 Subject: [PATCH 10/90] Add support for unique jobs --- docs/en/index.rst | 23 ++++++ src/Command/JobCommand.php | 20 +++++ src/Command/WorkerCommand.php | 2 + .../RemoveUniqueJobIdFromCacheExtension.php | 53 +++++++++++++ src/Job/Message.php | 4 +- src/QueueManager.php | 64 ++++++++++++++- templates/bake/job.twig | 9 +++ .../LimitAttemptsExtensionTest.php | 12 +-- ...emoveUniqueJobIdFromCacheExtensionTest.php | 78 +++++++++++++++++++ tests/TestCase/QueueManagerTest.php | 66 ++++++++++++++-- tests/TestCase/Task/JobTaskTest.php | 14 ++++ tests/comparisons/JobTaskWithUnique.php | 39 ++++++++++ tests/test_app/src/Job/UniqueJob.php | 21 +++++ 13 files changed, 392 insertions(+), 13 deletions(-) create mode 100644 src/Consumption/RemoveUniqueJobIdFromCacheExtension.php create mode 100644 tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php create mode 100644 tests/comparisons/JobTaskWithUnique.php create mode 100644 tests/test_app/src/Job/UniqueJob.php diff --git a/docs/en/index.rst b/docs/en/index.rst index 6f58e90..e5c3ae5 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -59,6 +59,16 @@ The following configuration should be present in the config array of your **conf // Whether to store failed jobs in the queue_failed_jobs table. default: false 'storeFailedJobs' => true, + + // (optional) The cache configuration for storing unique job ids. `duration` + // should be greater than the maximum length of time any job can be expected + // to remain on the queue. Otherwise, duplicate jobs may be + // possible. Defaults to +24 hours. Note that `File` engine is only suitable + // for local development. + // See https://book.cakephp.org/4/en/core-libraries/caching.html#configuring-cache-engines. + 'uniqueCache' => [ + 'engine' => 'File', + ], ] ], // ... @@ -111,6 +121,13 @@ Create a Job class:: */ public static $maxAttempts = 3; + /** + * Whether there should be only one instance of a job on the queue at a time. (optional property) + * + * @var bool + */ + public static $shouldBeUnique = false; + public function execute(Message $message): ?string { $id = $message->getArgument('id'); @@ -151,6 +168,12 @@ Properties: provided, this value will override the value provided in the worker command line option ``--max-attempts``. If a value is not provided by the job or by the command line option, the job may be requeued an infinite number of times. +- ``shouldBeUnique``: If ``true``, only one instance of the job, identified by + it's class, method, and data, will be allowed to be present on the queue at a + time. Subsequent pushes will be silently dropped. This is useful for + idempotent operations where consecutive job executions have no benefit. For + example, refreshing calculated data. If ``true``, the ``uniqueCache`` + configuration must be set. Queueing -------- diff --git a/src/Command/JobCommand.php b/src/Command/JobCommand.php index 5c7f260..d845ac2 100644 --- a/src/Command/JobCommand.php +++ b/src/Command/JobCommand.php @@ -17,6 +17,7 @@ namespace Cake\Queue\Command; use Bake\Command\SimpleBakeCommand; +use Cake\Console\Arguments; use Cake\Console\ConsoleOptionParser; class JobCommand extends SimpleBakeCommand @@ -47,6 +48,20 @@ public function template(): string return 'Cake/Queue.job'; } + /** + * @inheritDoc + */ + public function templateData(Arguments $arguments): array + { + $parentData = parent::templateData($arguments); + + $data = [ + 'isUnique' => $arguments->getOption('unique'), + ]; + + return array_merge($parentData, $data); + } + /** * Gets the option parser instance and configures it. * @@ -61,6 +76,11 @@ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionPar ->setDescription('Bake a queue job class.') ->addArgument('name', [ 'help' => 'The name of the queue job class to create.', + ]) + ->addOption('unique', [ + 'help' => 'Whether there should be only one instance of a job on the queue at a time.', + 'boolean' => true, + 'default' => false, ]); } } diff --git a/src/Command/WorkerCommand.php b/src/Command/WorkerCommand.php index ed335ff..7141802 100644 --- a/src/Command/WorkerCommand.php +++ b/src/Command/WorkerCommand.php @@ -24,6 +24,7 @@ use Cake\Log\Log; use Cake\Queue\Consumption\LimitAttemptsExtension; use Cake\Queue\Consumption\LimitConsumedMessagesExtension; +use Cake\Queue\Consumption\RemoveUniqueJobIdFromCacheExtension; use Cake\Queue\Listener\FailedJobsListener; use Cake\Queue\Queue\Processor; use Cake\Queue\QueueManager; @@ -117,6 +118,7 @@ protected function getQueueExtension(Arguments $args, LoggerInterface $logger): $extensions = [ new LoggerExtension($logger), $limitAttempsExtension, + new RemoveUniqueJobIdFromCacheExtension('Cake/Queue.queueUnique'), ]; if (!is_null($args->getOption('max-jobs'))) { diff --git a/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php b/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php new file mode 100644 index 0000000..4f4fd1b --- /dev/null +++ b/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php @@ -0,0 +1,53 @@ +cache = $cache; + } + + /** + * @param \Enqueue\Consumption\Context\MessageResult $context The result of the message after it was processed. + * @return void + */ + public function onResult(MessageResult $context): void + { + $message = $context->getMessage(); + + $jobMessage = new Message($message, $context->getContext()); + + /** @psalm-var class-string $class */ + [$class, $method] = $jobMessage->getTarget(); + + if (empty($class::$shouldBeUnique)) { + return; + } + + $data = $jobMessage->getArgument(); + + $uniqueId = QueueManager::getUniqueId($class, $method, $data); + + Cache::delete($uniqueId, $this->cache); + } +} diff --git a/src/Job/Message.php b/src/Job/Message.php index de73f81..7984e28 100644 --- a/src/Job/Message.php +++ b/src/Job/Message.php @@ -104,7 +104,8 @@ public function getCallable() /** * Get the target class and method. * - * @return array + * @return array{string, string} + * @psalm-return array{class-string, string} */ public function getTarget(): array { @@ -150,6 +151,7 @@ public function getMaxAttempts(): ?int { $target = $this->getTarget(); + /** @psalm-var class-string $class */ $class = $target[0]; return $class::$maxAttempts ?? null; diff --git a/src/QueueManager.php b/src/QueueManager.php index 8f9de11..edd0660 100644 --- a/src/QueueManager.php +++ b/src/QueueManager.php @@ -17,6 +17,7 @@ namespace Cake\Queue; use BadMethodCallException; +use Cake\Cache\Cache; use Cake\Core\App; use Cake\Log\Log; use Enqueue\Client\Message as ClientMessage; @@ -119,6 +120,16 @@ public static function setConfig($key, $config = null): void } } + if (!empty($config['uniqueCache'])) { + $cacheDefaults = [ + 'duration' => '+24 hours', + ]; + + $cacheConfig = array_merge($cacheDefaults, $config['uniqueCache']); + + Cache::setConfig('Cake/Queue.queueUnique', $cacheConfig); + } + /** @psalm-suppress InvalidPropertyAssignmentValue */ static::$_config[$key] = $config; } @@ -209,7 +220,33 @@ public static function push($className, array $data = [], array $options = []): $name = $options['config'] ?? 'default'; - $config = static::getConfig($name); + $config = static::getConfig($name) + [ + 'logger' => null, + ]; + + $logger = $config['logger'] ? Log::engine($config['logger']) : null; + + /** @psalm-suppress InvalidPropertyFetch */ + if (!empty($class::$shouldBeUnique)) { + if (!Cache::getConfig('Cake/Queue.queueUnique')) { + throw new InvalidArgumentException( + "$class::\$shouldBeUnique is set to `true` but `uniqueCache` configuration is missing." + ); + } + + $uniqueId = static::getUniqueId($class, $method, $data); + + if (Cache::read($uniqueId, 'Cake/Queue.queueUnique')) { + if ($logger) { + $logger->debug( + "An identical instance of $class already exists on the queue. This push will be ignored." + ); + } + + return; + } + } + $queue = $options['queue'] ?? $config['queue'] ?? 'default'; $message = new ClientMessage([ @@ -237,5 +274,30 @@ public static function push($className, array $data = [], array $options = []): $client = static::engine($name); $client->sendEvent($queue, $message); + + if (!empty($class::$shouldBeUnique)) { + $uniqueId = static::getUniqueId($class, $method, $data); + + Cache::add($uniqueId, true, 'Cake/Queue.queueUnique'); + } + } + + /** + * @param string $class Class name + * @param string $method Method name + * @param array $data Message data + * @return string + */ + public static function getUniqueId(string $class, string $method, array $data): string + { + sort($data); + + $hashInput = implode([ + $class, + $method, + json_encode($data), + ]); + + return hash('md5', $hashInput); } } diff --git a/templates/bake/job.twig b/templates/bake/job.twig index 9c2e888..684fcfb 100644 --- a/templates/bake/job.twig +++ b/templates/bake/job.twig @@ -34,6 +34,15 @@ class {{ name }}Job implements JobInterface */ public static $maxAttempts = 3; +{% if isUnique %} + /** + * Whether there should be only one instance of a job on the queue at a time. (optional property) + * + * @var bool + */ + public static $shouldBeUnique = true; + +{% endif %} /** * Executes logic for {{ name }}Job * diff --git a/tests/TestCase/Consumption/LimitAttemptsExtensionTest.php b/tests/TestCase/Consumption/LimitAttemptsExtensionTest.php index 14f963f..7094d47 100644 --- a/tests/TestCase/Consumption/LimitAttemptsExtensionTest.php +++ b/tests/TestCase/Consumption/LimitAttemptsExtensionTest.php @@ -40,7 +40,7 @@ public static function dropConfigs() public function testMessageShouldBeRequeuedIfMaxAttemptsIsNotSet() { - $consume = $this->setupQeueue(); + $consume = $this->setupQueue(); QueueManager::push(RequeueJob::class); @@ -52,7 +52,7 @@ public function testMessageShouldBeRequeuedIfMaxAttemptsIsNotSet() public function testFailedEventIsFiredWhenMaxAttemptsIsExceeded() { - $consume = $this->setupQeueue(); + $consume = $this->setupQueue(); QueueManager::push(MaxAttemptsIsThreeJob::class, []); @@ -63,7 +63,7 @@ public function testFailedEventIsFiredWhenMaxAttemptsIsExceeded() public function testMessageShouldBeRequeuedUntilMaxAttemptsIsReached() { - $consume = $this->setupQeueue(); + $consume = $this->setupQueue(); QueueManager::push(MaxAttemptsIsThreeJob::class, []); @@ -74,7 +74,7 @@ public function testMessageShouldBeRequeuedUntilMaxAttemptsIsReached() public function testMessageShouldBeRequeuedIfGlobalMaxAttemptsIsNotSet() { - $consume = $this->setupQeueue(); + $consume = $this->setupQueue(); QueueManager::push(RequeueJob::class); @@ -86,7 +86,7 @@ public function testMessageShouldBeRequeuedIfGlobalMaxAttemptsIsNotSet() public function testMessageShouldBeRequeuedUntilGlobalMaxAttemptsIsReached() { - $consume = $this->setupQeueue([3]); + $consume = $this->setupQueue([3]); QueueManager::push(MaxAttemptsIsThreeJob::class, ['succeedAt' => 10]); @@ -95,7 +95,7 @@ public function testMessageShouldBeRequeuedUntilGlobalMaxAttemptsIsReached() $this->assertDebugLogContainsExactly('MaxAttemptsIsThreeJob is requeueing', 3); } - protected function setupQeueue($extensionArgs = []) + protected function setupQueue($extensionArgs = []) { Log::setConfig('debug', [ 'className' => 'Array', diff --git a/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php b/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php new file mode 100644 index 0000000..72e3b18 --- /dev/null +++ b/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php @@ -0,0 +1,78 @@ +setupQueue(); + + QueueManager::push(UniqueJob::class, []); + + $uniqueId = QueueManager::getUniqueId(UniqueJob::class, 'execute', []); + $this->assertTrue(Cache::read($uniqueId, 'Cake/Queue.queueUnique')); + + $consume(); + + $this->assertNull(Cache::read($uniqueId, 'Cake/Queue.queueUnique')); + } + + protected function setupQueue($extensionArgs = []) + { + Log::setConfig('debug', [ + 'className' => 'Array', + 'levels' => ['debug'], + ]); + + QueueManager::setConfig('default', [ + 'url' => 'file:///' . TMP . DS . uniqid('queue'), + 'receiveTimeout' => 100, + 'uniqueCache' => [ + 'engine' => 'File', + ], + ]); + + $client = QueueManager::engine('default'); + + $processor = new QueueProcessor(new NullLogger()); + $client->bindTopic('default', $processor); + + $extension = new ChainExtension([ + new LimitConsumedMessagesExtension(1), + new RemoveUniqueJobIdFromCacheExtension('Cake/Queue.queueUnique'), + ]); + + return function () use ($client, $extension) { + $client->consume($extension); + }; + } +} diff --git a/tests/TestCase/QueueManagerTest.php b/tests/TestCase/QueueManagerTest.php index c9e1a1c..1de3ba8 100644 --- a/tests/TestCase/QueueManagerTest.php +++ b/tests/TestCase/QueueManagerTest.php @@ -17,19 +17,28 @@ namespace Cake\Queue\Test\TestCase; use BadMethodCallException; +use Cake\Cache\Cache; use Cake\Log\Log; use Cake\Queue\QueueManager; use Cake\TestSuite\TestCase; use Enqueue\SimpleClient\SimpleClient; use LogicException; use TestApp\Job\LogToDebugJob; +use TestApp\Job\UniqueJob; /** * QueueManager test */ class QueueManagerTest extends TestCase { - private $fsQueueUrl = 'file:///' . TMP . DS . 'queue'; + use DebugLogTrait; + + private $fsQueuePath = TMP . DS . 'queue'; + + private function getFsQueueUrl(): string + { + return 'file:///' . $this->fsQueuePath; + } public function tearDown(): void { @@ -39,7 +48,12 @@ public function tearDown(): void Log::drop('test'); // delete file based queues - array_map('unlink', glob($this->fsQueueUrl . DS . '*')); + array_map('unlink', glob($this->fsQueuePath . DS . '*')); + + if (Cache::getConfig('Cake/Queue.queueUnique')) { + Cache::clear('Cake/Queue.queueUnique'); + Cache::drop('Cake/Queue.queueUnique'); + } } public function testSetConfig() @@ -81,7 +95,7 @@ public function testSetConfigOverwrite() public function testNonDefaultQueueNameString() { QueueManager::setConfig('test', [ - 'url' => $this->fsQueueUrl, + 'url' => $this->getFsQueueUrl(), 'queue' => 'other', ]); $engine = QueueManager::engine('test'); @@ -127,14 +141,56 @@ public function testPushInvalidClass() public function testMessageIsPushedToQueuePassedAsOption() { QueueManager::setConfig('test', [ - 'url' => $this->fsQueueUrl, + 'url' => $this->getFsQueueUrl(), 'queue' => 'test', ]); QueueManager::push(LogToDebugJob::class, [], ['config' => 'test', 'queue' => 'non-default-queue-name']); - $fsQueueFile = $this->fsQueueUrl . DS . 'enqueue.app.test'; + $fsQueueFile = $this->getFsQueueUrl() . DS . 'enqueue.app.test'; $this->assertFileExists($fsQueueFile); $this->assertStringContainsString('non-default-queue-name', file_get_contents($fsQueueFile)); } + + public function testUniqueMessageIsQueuedOnlyOnce() + { + QueueManager::setConfig('test', [ + 'url' => $this->getFsQueueUrl(), + 'queue' => 'test', + 'uniqueCache' => [ + 'engine' => 'File', + ], + ]); + + QueueManager::push(UniqueJob::class, [], ['config' => 'test']); + QueueManager::push(UniqueJob::class, [], ['config' => 'test']); + + $fsQueueFile = $this->getFsQueueUrl() . DS . 'enqueue.app.test'; + $this->assertFileExists($fsQueueFile); + $this->assertSame(1, substr_count(file_get_contents($fsQueueFile), 'UniqueJob')); + } + + public function testDroppedJobIsLoggedForUniqueJob() + { + Log::setConfig('debug', [ + 'className' => 'Array', + 'levels' => ['notice', 'info', 'debug'], + ]); + + QueueManager::setConfig('test', [ + 'url' => $this->getFsQueueUrl(), + 'queue' => 'test', + 'uniqueCache' => [ + 'engine' => 'File', + ], + 'logger' => 'debug', + ]); + + QueueManager::push(UniqueJob::class, [], ['config' => 'test']); + QueueManager::push(UniqueJob::class, [], ['config' => 'test']); + + $this->assertDebugLogContainsExactly('An identical instance of TestApp\Job\UniqueJob already exists on the queue. This push will be ignored.', 1); + + Log::drop('debug'); + } } diff --git a/tests/TestCase/Task/JobTaskTest.php b/tests/TestCase/Task/JobTaskTest.php index bd8c65b..76d2b0e 100644 --- a/tests/TestCase/Task/JobTaskTest.php +++ b/tests/TestCase/Task/JobTaskTest.php @@ -72,4 +72,18 @@ public function testMain() file_get_contents($this->generatedFile) ); } + + public function testMainWithUnique() + { + $this->generatedFile = APP . 'Job' . DS . 'UploadJob.php'; + + $this->exec('bake job upload --unique'); + $this->assertExitCode(Command::CODE_SUCCESS); + $this->assertFileExists($this->generatedFile); + $this->assertOutputContains('Creating file ' . $this->generatedFile); + $this->assertSameAsFile( + $this->comparisonDir . 'JobTaskWithUnique.php', + file_get_contents($this->generatedFile) + ); + } } diff --git a/tests/comparisons/JobTaskWithUnique.php b/tests/comparisons/JobTaskWithUnique.php new file mode 100644 index 0000000..652b7ed --- /dev/null +++ b/tests/comparisons/JobTaskWithUnique.php @@ -0,0 +1,39 @@ + Date: Fri, 18 Nov 2022 16:24:38 -0500 Subject: [PATCH 11/90] Suppress psalm InvalidPropertyFetch class-string wasn't being recognized as a valid type to call static methods on --- src/Consumption/RemoveUniqueJobIdFromCacheExtension.php | 2 +- src/Job/Message.php | 2 +- src/QueueManager.php | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php b/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php index 4f4fd1b..185db55 100644 --- a/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php +++ b/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php @@ -37,9 +37,9 @@ public function onResult(MessageResult $context): void $jobMessage = new Message($message, $context->getContext()); - /** @psalm-var class-string $class */ [$class, $method] = $jobMessage->getTarget(); + /** @psalm-suppress InvalidPropertyFetch */ if (empty($class::$shouldBeUnique)) { return; } diff --git a/src/Job/Message.php b/src/Job/Message.php index 7984e28..a7f821c 100644 --- a/src/Job/Message.php +++ b/src/Job/Message.php @@ -151,9 +151,9 @@ public function getMaxAttempts(): ?int { $target = $this->getTarget(); - /** @psalm-var class-string $class */ $class = $target[0]; + /** @psalm-suppress InvalidPropertyFetch */ return $class::$maxAttempts ?? null; } diff --git a/src/QueueManager.php b/src/QueueManager.php index edd0660..4c63b6e 100644 --- a/src/QueueManager.php +++ b/src/QueueManager.php @@ -275,6 +275,7 @@ public static function push($className, array $data = [], array $options = []): $client = static::engine($name); $client->sendEvent($queue, $message); + /** @psalm-suppress InvalidPropertyFetch */ if (!empty($class::$shouldBeUnique)) { $uniqueId = static::getUniqueId($class, $method, $data); From f34aa71b2b10d83145441964a59a80da897a6d0d Mon Sep 17 00:00:00 2001 From: Sheldon Reiff Date: Fri, 2 Dec 2022 17:43:36 -0500 Subject: [PATCH 12/90] Fix phstan generic interface error --- src/Listener/FailedJobsListener.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Listener/FailedJobsListener.php b/src/Listener/FailedJobsListener.php index 01325a5..89f5616 100644 --- a/src/Listener/FailedJobsListener.php +++ b/src/Listener/FailedJobsListener.php @@ -16,7 +16,6 @@ */ namespace Cake\Queue\Listener; -use Cake\Event\EventInterface; use Cake\Event\EventListenerInterface; use Cake\ORM\Exception\PersistenceFailedException; use Cake\ORM\Locator\LocatorAwareTrait; @@ -39,10 +38,10 @@ public function implementedEvents(): array } /** - * @param \Cake\Event\EventInterface $event EventInterface + * @param object $event EventInterface. * @return void */ - public function storeFailedJob(EventInterface $event): void + public function storeFailedJob($event): void { /** @var \Cake\Queue\Job\Message $jobMessage */ $jobMessage = $event->getSubject(); From 12376b1c4a964504c18ea195428a1ec7adb27d06 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 9 Dec 2022 11:29:24 +0100 Subject: [PATCH 13/90] Replace serialize/unserialize with json encode/decode --- src/Job/SendMailJob.php | 15 ++++++++---- src/Mailer/Transport/QueueTransport.php | 2 +- tests/TestCase/Job/SendMailJobTest.php | 24 ++++++++++++++++++- .../Mailer/Transport/QueueTransportTest.php | 23 +++++++----------- tests/test_app/files/test.txt | 1 + 5 files changed, 43 insertions(+), 22 deletions(-) create mode 100644 tests/test_app/files/test.txt diff --git a/src/Job/SendMailJob.php b/src/Job/SendMailJob.php index cdfe60b..785fba7 100644 --- a/src/Job/SendMailJob.php +++ b/src/Job/SendMailJob.php @@ -37,14 +37,19 @@ public function execute(Message $message): ?string /** @var \Cake\Mailer\AbstractTransport $transport */ $transport = $this->getTransport($transportClassName, $config); - $emailMessage = unserialize($message->getArgument('emailMessage')); + $emailMessage = new \Cake\Mailer\Message(); + $data = json_decode($message->getArgument('emailMessage'), true); + if (!is_array($data)) { + throw new \InvalidArgumentException('Email Message cannot be decoded.'); + } + $emailMessage->createFromArray($data); $result = $transport->send($emailMessage); } catch (\Exception $e) { Log::error(sprintf('An error has occurred processing message: %s', $e->getMessage())); - } finally { - if (!$result) { - return Processor::REJECT; - } + } + + if (!$result) { + return Processor::REJECT; } return Processor::ACK; diff --git a/src/Mailer/Transport/QueueTransport.php b/src/Mailer/Transport/QueueTransport.php index bda2b06..2c1cc34 100644 --- a/src/Mailer/Transport/QueueTransport.php +++ b/src/Mailer/Transport/QueueTransport.php @@ -86,7 +86,7 @@ protected function prepareData(Message $message): array return [ 'transport' => $this->getConfig('transport'), 'config' => $this->getConfig(), - 'emailMessage' => serialize($message), + 'emailMessage' => json_encode($message), ]; } } diff --git a/tests/TestCase/Job/SendMailJobTest.php b/tests/TestCase/Job/SendMailJobTest.php index 4f56d41..56b7fc8 100644 --- a/tests/TestCase/Job/SendMailJobTest.php +++ b/tests/TestCase/Job/SendMailJobTest.php @@ -16,6 +16,7 @@ */ namespace Cake\Queue\Test\TestCase\Job; +use Cake\Mailer\Mailer; use Cake\Mailer\Transport\DebugTransport; use Cake\Queue\Job\Message; use Cake\Queue\Job\SendMailJob; @@ -62,6 +63,20 @@ public function testExecute() $this->assertSame(Processor::ACK, $actual); } + /** + * Test execute with attachments method + * + * @return void + */ + public function testExecuteWithAttachments() + { + $emailMessage = clone $this->message; + $emailMessage->addAttachments(['test.txt' => ROOT . 'files' . DS . 'test.txt']); + $message = $this->createMessage(DebugTransport::class, [], $emailMessage); + $actual = $this->job->execute($message); + $this->assertSame(Processor::ACK, $actual); + } + /** * Test execute method with invalid transport * @@ -86,6 +101,13 @@ public function testExecuteUnserializableMessage() $this->assertSame(Processor::REJECT, $actual); } + public function testExecuteNoAbstractTransport() + { + $message = $this->createMessage(Mailer::class, [], $this->message); + $actual = $this->job->execute($message); + $this->assertSame(Processor::REJECT, $actual); + } + /** * Create a simple message for testing. * @@ -98,7 +120,7 @@ protected function createMessage($transport, $config, $emailMessage): Message 'data' => [ 'transport' => $transport, 'config' => $config, - 'emailMessage' => serialize($emailMessage), + 'emailMessage' => json_encode($emailMessage), ], ]; diff --git a/tests/TestCase/Mailer/Transport/QueueTransportTest.php b/tests/TestCase/Mailer/Transport/QueueTransportTest.php index c2858ba..45fe4ef 100644 --- a/tests/TestCase/Mailer/Transport/QueueTransportTest.php +++ b/tests/TestCase/Mailer/Transport/QueueTransportTest.php @@ -17,6 +17,7 @@ namespace Cake\Queue\Test\TestCase\Job; use Cake\Queue\Mailer\Transport\QueueTransport; +use Cake\Queue\QueueManager; use Cake\TestSuite\TestCase; class QueueTransportTest extends TestCase @@ -28,26 +29,17 @@ class QueueTransportTest extends TestCase */ public function testSend() { + QueueManager::setConfig('default', [ + 'queue' => 'default', + 'url' => 'null:', + ]); $message = (new \Cake\Mailer\Message()) ->setFrom('from@example.com') ->setTo('to@example.com') ->setSubject('Sample Subject'); - $transport = $this - ->getMockBuilder(QueueTransport::class)->onlyMethods(['enqueueJob']) - ->getMock(); - $expectedData = [ - 'transport' => 'Cake\Mailer\Transport\MailTransport', - 'config' => [ - 'options' => [], - 'transport' => 'Cake\Mailer\Transport\MailTransport', - ], - 'emailMessage' => serialize($message), - ]; - $expectedOptions = []; - $transport->expects($this->once()) - ->method('enqueueJob') - ->with($expectedData, $expectedOptions); + $transport = new QueueTransport(); + $result = $transport->send($message); $headers = $message->getHeadersString( @@ -66,5 +58,6 @@ public function testSend() $expected = ['headers' => $headers, 'message' => 'Message has been enqueued']; $this->assertEquals($expected, $result); + QueueManager::drop('default'); } } diff --git a/tests/test_app/files/test.txt b/tests/test_app/files/test.txt new file mode 100644 index 0000000..49c224b --- /dev/null +++ b/tests/test_app/files/test.txt @@ -0,0 +1 @@ +TEST CONTENT From 8a4278f5380a09739ac74a2d0179f98d293537cc Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 9 Dec 2022 12:57:13 +0100 Subject: [PATCH 14/90] Improve coverage for FailedJobsListener --- .../Listener/FailedJobsListenerTest.php | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/TestCase/Listener/FailedJobsListenerTest.php b/tests/TestCase/Listener/FailedJobsListenerTest.php index 182ad35..7a56ca6 100644 --- a/tests/TestCase/Listener/FailedJobsListenerTest.php +++ b/tests/TestCase/Listener/FailedJobsListenerTest.php @@ -18,8 +18,12 @@ use Cake\Event\Event; use Cake\Event\EventManager; +use Cake\ORM\Entity; +use Cake\ORM\Exception\PersistenceFailedException; +use Cake\ORM\Locator\TableLocator; use Cake\Queue\Job\Message; use Cake\Queue\Listener\FailedJobsListener; +use Cake\Queue\Model\Table\FailedJobsTable; use Cake\Queue\QueueManager; use Cake\TestSuite\TestCase; use Enqueue\Null\NullConnectionFactory; @@ -92,4 +96,75 @@ public function testFailedJobIsAddedWhenEventIsFired() $this->assertSame('example_queue', $failedJob->queue); $this->assertStringContainsString('some message', $failedJob->exception); } + + /** + * Data provider for testStoreFailedJobException + * + * @return array[] + */ + public function storeFailedJobExceptionDataProvider() + { + return [ + [['exception' => 'some message'], '`logger` was not defined on Consumption.LimitAttemptsExtension.failed event'], + [['exception' => 'some message', 'logger' => new \stdClass()], '`logger` is not an instance of `LoggerInterface` on Consumption.LimitAttemptsExtension.failed event.'], + ]; + } + + /** + * @dataProvider storeFailedJobExceptionDataProvider + * @return void + */ + public function testStoreFailedJobException($eventData, $exceptionMessage) + { + $tableLocator = $this + ->getMockBuilder(TableLocator::class) + ->onlyMethods(['get']) + ->getMock(); + $failedJobsTable = $this + ->getMockBuilder(FailedJobsTable::class) + ->onlyMethods(['saveOrFail']) + ->getMock(); + $failedJobsListener = $this + ->getMockBuilder(FailedJobsListener::class) + ->onlyMethods(['getTableLocator']) + ->getMock(); + $failedJobsTable->expects($this->once()) + ->method('saveOrFail') + ->willThrowException(new PersistenceFailedException(new Entity(), 'Persistence Failed')); + $tableLocator->expects($this->once()) + ->method('get') + ->with('Cake/Queue.FailedJobs') + ->willReturn($failedJobsTable); + $failedJobsListener->expects($this->once()) + ->method('getTableLocator') + ->willReturn($tableLocator); + + $parsedBody = [ + 'class' => [LogToDebugJob::class, 'execute'], + 'data' => ['example_key' => 'example_value'], + 'requeueOptions' => [ + 'config' => 'example_config', + 'priority' => 'example_priority', + 'queue' => 'example_queue', + ], + ]; + $messageBody = json_encode($parsedBody); + $connectionFactory = new NullConnectionFactory(); + + $context = $connectionFactory->createContext(); + $originalMessage = new NullMessage($messageBody); + $message = new Message($originalMessage, $context); + + $event = new Event( + 'Consumption.LimitAttemptsExtension.failed', + $message, + $eventData + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($exceptionMessage); + + EventManager::instance()->on($failedJobsListener); + EventManager::instance()->dispatch($event); + } } From 357c9f4b93c66a915e2c26be8b10a26e644d2250 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 16 Dec 2022 11:13:15 +0100 Subject: [PATCH 15/90] Improve unit tests to check send is called with the correct parameters --- tests/TestCase/Job/SendMailJobTest.php | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/TestCase/Job/SendMailJobTest.php b/tests/TestCase/Job/SendMailJobTest.php index 56b7fc8..7ca551c 100644 --- a/tests/TestCase/Job/SendMailJobTest.php +++ b/tests/TestCase/Job/SendMailJobTest.php @@ -58,8 +58,23 @@ public function setUp(): void */ public function testExecute() { + $job = $this->getMockBuilder(SendMailJob::class) + ->onlyMethods(['getTransport']) + ->getMock(); $message = $this->createMessage(DebugTransport::class, [], $this->message); - $actual = $this->job->execute($message); + $emailMessage = new \Cake\Mailer\Message(); + $data = json_decode($message->getArgument('emailMessage'), true); + $emailMessage->createFromArray($data); + $transport = $this->getMockBuilder(DebugTransport::class)->getMock(); + $transport->expects($this->once()) + ->method('send') + ->with($emailMessage) + ->willReturn(['message' => 'test', 'headers' => []]); + $job->expects($this->once()) + ->method('getTransport') + ->with(DebugTransport::class, []) + ->willReturn($transport); + $actual = $job->execute($message); $this->assertSame(Processor::ACK, $actual); } From 9cc3be1f9e72aa320b395a7886ac54aa876e4e20 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Fri, 6 Jan 2023 15:14:08 -0500 Subject: [PATCH 16/90] Add docs for #106 --- docs/en/index.rst | 45 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/docs/en/index.rst b/docs/en/index.rst index 2002bb7..5580b0a 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -178,10 +178,10 @@ Job Properties: example, refreshing calculated data. If ``true``, the ``uniqueCache`` configuration must be set. -Queueing --------- +Queueing Jobs +------------- -Queue the messages using the included `Queue\QueueManager` class:: +You can enqueue jobs using ``Cake\Queue\QueueManager``:: use App\Job\ExampleJob; use Cake\Queue\QueueManager; @@ -239,8 +239,8 @@ The following keys are valid for use within the ``options`` array: - description: The name of a queue to use - type: string -Queuing Mailer Actions ----------------------- +Queueing Mailer Actions +----------------------- Mailer actions can be queued by adding the ``Queue\Mailer\QueueTrait`` to the mailer class. The following example shows how to setup the trait within a mailer @@ -287,6 +287,41 @@ The exposed ``QueueTrait::push()`` method has a similar signature to options this array holds are the same options as those available for ``QueueManager::push()``. +Delivering E-mail via Queue Jobs +-------------------------------- + +If your application isn't using Mailers but you still want to deliver email via +queue jobs, you can use the ``QueueTransport``. In your application's +``EmailTransport`` configuration add a transport:: + + // in app/config.php + use Cake\Queue\Mailer\Transport\QueueTransport; + + return [ + // ... other configuration + 'EmailTransport' => [ + 'queue' => [ + 'className' => QueueTransport::class, + // The transport to use inside the queue job. + 'transport' => MailTransport::class, + 'config' => [ + // Configuration for MailTransport. + ] + ] + ], + 'Email' => [ + 'default' => [ + // Connect the default email profile to deliver + // by queue jobs. + 'transport' => 'queue', + ] + ] + ]; + +With this configuration in place, any time you send an email with the ``default`` +email profile CakePHP will generate a queue message. Once that queue message is +processed the ``MailTransport`` will be used to deliver the email messages. + Run the worker ============== From cd764e590c70d0a0930568abfd042a74390e70a4 Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Fri, 20 Jan 2023 21:00:18 +0100 Subject: [PATCH 17/90] cake5 upgrade + new stan setup --- .editorconfig | 3 + .github/workflows/ci.yml | 90 ++----------------- .gitignore | 1 + .phive/phars.xml | 5 ++ composer.json | 25 ++++-- phpstan.neon | 2 + phpunit.xml.dist | 45 ++++++---- psalm-baseline.xml | 11 +++ psalm.xml | 7 +- src/Command/JobCommand.php | 2 +- src/Command/PurgeFailedCommand.php | 2 +- src/Command/RequeueCommand.php | 16 ++-- src/Command/WorkerCommand.php | 9 +- src/Consumption/LimitAttemptsExtension.php | 5 +- .../LimitConsumedMessagesExtension.php | 4 +- .../RemoveUniqueJobIdFromCacheExtension.php | 2 +- src/Job/Message.php | 38 +++----- src/Job/SendMailJob.php | 13 +-- src/Listener/FailedJobsListener.php | 2 +- src/Mailer/Transport/QueueTransport.php | 5 +- src/Model/Entity/FailedJob.php | 7 +- src/Plugin.php | 11 +-- src/Queue/Processor.php | 15 ++-- src/QueueManager.php | 14 +-- tests/Fixture/FailedJobsFixture.php | 26 +----- .../Command/PurgeFailedCommandTest.php | 10 +-- tests/TestCase/Command/RequeueCommandTest.php | 11 +-- tests/TestCase/Command/WorkerCommandTest.php | 8 +- tests/TestCase/Job/MailerJobTest.php | 3 +- tests/TestCase/Job/SendMailJobTest.php | 22 ++--- .../Listener/FailedJobsListenerTest.php | 8 +- .../Mailer/Transport/QueueTransportTest.php | 3 +- tests/TestCase/PluginTest.php | 3 +- tests/TestCase/Task/JobTaskTest.php | 3 +- tests/bootstrap.php | 7 ++ tests/schema.php | 25 ++++++ 36 files changed, 208 insertions(+), 255 deletions(-) create mode 100644 .phive/phars.xml create mode 100644 psalm-baseline.xml create mode 100644 tests/schema.php diff --git a/.editorconfig b/.editorconfig index 36dfb48..15144ff 100644 --- a/.editorconfig +++ b/.editorconfig @@ -14,5 +14,8 @@ trim_trailing_whitespace = true [*.yml] indent_size = 2 +[phars.xml] +indent_size = 2 + [*.neon] indent_style = tab diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73a69c3..758f712 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,93 +4,19 @@ on: push: branches: - master + - cake5 pull_request: branches: - '*' +permissions: + contents: read + jobs: testsuite: - runs-on: ubuntu-18.04 - strategy: - fail-fast: false - matrix: - php-version: ['7.2', '7.4', '8.0', '8.1'] - prefer-lowest: [''] - include: - - php-version: '7.2' - prefer-lowest: 'prefer-lowest' - - steps: - - uses: actions/checkout@v3 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - extensions: mbstring, intl - coverage: pcov - - - name: Get composer cache directory - id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" - - - name: Get date part for cache key - id: key-date - run: echo "::set-output name=date::$(date +'%Y-%m')" - - - name: Cache composer dependencies - uses: actions/cache@v3 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }} - - - name: Composer install - run: | - if ${{ matrix.prefer-lowest == 'prefer-lowest' }}; then - composer update --prefer-lowest --prefer-stable - elif ${{ matrix.php-version == '8.1' }}; then - composer update --ignore-platform-reqs - else - composer update - fi - - - name: Run PHPUnit - run: | - if ${{ matrix.php-version == '7.4'}}; then - vendor/bin/phpunit --coverage-clover=coverage.xml - else - vendor/bin/phpunit - fi - - - name: Code Coverage Report - if: matrix.php-version == '7.4' - uses: codecov/codecov-action@v3 + uses: cakephp/.github/.github/workflows/testsuite-with-db.yml@5.x + secrets: inherit cs-stan: - name: Coding Standard & Static Analysis - runs-on: ubuntu-18.04 - - steps: - - uses: actions/checkout@v3 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '7.4' - extensions: mbstring, intl - coverage: none - tools: cs2pr - - - name: Composer Install - run: composer stan-setup - - - name: Run PHP CodeSniffer - run: vendor/bin/phpcs --report=checkstyle src/ tests/ | cs2pr - - - name: Run psalm - if: success() || failure() - run: vendor/bin/psalm.phar --output-format=github - - - name: Run phpstan - if: success() || failure() - run: vendor/bin/phpstan.phar analyse --error-format=github + uses: cakephp/.github/.github/workflows/cs-stan.yml@5.x + secrets: inherit diff --git a/.gitignore b/.gitignore index 6be9eff..deffac9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /composer.lock /phpunit.xml /phpcs.xml +/tools /vendor *.mo debug.log diff --git a/.phive/phars.xml b/.phive/phars.xml new file mode 100644 index 0000000..b3c9592 --- /dev/null +++ b/.phive/phars.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composer.json b/composer.json index 7e788c3..7bb9db0 100644 --- a/composer.json +++ b/composer.json @@ -18,16 +18,16 @@ "source": "https://github.com/cakephp/queue" }, "require": { - "php": ">=7.2.0", - "cakephp/cakephp": "^4.1", + "php": ">=8.1", + "cakephp/cakephp": "5.x-dev as 5.0.0", "enqueue/simple-client": "^0.10", - "psr/log": "^1.1 || ^2.0" + "psr/log": "^3.0" }, "require-dev": { - "cakephp/bake": "^2.1", - "cakephp/cakephp-codesniffer": "^4.0", + "cakephp/bake": "3.x-dev as 3.0.0", + "cakephp/cakephp-codesniffer": "^5.0", "enqueue/fs": "^0.10", - "phpunit/phpunit": "^8.5 || ^9.3" + "phpunit/phpunit": "^9.5.19" }, "autoload": { "psr-4": { @@ -52,11 +52,20 @@ ], "cs-check": "phpcs --colors -p src/ tests/", "cs-fix": "phpcbf --colors -p src/ tests/", - "stan": "phpstan analyse src/ && psalm.phar --show-info=false", - "stan-setup": "cp composer.json composer.backup && composer require --dev phpstan/phpstan:^1.5 psalm/phar:~4.22 && mv composer.backup composer.json", + "stan": [ + "@phpstan", + "@psalm" + ], + "phpstan": "tools/phpstan analyse", + "psalm": "tools/psalm --show-info=false", + "stan-baseline": "tools/phpstan --generate-baseline", + "psalm-baseline": "tools/psalm --set-baseline=psalm-baseline.xml", + "stan-setup": "phive install", "test": "phpunit", "test-coverage": "phpunit --coverage-clover=clover.xml" }, + "minimum-stability": "dev", + "prefer-stable": true, "config": { "sort-packages": true, "allow-plugins": { diff --git a/phpstan.neon b/phpstan.neon index f5a1214..fcfb25f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,6 +1,8 @@ parameters: level: 6 checkMissingIterableValueType: false + checkGenericClassInNonGenericObjectType: false + treatPhpDocTypesAsCertain: false bootstrapFiles: - tests/bootstrap.php paths: diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 3481e82..6ec7295 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -4,22 +4,31 @@ colors="true" bootstrap="tests/bootstrap.php" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"> - - - - ./src/ - - - - - tests/TestCase - - - - - - - - - + + + tests/TestCase + + + + + + + + + + + ./src/ + + + + + + + + + diff --git a/psalm-baseline.xml b/psalm-baseline.xml new file mode 100644 index 0000000..471bb63 --- /dev/null +++ b/psalm-baseline.xml @@ -0,0 +1,11 @@ + + + + + \Cake\Queue\Model\Entity\FailedJob[]|\Cake\Datasource\ResultSetInterface + \Cake\Queue\Model\Entity\FailedJob[]|\Cake\Datasource\ResultSetInterface + \Cake\Queue\Model\Entity\FailedJob[]|\Cake\Datasource\ResultSetInterface|false + \Cake\Queue\Model\Entity\FailedJob[]|\Cake\Datasource\ResultSetInterface|false + + + diff --git a/psalm.xml b/psalm.xml index c5f6fe5..5b6b969 100644 --- a/psalm.xml +++ b/psalm.xml @@ -1,9 +1,13 @@ @@ -13,12 +17,9 @@ - - - diff --git a/src/Command/JobCommand.php b/src/Command/JobCommand.php index d845ac2..bf0c1c0 100644 --- a/src/Command/JobCommand.php +++ b/src/Command/JobCommand.php @@ -22,7 +22,7 @@ class JobCommand extends SimpleBakeCommand { - public $pathFragment = 'Job/'; + public string $pathFragment = 'Job/'; /** * @inheritDoc diff --git a/src/Command/PurgeFailedCommand.php b/src/Command/PurgeFailedCommand.php index a6b7602..f2c0d3a 100644 --- a/src/Command/PurgeFailedCommand.php +++ b/src/Command/PurgeFailedCommand.php @@ -74,7 +74,7 @@ public function getOptionParser(): ConsoleOptionParser * @param \Cake\Console\ConsoleIo $io ConsoleIo * @return void */ - public function execute(Arguments $args, ConsoleIo $io) + public function execute(Arguments $args, ConsoleIo $io): void { /** @var \Cake\Queue\Model\Table\FailedJobsTable $failedJobsTable */ $failedJobsTable = $this->getTableLocator()->get('Cake/Queue.FailedJobs'); diff --git a/src/Command/RequeueCommand.php b/src/Command/RequeueCommand.php index ea76d94..de6cb88 100644 --- a/src/Command/RequeueCommand.php +++ b/src/Command/RequeueCommand.php @@ -76,12 +76,12 @@ public function getOptionParser(): ConsoleOptionParser * @param \Cake\Console\ConsoleIo $io ConsoleIo * @return void */ - public function execute(Arguments $args, ConsoleIo $io) + public function execute(Arguments $args, ConsoleIo $io): void { /** @var \Cake\Queue\Model\Table\FailedJobsTable $failedJobsTable */ $failedJobsTable = $this->getTableLocator()->get('Cake/Queue.FailedJobs'); - $jobsToRequeue = $failedJobsTable->find(); + $jobsToRequeueQuery = $failedJobsTable->find(); if ($args->hasArgument('ids')) { $idsArg = $args->getArgument('ids'); @@ -89,23 +89,23 @@ public function execute(Arguments $args, ConsoleIo $io) if ($idsArg !== null) { $ids = explode(',', $idsArg); - $jobsToRequeue->whereInList('id', $ids); + $jobsToRequeueQuery->whereInList('id', $ids); } } if ($args->hasOption('class')) { - $jobsToRequeue->where(['class' => $args->getOption('class')]); + $jobsToRequeueQuery->where(['class' => $args->getOption('class')]); } if ($args->hasOption('queue')) { - $jobsToRequeue->where(['queue' => $args->getOption('queue')]); + $jobsToRequeueQuery->where(['queue' => $args->getOption('queue')]); } if ($args->hasOption('config')) { - $jobsToRequeue->where(['config' => $args->getOption('config')]); + $jobsToRequeueQuery->where(['config' => $args->getOption('config')]); } - $requeueingCount = $jobsToRequeue->count(); + $requeueingCount = $jobsToRequeueQuery->count(); if (!$requeueingCount) { $io->out('0 jobs found.'); @@ -126,6 +126,8 @@ public function execute(Arguments $args, ConsoleIo $io) $succeededCount = 0; $failedCount = 0; + /** @var array<\Cake\Queue\Model\Entity\FailedJob> $jobsToRequeue */ + $jobsToRequeue = $jobsToRequeueQuery->all(); foreach ($jobsToRequeue as $failedJob) { $io->verbose("Requeueing FailedJob with ID {$failedJob->id}."); try { diff --git a/src/Command/WorkerCommand.php b/src/Command/WorkerCommand.php index 18dbfa0..d334070 100644 --- a/src/Command/WorkerCommand.php +++ b/src/Command/WorkerCommand.php @@ -45,14 +45,13 @@ class WorkerCommand extends Command /** * @var \Cake\Core\ContainerInterface|null */ - protected $container; + protected ?ContainerInterface $container = null; /** * @param \Cake\Core\ContainerInterface|null $container DI container instance */ public function __construct(?ContainerInterface $container = null) { - parent::__construct(); $this->container = $container; } @@ -168,9 +167,9 @@ protected function getLogger(Arguments $args): LoggerInterface /** * @param \Cake\Console\Arguments $args Arguments * @param \Cake\Console\ConsoleIo $io ConsoleIo - * @return int|void|null + * @return int */ - public function execute(Arguments $args, ConsoleIo $io) + public function execute(Arguments $args, ConsoleIo $io): int { $logger = $this->getLogger($args); $processor = new Processor($logger, $this->container); @@ -202,5 +201,7 @@ public function execute(Arguments $args, ConsoleIo $io) $client->bindTopic($queue, $processor, $processorName); $client->consume($extension); + + return self::CODE_SUCCESS; } } diff --git a/src/Consumption/LimitAttemptsExtension.php b/src/Consumption/LimitAttemptsExtension.php index c7d19f9..6cb7f06 100644 --- a/src/Consumption/LimitAttemptsExtension.php +++ b/src/Consumption/LimitAttemptsExtension.php @@ -12,6 +12,9 @@ class LimitAttemptsExtension implements MessageResultExtensionInterface { + /** + * @use \Cake\Event\EventDispatcherTrait<\Cake\Queue\Job\Message> + */ use EventDispatcherTrait; /** @@ -27,7 +30,7 @@ class LimitAttemptsExtension implements MessageResultExtensionInterface * * @var int|null */ - protected $maxAttempts; + protected ?int $maxAttempts = null; /** * @param int|null $maxAttempts The maximum number of times a job may be attempted. diff --git a/src/Consumption/LimitConsumedMessagesExtension.php b/src/Consumption/LimitConsumedMessagesExtension.php index 69f5e75..680f8b4 100644 --- a/src/Consumption/LimitConsumedMessagesExtension.php +++ b/src/Consumption/LimitConsumedMessagesExtension.php @@ -21,12 +21,12 @@ class LimitConsumedMessagesExtension implements PreConsumeExtensionInterface, Po /** * @var int */ - protected $messageLimit; + protected int $messageLimit; /** * @var int */ - protected $messageConsumed = 0; + protected int $messageConsumed = 0; /** * @param int $messageLimit The number of messages to process before exiting. diff --git a/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php b/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php index 185db55..9aeb8d5 100644 --- a/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php +++ b/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php @@ -16,7 +16,7 @@ class RemoveUniqueJobIdFromCacheExtension implements MessageResultExtensionInter * * @var string */ - protected $cache; + protected string $cache; /** * @param string $cache Cache engine name. diff --git a/src/Job/Message.php b/src/Job/Message.php index be37a6f..4f3d8a4 100644 --- a/src/Job/Message.php +++ b/src/Job/Message.php @@ -22,34 +22,20 @@ use Interop\Queue\Context; use Interop\Queue\Message as QueueMessage; use JsonSerializable; +use ReturnTypeWillChange; use RuntimeException; class Message implements JsonSerializable { - /** - * @var \Interop\Queue\Context - */ - protected $context; + protected Context $context; - /** - * @var \Interop\Queue\Message - */ - protected $originalMessage; + protected QueueMessage $originalMessage; - /** - * @var array - */ - protected $parsedBody; + protected array $parsedBody; - /** - * @var \Closure|null - */ - protected $callable; + protected ?Closure $callable = null; - /** - * @var \Cake\Core\ContainerInterface|null - */ - protected $container; + protected ?ContainerInterface $container = null; /** * @param \Interop\Queue\Message $originalMessage Queue message. @@ -96,7 +82,7 @@ public function getParsedBody(): array * * @return \Closure */ - public function getCallable() + public function getCallable(): Closure { if ($this->callable) { return $this->callable; @@ -139,7 +125,7 @@ public function getTarget(): array * @param mixed $default Default value. * @return mixed */ - public function getArgument($key = null, $default = null) + public function getArgument(mixed $key = null, mixed $default = null): mixed { if (array_key_exists('data', $this->parsedBody)) { $data = $this->parsedBody['data']; @@ -158,7 +144,7 @@ public function getArgument($key = null, $default = null) /** * The maximum number of attempts allowed by the job. * - * @return null|int + * @return int|null */ public function getMaxAttempts(): ?int { @@ -174,7 +160,7 @@ public function getMaxAttempts(): ?int * @return string * @psalm-suppress InvalidToString */ - public function __toString() + public function __toString(): string { return json_encode($this); } @@ -182,8 +168,8 @@ public function __toString() /** * @return array */ - #[\ReturnTypeWillChange] - public function jsonSerialize() + #[ReturnTypeWillChange] + public function jsonSerialize(): array { return $this->parsedBody; } diff --git a/src/Job/SendMailJob.php b/src/Job/SendMailJob.php index 785fba7..c642723 100644 --- a/src/Job/SendMailJob.php +++ b/src/Job/SendMailJob.php @@ -18,7 +18,10 @@ use Cake\Log\Log; use Cake\Mailer\AbstractTransport; +use Cake\Mailer\Message as CakeMessage; use Cake\Queue\Queue\Processor; +use Exception; +use InvalidArgumentException; /** * SendMailJob class to be used by QueueTransport to enqueue emails @@ -37,14 +40,14 @@ public function execute(Message $message): ?string /** @var \Cake\Mailer\AbstractTransport $transport */ $transport = $this->getTransport($transportClassName, $config); - $emailMessage = new \Cake\Mailer\Message(); + $emailMessage = new CakeMessage(); $data = json_decode($message->getArgument('emailMessage'), true); if (!is_array($data)) { - throw new \InvalidArgumentException('Email Message cannot be decoded.'); + throw new InvalidArgumentException('Email Message cannot be decoded.'); } $emailMessage->createFromArray($data); $result = $transport->send($emailMessage); - } catch (\Exception $e) { + } catch (Exception $e) { Log::error(sprintf('An error has occurred processing message: %s', $e->getMessage())); } @@ -70,13 +73,13 @@ protected function getTransport(string $transportClassName, array $config): Abst !class_exists($transportClassName) || !method_exists($transportClassName, 'send') ) { - throw new \InvalidArgumentException(sprintf('Transport class name is not valid: %s', $transportClassName)); + throw new InvalidArgumentException(sprintf('Transport class name is not valid: %s', $transportClassName)); } $transport = new $transportClassName($config); if (!($transport instanceof AbstractTransport)) { - throw new \InvalidArgumentException('Provided class does not extend AbstractTransport.'); + throw new InvalidArgumentException('Provided class does not extend AbstractTransport.'); } return $transport; diff --git a/src/Listener/FailedJobsListener.php b/src/Listener/FailedJobsListener.php index 89f5616..17b5acf 100644 --- a/src/Listener/FailedJobsListener.php +++ b/src/Listener/FailedJobsListener.php @@ -41,7 +41,7 @@ public function implementedEvents(): array * @param object $event EventInterface. * @return void */ - public function storeFailedJob($event): void + public function storeFailedJob(object $event): void { /** @var \Cake\Queue\Job\Message $jobMessage */ $jobMessage = $event->getSubject(); diff --git a/src/Mailer/Transport/QueueTransport.php b/src/Mailer/Transport/QueueTransport.php index 2c1cc34..fc04cf9 100644 --- a/src/Mailer/Transport/QueueTransport.php +++ b/src/Mailer/Transport/QueueTransport.php @@ -16,19 +16,20 @@ */ namespace Cake\Queue\Mailer\Transport; +use Cake\Mailer\AbstractTransport; use Cake\Mailer\Message; use Cake\Mailer\Transport\MailTransport; use Cake\Queue\Job\SendMailJob; use Cake\Queue\QueueManager; -class QueueTransport extends \Cake\Mailer\AbstractTransport +class QueueTransport extends AbstractTransport { /** * Default config for this class * * @var array */ - protected $_defaultConfig = [ + protected array $_defaultConfig = [ 'options' => [], 'transport' => MailTransport::class, ]; diff --git a/src/Model/Entity/FailedJob.php b/src/Model/Entity/FailedJob.php index dcf6b90..a0e0c7a 100644 --- a/src/Model/Entity/FailedJob.php +++ b/src/Model/Entity/FailedJob.php @@ -16,7 +16,9 @@ * @property string|null $priority * @property string|null $queue * @property string|null $exception - * @property \Cake\I18n\FrozenTime|null $created + * @property \Cake\I18n\DateTime|null $created + * + * @property array $decoded_data */ class FailedJob extends Entity { @@ -31,7 +33,7 @@ class FailedJob extends Entity * * @var array */ - protected $_accessible = [ + protected array $_accessible = [ 'class' => true, 'method' => true, 'data' => true, @@ -44,6 +46,7 @@ class FailedJob extends Entity /** * @return array + * @see \Cake\Queue\Model\Entity\FailedJob::$decoded_data */ protected function _getDecodedData(): array { diff --git a/src/Plugin.php b/src/Plugin.php index 010ef1a..413fb5c 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -25,6 +25,7 @@ use Cake\Queue\Command\PurgeFailedCommand; use Cake\Queue\Command\RequeueCommand; use Cake\Queue\Command\WorkerCommand; +use InvalidArgumentException; /** * Plugin for Queue @@ -33,17 +34,13 @@ class Plugin extends BasePlugin { /** * Plugin name. - * - * @var string */ - protected $name = 'Cake/Queue'; + protected ?string $name = 'Cake/Queue'; /** * Load routes or not - * - * @var bool */ - protected $routesEnabled = false; + protected bool $routesEnabled = false; /** * Load the Queue configuration @@ -54,7 +51,7 @@ class Plugin extends BasePlugin public function bootstrap(PluginApplicationInterface $app): void { if (!Configure::read('Queue')) { - throw new \InvalidArgumentException( + throw new InvalidArgumentException( 'Missing `Queue` configuration key, please check the CakePHP Queue documentation' . ' to complete the plugin setup.' ); diff --git a/src/Queue/Processor.php b/src/Queue/Processor.php index 5051079..5e7520e 100644 --- a/src/Queue/Processor.php +++ b/src/Queue/Processor.php @@ -31,17 +31,20 @@ class Processor implements InteropProcessor { + /** + * @use \Cake\Event\EventDispatcherTrait<\Cake\Queue\Queue\Processor> + */ use EventDispatcherTrait; /** * @var \Psr\Log\LoggerInterface */ - protected $logger; + protected LoggerInterface $logger; /** * @var \Cake\Core\ContainerInterface|null */ - protected $container; + protected ?ContainerInterface $container = null; /** * Processor constructor @@ -60,9 +63,9 @@ public function __construct(?LoggerInterface $logger = null, ?ContainerInterface * * @param \Interop\Queue\Message $message Message. * @param \Interop\Queue\Context $context Context. - * @return string|object with __toString method implemented + * @return object|string with __toString method implemented */ - public function process(QueueMessage $message, Context $context) + public function process(QueueMessage $message, Context $context): string|object { $this->dispatchEvent('Processor.message.seen', ['queueMessage' => $message]); @@ -112,9 +115,9 @@ public function process(QueueMessage $message, Context $context) /** * @param \Cake\Queue\Job\Message $message Message. - * @return string|object with __toString method implemented + * @return object|string with __toString method implemented */ - public function processMessage(Message $message) + public function processMessage(Message $message): string|object { $callable = $message->getCallable(); $response = $callable($message); diff --git a/src/QueueManager.php b/src/QueueManager.php index 4c63b6e..412b851 100644 --- a/src/QueueManager.php +++ b/src/QueueManager.php @@ -32,14 +32,14 @@ class QueueManager * * @var array */ - protected static $_config = []; + protected static array $_config = []; /** * Queue clients * * @var array */ - protected static $_clients = []; + protected static array $_clients = []; /** * This method can be used to define configuration adapters for an application. @@ -72,13 +72,13 @@ class QueueManager * QueueManager::setConfig($arrayOfConfig); * ``` * - * @param string|array $key The name of the configuration, or an array of multiple configs. + * @param array|string $key The name of the configuration, or an array of multiple configs. * @param array $config An array of name => configuration data for adapter. * @throws \BadMethodCallException When trying to modify an existing config. * @throws \LogicException When trying to store an invalid structured config array. * @return void */ - public static function setConfig($key, $config = null): void + public static function setConfig(string|array $key, ?array $config = null): void { if ($config === null) { if (!is_array($key)) { @@ -140,7 +140,7 @@ public static function setConfig($key, $config = null): void * @param string $key The name of the configuration. * @return mixed Configuration data at the named key or null if the key does not exist. */ - public static function getConfig(string $key) + public static function getConfig(string $key): mixed { return static::$_config[$key] ?? null; } @@ -188,7 +188,7 @@ public static function engine(string $name): SimpleClient /** * Push a single job onto the queue. * - * @param string|string[] $className The classname of a job that implements the + * @param array|string $className The classname of a job that implements the * \Cake\Queue\Job\JobInterface. The class will be constructed by * \Cake\Queue\Processor and have the execute method invoked. * @param array $data An array of data that will be passed to the job. @@ -209,7 +209,7 @@ public static function engine(string $name): SimpleClient * string 'default' if empty. * @return void */ - public static function push($className, array $data = [], array $options = []): void + public static function push(string|array $className, array $data = [], array $options = []): void { [$class, $method] = is_array($className) ? $className : [$className, 'execute']; diff --git a/tests/Fixture/FailedJobsFixture.php b/tests/Fixture/FailedJobsFixture.php index 2f0da38..c44743e 100644 --- a/tests/Fixture/FailedJobsFixture.php +++ b/tests/Fixture/FailedJobsFixture.php @@ -12,32 +12,8 @@ */ class FailedJobsFixture extends TestFixture { - public $table = 'queue_failed_jobs'; + public string $table = 'queue_failed_jobs'; - /** - * Fields - * - * @var array - */ - // phpcs:disable - public $fields = [ - 'id' => ['type' => 'integer', 'length' => null, 'unsigned' => false, 'null' => false, 'default' => null, 'comment' => '', 'autoIncrement' => true, 'precision' => null], - 'class' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], - 'method' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], - 'data' => ['type' => 'text', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], - 'config' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], - 'priority' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], - 'queue' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], - 'exception' => ['type' => 'text', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], - 'created' => ['type' => 'datetime', 'length' => null, 'precision' => null, 'null' => true, 'default' => null, 'comment' => ''], - '_constraints' => [ - 'primary' => ['type' => 'primary', 'columns' => ['id'], 'length' => []], - ], - '_options' => [ - 'engine' => 'InnoDB', - ], - ]; - // phpcs:enable /** * Init method * diff --git a/tests/TestCase/Command/PurgeFailedCommandTest.php b/tests/TestCase/Command/PurgeFailedCommandTest.php index af7a1b2..cf5cd6f 100644 --- a/tests/TestCase/Command/PurgeFailedCommandTest.php +++ b/tests/TestCase/Command/PurgeFailedCommandTest.php @@ -16,7 +16,7 @@ */ namespace Cake\Queue\Test\TestCase\Command; -use Cake\TestSuite\ConsoleIntegrationTestTrait; +use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; use Cake\TestSuite\TestCase; use TestApp\Job\LogToDebugJob; @@ -29,16 +29,10 @@ class PurgeFailedCommandTest extends TestCase { use ConsoleIntegrationTestTrait; - protected $fixtures = [ + protected array $fixtures = [ 'plugin.Cake/Queue.FailedJobs', ]; - public function setUp(): void - { - parent::setUp(); - $this->useCommandRunner(); - } - public function testFailedJobsAreDeleted() { $this->exec('queue purge_failed', ['y']); diff --git a/tests/TestCase/Command/RequeueCommandTest.php b/tests/TestCase/Command/RequeueCommandTest.php index e4bc6a0..51929d5 100644 --- a/tests/TestCase/Command/RequeueCommandTest.php +++ b/tests/TestCase/Command/RequeueCommandTest.php @@ -16,11 +16,11 @@ */ namespace Cake\Queue\Test\TestCase\Command; +use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; use Cake\Core\Configure; use Cake\Log\Log; use Cake\Queue\QueueManager; use Cake\Queue\Test\TestCase\DebugLogTrait; -use Cake\TestSuite\ConsoleIntegrationTestTrait; use Cake\TestSuite\TestCase; use TestApp\Job\LogToDebugJob; @@ -34,14 +34,13 @@ class RequeueCommandTest extends TestCase use ConsoleIntegrationTestTrait; use DebugLogTrait; - protected $fixtures = [ + protected array $fixtures = [ 'plugin.Cake/Queue.FailedJobs', ]; public function setUp(): void { parent::setUp(); - $this->useCommandRunner(); Log::setConfig('debug', [ 'className' => 'Array', @@ -74,7 +73,6 @@ public function testJobsAreRequeued() $this->assertDebugLogContainsExactly('MaxAttemptsIsThreeJob is requeueing', 0); $this->cleanupConsoleTrait(); - $this->useCommandRunner(); $this->exec('queue requeue --queue default', ['y']); $this->assertOutputContains('Requeueing 2 jobs.'); @@ -95,7 +93,6 @@ public function testJobsAreNotRequeuedIfNotConfirmed() $this->assertDebugLogContainsExactly('MaxAttemptsIsThreeJob is requeueing', 0); $this->cleanupConsoleTrait(); - $this->useCommandRunner(); $this->exec('queue requeue --queue default', ['n']); $this->assertOutputNotContains('Requeueing'); @@ -116,7 +113,6 @@ public function testJobsAreRequeuedById() $this->assertDebugLogContainsExactly('MaxAttemptsIsThreeJob was run', 0); $this->cleanupConsoleTrait(); - $this->useCommandRunner(); $this->exec('queue requeue 1,2 -f'); $this->assertOutputContains('Requeueing 2 jobs.'); @@ -136,7 +132,6 @@ public function testJobsAreRequeuedByClass() $this->assertDebugLogContainsExactly('Debug job was run', 0); $this->cleanupConsoleTrait(); - $this->useCommandRunner(); $class = LogToDebugJob::class; $this->exec("queue requeue --class {$class} --queue default -f"); @@ -163,7 +158,6 @@ public function testJobsAreRequeuedByQueue() $this->assertDebugLogContainsExactly('Debug job was run', 0); $this->cleanupConsoleTrait(); - $this->useCommandRunner(); $this->exec('queue requeue --queue alternate_queue -f'); $this->assertOutputContains('Requeueing 1 jobs.'); @@ -190,7 +184,6 @@ public function testJobsAreRequeuedByConfig() $this->assertDebugLogContainsExactly('Debug job was run', 0); $this->cleanupConsoleTrait(); - $this->useCommandRunner(); $this->exec('queue requeue --config alternate_config -f'); $this->assertOutputContains('Requeueing 1 jobs.'); diff --git a/tests/TestCase/Command/WorkerCommandTest.php b/tests/TestCase/Command/WorkerCommandTest.php index 3034bb6..0a42214 100644 --- a/tests/TestCase/Command/WorkerCommandTest.php +++ b/tests/TestCase/Command/WorkerCommandTest.php @@ -16,12 +16,12 @@ */ namespace Cake\Queue\Test\TestCase\Command; +use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; use Cake\Core\Configure; use Cake\Log\Log; use Cake\Queue\QueueManager; use Cake\Queue\Test\test_app\src\Job\LogToDebugWithServiceJob; use Cake\Queue\Test\TestCase\DebugLogTrait; -use Cake\TestSuite\ConsoleIntegrationTestTrait; use Cake\TestSuite\TestCase; use TestApp\Job\LogToDebugJob; use TestApp\Job\RequeueJob; @@ -37,12 +37,6 @@ class WorkerCommandTest extends TestCase use ConsoleIntegrationTestTrait; use DebugLogTrait; - public function setUp(): void - { - parent::setUp(); - $this->useCommandRunner(); - } - /** * Test that command description prints out */ diff --git a/tests/TestCase/Job/MailerJobTest.php b/tests/TestCase/Job/MailerJobTest.php index f083153..112a69d 100644 --- a/tests/TestCase/Job/MailerJobTest.php +++ b/tests/TestCase/Job/MailerJobTest.php @@ -16,6 +16,7 @@ */ namespace Cake\Queue\Test\TestCase\Job; +use BadMethodCallException; use Cake\Mailer\Exception\MissingMailerException; use Cake\Mailer\Mailer; use Cake\Queue\Job\MailerJob; @@ -122,7 +123,7 @@ public function testExecuteBadMethodCallException() $this->equalTo($this->args), $this->equalTo($this->headers) ) - ->willThrowException(new \BadMethodCallException('Welcome is not a valid method')); + ->willThrowException(new BadMethodCallException('Welcome is not a valid method')); $this->job->expects($this->once()) ->method('getMailer') diff --git a/tests/TestCase/Job/SendMailJobTest.php b/tests/TestCase/Job/SendMailJobTest.php index 7ca551c..78090f9 100644 --- a/tests/TestCase/Job/SendMailJobTest.php +++ b/tests/TestCase/Job/SendMailJobTest.php @@ -17,8 +17,9 @@ namespace Cake\Queue\Test\TestCase\Job; use Cake\Mailer\Mailer; +use Cake\Mailer\Message as CakeMessage; use Cake\Mailer\Transport\DebugTransport; -use Cake\Queue\Job\Message; +use Cake\Queue\Job\Message as QueueJobMessage; use Cake\Queue\Job\SendMailJob; use Cake\Queue\Queue\Processor; use Cake\TestSuite\TestCase; @@ -27,15 +28,9 @@ class SendMailJobTest extends TestCase { - /** - * @var \Cake\Queue\Job\SendMailJob - */ - protected $job; + protected SendMailJob $job; - /** - * @var \Cake\Mailer\Message - */ - protected $message; + protected CakeMessage $message; /** * @inheritDoc @@ -45,7 +40,7 @@ public function setUp(): void parent::setUp(); $this->job = new SendMailJob(); - $this->message = (new \Cake\Mailer\Message()) + $this->message = (new CakeMessage()) ->setFrom('from@example.com') ->setTo('to@example.com') ->setSubject('Sample Subject'); @@ -62,7 +57,7 @@ public function testExecute() ->onlyMethods(['getTransport']) ->getMock(); $message = $this->createMessage(DebugTransport::class, [], $this->message); - $emailMessage = new \Cake\Mailer\Message(); + $emailMessage = new CakeMessage(); $data = json_decode($message->getArgument('emailMessage'), true); $emailMessage->createFromArray($data); $transport = $this->getMockBuilder(DebugTransport::class)->getMock(); @@ -128,7 +123,7 @@ public function testExecuteNoAbstractTransport() * * @return \Cake\Queue\Job\Message */ - protected function createMessage($transport, $config, $emailMessage): Message + protected function createMessage($transport, $config, $emailMessage): QueueJobMessage { $messageBody = [ 'class' => ['Queue\\Job\\SendMailJob', 'execute'], @@ -142,8 +137,7 @@ protected function createMessage($transport, $config, $emailMessage): Message $connectionFactory = new NullConnectionFactory(); $context = $connectionFactory->createContext(); $originalMessage = new NullMessage(json_encode($messageBody)); - $message = new Message($originalMessage, $context); - return $message; + return new QueueJobMessage($originalMessage, $context); } } diff --git a/tests/TestCase/Listener/FailedJobsListenerTest.php b/tests/TestCase/Listener/FailedJobsListenerTest.php index 7a56ca6..89193bc 100644 --- a/tests/TestCase/Listener/FailedJobsListenerTest.php +++ b/tests/TestCase/Listener/FailedJobsListenerTest.php @@ -28,11 +28,13 @@ use Cake\TestSuite\TestCase; use Enqueue\Null\NullConnectionFactory; use Enqueue\Null\NullMessage; +use RuntimeException; +use stdClass; use TestApp\Job\LogToDebugJob; class FailedJobsListenerTest extends TestCase { - protected $fixtures = [ + protected array $fixtures = [ 'plugin.Cake/Queue.FailedJobs', ]; @@ -106,7 +108,7 @@ public function storeFailedJobExceptionDataProvider() { return [ [['exception' => 'some message'], '`logger` was not defined on Consumption.LimitAttemptsExtension.failed event'], - [['exception' => 'some message', 'logger' => new \stdClass()], '`logger` is not an instance of `LoggerInterface` on Consumption.LimitAttemptsExtension.failed event.'], + [['exception' => 'some message', 'logger' => new stdClass()], '`logger` is not an instance of `LoggerInterface` on Consumption.LimitAttemptsExtension.failed event.'], ]; } @@ -161,7 +163,7 @@ public function testStoreFailedJobException($eventData, $exceptionMessage) $eventData ); - $this->expectException(\RuntimeException::class); + $this->expectException(RuntimeException::class); $this->expectExceptionMessage($exceptionMessage); EventManager::instance()->on($failedJobsListener); diff --git a/tests/TestCase/Mailer/Transport/QueueTransportTest.php b/tests/TestCase/Mailer/Transport/QueueTransportTest.php index 45fe4ef..ff9e8df 100644 --- a/tests/TestCase/Mailer/Transport/QueueTransportTest.php +++ b/tests/TestCase/Mailer/Transport/QueueTransportTest.php @@ -16,6 +16,7 @@ */ namespace Cake\Queue\Test\TestCase\Job; +use Cake\Mailer\Message; use Cake\Queue\Mailer\Transport\QueueTransport; use Cake\Queue\QueueManager; use Cake\TestSuite\TestCase; @@ -33,7 +34,7 @@ public function testSend() 'queue' => 'default', 'url' => 'null:', ]); - $message = (new \Cake\Mailer\Message()) + $message = (new Message()) ->setFrom('from@example.com') ->setTo('to@example.com') ->setSubject('Sample Subject'); diff --git a/tests/TestCase/PluginTest.php b/tests/TestCase/PluginTest.php index 9a8eb87..c48e060 100644 --- a/tests/TestCase/PluginTest.php +++ b/tests/TestCase/PluginTest.php @@ -7,6 +7,7 @@ use Cake\Queue\Plugin; use Cake\Queue\QueueManager; use Cake\TestSuite\TestCase; +use InvalidArgumentException; use TestApp\Application; class PluginTest extends TestCase @@ -18,7 +19,7 @@ class PluginTest extends TestCase */ public function testBootstrapNoConfig() { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Missing `Queue` configuration key, please check the CakePHP Queue documentation to complete the plugin setup'); Configure::delete('Queue'); $plugin = new Plugin(); diff --git a/tests/TestCase/Task/JobTaskTest.php b/tests/TestCase/Task/JobTaskTest.php index 76d2b0e..6c1af51 100644 --- a/tests/TestCase/Task/JobTaskTest.php +++ b/tests/TestCase/Task/JobTaskTest.php @@ -17,8 +17,8 @@ namespace Cake\Queue\Test\TestCase\Task; use Cake\Command\Command; +use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; use Cake\Queue\QueueManager; -use Cake\TestSuite\ConsoleIntegrationTestTrait; use Cake\TestSuite\StringCompareTrait; use Cake\TestSuite\TestCase; @@ -45,7 +45,6 @@ public function setUp(): void parent::setUp(); $this->comparisonDir = dirname(dirname(__DIR__)) . DS . 'comparisons' . DS; - $this->useCommandRunner(); QueueManager::drop('default'); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 66530a9..2435ee2 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -18,6 +18,7 @@ use Cake\Cache\Cache; use Cake\Core\Configure; use Cake\Routing\Router; +use Cake\TestSuite\Fixture\SchemaLoader; $findRoot = function ($root) { do { @@ -99,3 +100,9 @@ ]); Router::reload(); + +// Create test database schema +if (env('FIXTURE_SCHEMA_METADATA')) { + $loader = new SchemaLoader(); + $loader->loadInternalFile(env('FIXTURE_SCHEMA_METADATA'), 'test'); +} diff --git a/tests/schema.php b/tests/schema.php new file mode 100644 index 0000000..3aeba16 --- /dev/null +++ b/tests/schema.php @@ -0,0 +1,25 @@ + 'queue_failed_jobs', + 'columns' => [ + 'id' => ['type' => 'integer', 'length' => null, 'unsigned' => false, 'null' => false, 'default' => null, 'comment' => '', 'autoIncrement' => true, 'precision' => null], + 'class' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], + 'method' => ['type' => 'string', 'length' => 255, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], + 'data' => ['type' => 'text', 'length' => null, 'null' => false, 'default' => null, 'comment' => '', 'precision' => null], + 'config' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], + 'priority' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], + 'queue' => ['type' => 'string', 'length' => 255, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], + 'exception' => ['type' => 'text', 'length' => null, 'null' => true, 'default' => null, 'comment' => '', 'precision' => null], + 'created' => ['type' => 'datetime', 'length' => null, 'precision' => null, 'null' => true, 'default' => null, 'comment' => ''], + ], + 'constraints' => [ + 'primary' => [ + 'type' => 'primary', + 'columns' => ['id'], + ], + ], + ], +]; From 407fad58b1d2681d881edb9ebe5ad42ac6f011ff Mon Sep 17 00:00:00 2001 From: Alex Mayer Date: Wed, 15 Feb 2023 14:25:54 -0500 Subject: [PATCH 18/90] Catch All Throwables while Processing Message Fixes #111 --- src/Queue/Processor.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Queue/Processor.php b/src/Queue/Processor.php index 5051079..9fdb602 100644 --- a/src/Queue/Processor.php +++ b/src/Queue/Processor.php @@ -21,13 +21,13 @@ use Cake\Queue\Job\Message; use Enqueue\Consumption\Result; use Error; -use Exception; use Interop\Queue\Context; use Interop\Queue\Message as QueueMessage; use Interop\Queue\Processor as InteropProcessor; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use RuntimeException; +use Throwable; class Processor implements InteropProcessor { @@ -80,7 +80,7 @@ public function process(QueueMessage $message, Context $context) try { $response = $this->processMessage($jobMessage); - } catch (Exception $e) { + } catch (Throwable $e) { $this->logger->debug(sprintf('Message encountered exception: %s', $e->getMessage())); $this->dispatchEvent('Processor.message.exception', [ 'message' => $jobMessage, From 3f731e870a87e1ed81e3e424d5eaa02c4c6b77b0 Mon Sep 17 00:00:00 2001 From: Arhell Date: Thu, 23 Feb 2023 00:11:22 +0200 Subject: [PATCH 19/90] update tests folder links --- tests/TestCase/QueueManagerTest.php | 10 +++++----- tests/TestCase/Task/JobTaskTest.php | 10 +++++----- tests/bootstrap.php | 10 +++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/TestCase/QueueManagerTest.php b/tests/TestCase/QueueManagerTest.php index 1de3ba8..0de0c27 100644 --- a/tests/TestCase/QueueManagerTest.php +++ b/tests/TestCase/QueueManagerTest.php @@ -2,17 +2,17 @@ declare(strict_types=1); /** - * CakePHP(tm) : Rapid Development Framework (http://cakephp.org) - * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org) + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) * * Licensed under The MIT License * For full copyright and license information, please see the LICENSE.txt * Redistributions of files must retain the above copyright notice. * - * @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://cakephp.org CakePHP(tm) Project + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project * @since 0.1.0 - * @license http://www.opensource.org/licenses/mit-license.php MIT License + * @license https://www.opensource.org/licenses/mit-license.php MIT License */ namespace Cake\Queue\Test\TestCase; diff --git a/tests/TestCase/Task/JobTaskTest.php b/tests/TestCase/Task/JobTaskTest.php index 76d2b0e..8baa724 100644 --- a/tests/TestCase/Task/JobTaskTest.php +++ b/tests/TestCase/Task/JobTaskTest.php @@ -2,17 +2,17 @@ declare(strict_types=1); /** - * CakePHP(tm) : Rapid Development Framework (http://cakephp.org) - * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org) + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) * * Licensed under The MIT License * For full copyright and license information, please see the LICENSE.txt * Redistributions of files must retain the above copyright notice. * - * @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://cakephp.org CakePHP(tm) Project + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project * @since 0.1.0 - * @license http://www.opensource.org/licenses/mit-license.php MIT License + * @license https://www.opensource.org/licenses/mit-license.php MIT License */ namespace Cake\Queue\Test\TestCase\Task; diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 66530a9..8545194 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -2,17 +2,17 @@ declare(strict_types=1); /** - * CakePHP(tm) : Rapid Development Framework (http://cakephp.org) - * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org) + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) * * Licensed under The MIT License * For full copyright and license information, please see the LICENSE.txt * Redistributions of files must retain the above copyright notice. * - * @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://cakephp.org CakePHP(tm) Project + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project * @since 0.1.0 - * @license http://www.opensource.org/licenses/mit-license.php MIT License + * @license https://www.opensource.org/licenses/mit-license.php MIT License */ use Cake\Cache\Cache; From 2a276bd313d5178c129a0ac15cbaebd8ce01ec0e Mon Sep 17 00:00:00 2001 From: Arhell Date: Fri, 24 Feb 2023 07:47:36 +0200 Subject: [PATCH 20/90] update readme link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bfb5e2a..8835527 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The plugin consists of a CakePHP shell wrapper and Queueing libraries for the [p ## Installation -You can install this plugin into your CakePHP application using [Composer](http://getcomposer.org). +You can install this plugin into your CakePHP application using [Composer](https://getcomposer.org). Run the following command ```sh From 7f460eeffd73291bf77236e90c7b15cea12a730d Mon Sep 17 00:00:00 2001 From: Alex Mayer Date: Sat, 4 Mar 2023 23:20:44 -0500 Subject: [PATCH 21/90] Set Exception as Property on Message Fixes #114 --- src/Consumption/LimitAttemptsExtension.php | 4 +--- src/Queue/Processor.php | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Consumption/LimitAttemptsExtension.php b/src/Consumption/LimitAttemptsExtension.php index c7d19f9..69ee8c4 100644 --- a/src/Consumption/LimitAttemptsExtension.php +++ b/src/Consumption/LimitAttemptsExtension.php @@ -61,13 +61,11 @@ public function onResult(MessageResult $context): void $attemptNumber = $message->getProperty(self::ATTEMPTS_PROPERTY, 0) + 1; if ($attemptNumber >= $maxAttempts) { - $originalResult = $context->getResult(); - $context->changeResult( Result::reject(sprintf('The maximum number of %d allowed attempts was reached.', $maxAttempts)) ); - $exception = $originalResult instanceof Result ? $originalResult->getReason() : null; + $exception = (string)$message->getProperty('jobException'); $this->dispatchEvent( 'Consumption.LimitAttemptsExtension.failed', diff --git a/src/Queue/Processor.php b/src/Queue/Processor.php index 9fdb602..3d57d2d 100644 --- a/src/Queue/Processor.php +++ b/src/Queue/Processor.php @@ -81,13 +81,15 @@ public function process(QueueMessage $message, Context $context) try { $response = $this->processMessage($jobMessage); } catch (Throwable $e) { + $message->setProperty('jobException', $e); + $this->logger->debug(sprintf('Message encountered exception: %s', $e->getMessage())); $this->dispatchEvent('Processor.message.exception', [ 'message' => $jobMessage, 'exception' => $e, ]); - return Result::requeue(sprintf('Exception occurred while processing message: %s', (string)$e)); + return Result::requeue('Exception occurred while processing message'); } if ($response === InteropProcessor::ACK) { From 38b75ec42190aaceec614c788613800f034d68f0 Mon Sep 17 00:00:00 2001 From: Alex Mayer Date: Sun, 5 Mar 2023 00:15:59 -0500 Subject: [PATCH 22/90] Fix "sucessfully" Typo --- src/Queue/Processor.php | 2 +- tests/TestCase/Queue/ProcessorTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Queue/Processor.php b/src/Queue/Processor.php index 9fdb602..face1b0 100644 --- a/src/Queue/Processor.php +++ b/src/Queue/Processor.php @@ -91,7 +91,7 @@ public function process(QueueMessage $message, Context $context) } if ($response === InteropProcessor::ACK) { - $this->logger->debug('Message processed sucessfully'); + $this->logger->debug('Message processed successfully'); $this->dispatchEvent('Processor.message.success', ['message' => $jobMessage]); return InteropProcessor::ACK; diff --git a/tests/TestCase/Queue/ProcessorTest.php b/tests/TestCase/Queue/ProcessorTest.php index 7f76af8..f64e6c2 100644 --- a/tests/TestCase/Queue/ProcessorTest.php +++ b/tests/TestCase/Queue/ProcessorTest.php @@ -39,8 +39,8 @@ class ProcessorTest extends TestCase public function dataProviderTestProcess(): array { return [ - 'ack' => ['processReturnAck', InteropProcessor::ACK, 'Message processed sucessfully', 'Processor.message.success'], - 'null' => ['processReturnNull', InteropProcessor::ACK, 'Message processed sucessfully', 'Processor.message.success'], + 'ack' => ['processReturnAck', InteropProcessor::ACK, 'Message processed successfully', 'Processor.message.success'], + 'null' => ['processReturnNull', InteropProcessor::ACK, 'Message processed successfully', 'Processor.message.success'], 'reject' => ['processReturnReject', InteropProcessor::REJECT, 'Message processed with rejection', 'Processor.message.reject'], 'requeue' => ['processReturnRequeue', InteropProcessor::REQUEUE, 'Message processed with failure, requeuing', 'Processor.message.failure'], 'string' => ['processReturnString', InteropProcessor::REQUEUE, 'Message processed with failure, requeuing', 'Processor.message.failure'], From 4dd3951035c01a6e3c0f8b72c001033e63d10506 Mon Sep 17 00:00:00 2001 From: Sheldon Reiff Date: Fri, 10 Mar 2023 17:43:41 -0500 Subject: [PATCH 23/90] Make max attempts optional in bake job command --- docs/en/index.rst | 2 +- src/Command/JobCommand.php | 7 +++++ templates/bake/job.twig | 4 ++- tests/TestCase/Task/JobTaskTest.php | 14 +++++++++ tests/comparisons/JobTask.php | 7 ----- tests/comparisons/JobTaskWithMaxAttempts.php | 32 ++++++++++++++++++++ tests/comparisons/JobTaskWithUnique.php | 7 ----- 7 files changed, 57 insertions(+), 16 deletions(-) create mode 100644 tests/comparisons/JobTaskWithMaxAttempts.php diff --git a/docs/en/index.rst b/docs/en/index.rst index 5580b0a..1552425 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -118,7 +118,7 @@ A simple job that logs received messages would look like:: use LogTrait; /** - * The maximum number of times the job may be attempted. + * The maximum number of times the job may be attempted. (optional property) * * @var int|null */ diff --git a/src/Command/JobCommand.php b/src/Command/JobCommand.php index d845ac2..406e6c5 100644 --- a/src/Command/JobCommand.php +++ b/src/Command/JobCommand.php @@ -55,8 +55,11 @@ public function templateData(Arguments $arguments): array { $parentData = parent::templateData($arguments); + $maxAttempts = $arguments->getOption('max-attempts'); + $data = [ 'isUnique' => $arguments->getOption('unique'), + 'maxAttempts' => $maxAttempts ? (int)$maxAttempts : null, ]; return array_merge($parentData, $data); @@ -77,6 +80,10 @@ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionPar ->addArgument('name', [ 'help' => 'The name of the queue job class to create.', ]) + ->addOption('max-attempts', [ + 'help' => 'The maximum number of times the job may be attempted.', + 'default' => null, + ]) ->addOption('unique', [ 'help' => 'Whether there should be only one instance of a job on the queue at a time.', 'boolean' => true, diff --git a/templates/bake/job.twig b/templates/bake/job.twig index 684fcfb..3a4705b 100644 --- a/templates/bake/job.twig +++ b/templates/bake/job.twig @@ -27,13 +27,15 @@ use Interop\Queue\Processor; */ class {{ name }}Job implements JobInterface { +{% if maxAttempts %} /** * The maximum number of times the job may be attempted. * * @var int|null */ - public static $maxAttempts = 3; + public static $maxAttempts = {{ maxAttempts }}; +{% endif %} {% if isUnique %} /** * Whether there should be only one instance of a job on the queue at a time. (optional property) diff --git a/tests/TestCase/Task/JobTaskTest.php b/tests/TestCase/Task/JobTaskTest.php index 8baa724..eea4708 100644 --- a/tests/TestCase/Task/JobTaskTest.php +++ b/tests/TestCase/Task/JobTaskTest.php @@ -86,4 +86,18 @@ public function testMainWithUnique() file_get_contents($this->generatedFile) ); } + + public function testMainWithMaxAttempts() + { + $this->generatedFile = APP . 'Job' . DS . 'UploadJob.php'; + + $this->exec('bake job upload --max-attempts 3'); + $this->assertExitCode(Command::CODE_SUCCESS); + $this->assertFileExists($this->generatedFile); + $this->assertOutputContains('Creating file ' . $this->generatedFile); + $this->assertSameAsFile( + $this->comparisonDir . 'JobTaskWithMaxAttempts.php', + file_get_contents($this->generatedFile) + ); + } } diff --git a/tests/comparisons/JobTask.php b/tests/comparisons/JobTask.php index 99c4f4e..822a677 100644 --- a/tests/comparisons/JobTask.php +++ b/tests/comparisons/JobTask.php @@ -12,13 +12,6 @@ */ class UploadJob implements JobInterface { - /** - * The maximum number of times the job may be attempted. - * - * @var int|null - */ - public static $maxAttempts = 3; - /** * Executes logic for UploadJob * diff --git a/tests/comparisons/JobTaskWithMaxAttempts.php b/tests/comparisons/JobTaskWithMaxAttempts.php new file mode 100644 index 0000000..99c4f4e --- /dev/null +++ b/tests/comparisons/JobTaskWithMaxAttempts.php @@ -0,0 +1,32 @@ + Date: Fri, 10 Mar 2023 17:13:22 -0500 Subject: [PATCH 24/90] Use unique names for queue unique cache configurations --- src/Command/WorkerCommand.php | 16 ++++-- src/QueueManager.php | 12 +++-- tests/TestCase/Command/WorkerCommandTest.php | 27 ++++++++++ ...emoveUniqueJobIdFromCacheExtensionTest.php | 15 +++--- tests/TestCase/QueueManagerTest.php | 49 ++++++++++++++++--- 5 files changed, 95 insertions(+), 24 deletions(-) diff --git a/src/Command/WorkerCommand.php b/src/Command/WorkerCommand.php index 18dbfa0..a32d896 100644 --- a/src/Command/WorkerCommand.php +++ b/src/Command/WorkerCommand.php @@ -130,10 +130,12 @@ protected function getQueueExtension(Arguments $args, LoggerInterface $logger): $limitAttempsExtension->getEventManager()->on(new FailedJobsListener()); + $configKey = (string)$args->getOption('config'); + $config = QueueManager::getConfig($configKey); + $extensions = [ new LoggerExtension($logger), $limitAttempsExtension, - new RemoveUniqueJobIdFromCacheExtension('Cake/Queue.queueUnique'), ]; if (!is_null($args->getOption('max-jobs'))) { @@ -146,6 +148,10 @@ protected function getQueueExtension(Arguments $args, LoggerInterface $logger): $extensions[] = new LimitConsumptionTimeExtension($endTime); } + if (isset($config['uniqueCacheKey'])) { + $extensions[] = new RemoveUniqueJobIdFromCacheExtension($config['uniqueCacheKey']); + } + return new ChainExtension($extensions); } @@ -172,16 +178,16 @@ protected function getLogger(Arguments $args): LoggerInterface */ public function execute(Arguments $args, ConsoleIo $io) { - $logger = $this->getLogger($args); - $processor = new Processor($logger, $this->container); - $extension = $this->getQueueExtension($args, $logger); - $config = (string)$args->getOption('config'); if (!Configure::check(sprintf('Queue.%s', $config))) { $io->error(sprintf('Configuration key "%s" was not found', $config)); $this->abort(); } + $logger = $this->getLogger($args); + $processor = new Processor($logger, $this->container); + $extension = $this->getQueueExtension($args, $logger); + $hasListener = Configure::check(sprintf('Queue.%s.listener', $config)); if ($hasListener) { $listenerClassName = Configure::read(sprintf('Queue.%s.listener', $config)); diff --git a/src/QueueManager.php b/src/QueueManager.php index 4c63b6e..c3a1dd5 100644 --- a/src/QueueManager.php +++ b/src/QueueManager.php @@ -89,6 +89,8 @@ public static function setConfig($key, $config = null): void } return; + } elseif (is_array($key)) { + throw new LogicException('If config is not null, key must be a string.'); } if (isset(static::$_config[$key])) { @@ -127,7 +129,9 @@ public static function setConfig($key, $config = null): void $cacheConfig = array_merge($cacheDefaults, $config['uniqueCache']); - Cache::setConfig('Cake/Queue.queueUnique', $cacheConfig); + $config['uniqueCacheKey'] = "Cake/Queue.queueUnique.{$key}"; + + Cache::setConfig($config['uniqueCacheKey'], $cacheConfig); } /** @psalm-suppress InvalidPropertyAssignmentValue */ @@ -228,7 +232,7 @@ public static function push($className, array $data = [], array $options = []): /** @psalm-suppress InvalidPropertyFetch */ if (!empty($class::$shouldBeUnique)) { - if (!Cache::getConfig('Cake/Queue.queueUnique')) { + if (empty($config['uniqueCache'])) { throw new InvalidArgumentException( "$class::\$shouldBeUnique is set to `true` but `uniqueCache` configuration is missing." ); @@ -236,7 +240,7 @@ public static function push($className, array $data = [], array $options = []): $uniqueId = static::getUniqueId($class, $method, $data); - if (Cache::read($uniqueId, 'Cake/Queue.queueUnique')) { + if (Cache::read($uniqueId, $config['uniqueCacheKey'])) { if ($logger) { $logger->debug( "An identical instance of $class already exists on the queue. This push will be ignored." @@ -279,7 +283,7 @@ public static function push($className, array $data = [], array $options = []): if (!empty($class::$shouldBeUnique)) { $uniqueId = static::getUniqueId($class, $method, $data); - Cache::add($uniqueId, true, 'Cake/Queue.queueUnique'); + Cache::add($uniqueId, true, $config['uniqueCacheKey']); } } diff --git a/tests/TestCase/Command/WorkerCommandTest.php b/tests/TestCase/Command/WorkerCommandTest.php index 3034bb6..e1de6de 100644 --- a/tests/TestCase/Command/WorkerCommandTest.php +++ b/tests/TestCase/Command/WorkerCommandTest.php @@ -299,4 +299,31 @@ public function testQueueProcessesJobWithDIService() $this->assertDebugLogContains('Debug job was run with service infotext'); } + + /** + * Test that queue will process when a unique cache is configured. + * + * @runInSeparateProcess + */ + public function testQueueProcessesWithUniqueCacheConfigured() + { + $config = [ + 'queue' => 'default', + 'url' => 'file:///' . TMP . DS . 'queue', + 'receiveTimeout' => 100, + 'uniqueCache' => [ + 'engine' => 'File', + ], + ]; + Configure::write('Queue', ['default' => $config]); + + Log::setConfig('debug', [ + 'className' => 'Array', + 'levels' => ['notice', 'info', 'debug'], + ]); + + $this->exec('queue worker --max-jobs=1 --logger=debug --verbose'); + + $this->assertDebugLogContains('Consumption has started'); + } } diff --git a/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php b/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php index 72e3b18..9d09e2a 100644 --- a/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php +++ b/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php @@ -26,9 +26,10 @@ public static function dropConfigs() QueueManager::drop('default'); - if (Cache::getConfig('Cake/Queue.queueUnique')) { - Cache::clear('Cake/Queue.queueUnique'); - Cache::drop('Cake/Queue.queueUnique'); + $cacheKey = QueueManager::getConfig('default')['uniqueCacheKey'] ?? null; + if ($cacheKey) { + Cache::clear($cacheKey); + Cache::drop($cacheKey); } } @@ -39,14 +40,14 @@ public function testJobIsRemovedFromCacheAfterProcessing() QueueManager::push(UniqueJob::class, []); $uniqueId = QueueManager::getUniqueId(UniqueJob::class, 'execute', []); - $this->assertTrue(Cache::read($uniqueId, 'Cake/Queue.queueUnique')); + $this->assertTrue(Cache::read($uniqueId, 'Cake/Queue.queueUnique.default')); $consume(); - $this->assertNull(Cache::read($uniqueId, 'Cake/Queue.queueUnique')); + $this->assertNull(Cache::read($uniqueId, 'Cake/Queue.queueUnique.default')); } - protected function setupQueue($extensionArgs = []) + protected function setupQueue() { Log::setConfig('debug', [ 'className' => 'Array', @@ -68,7 +69,7 @@ protected function setupQueue($extensionArgs = []) $extension = new ChainExtension([ new LimitConsumedMessagesExtension(1), - new RemoveUniqueJobIdFromCacheExtension('Cake/Queue.queueUnique'), + new RemoveUniqueJobIdFromCacheExtension('Cake/Queue.queueUnique.default'), ]); return function () use ($client, $extension) { diff --git a/tests/TestCase/QueueManagerTest.php b/tests/TestCase/QueueManagerTest.php index 0de0c27..b9f9874 100644 --- a/tests/TestCase/QueueManagerTest.php +++ b/tests/TestCase/QueueManagerTest.php @@ -44,35 +44,68 @@ public function tearDown(): void { parent::tearDown(); + $cacheKey = QueueManager::getConfig('test')['uniqueCacheKey'] ?? null; + if ($cacheKey) { + Cache::clear($cacheKey); + Cache::drop($cacheKey); + } + QueueManager::drop('test'); Log::drop('test'); // delete file based queues array_map('unlink', glob($this->fsQueuePath . DS . '*')); - - if (Cache::getConfig('Cake/Queue.queueUnique')) { - Cache::clear('Cake/Queue.queueUnique'); - Cache::drop('Cake/Queue.queueUnique'); - } } public function testSetConfig() { - $result = QueueManager::setConfig('test', [ + QueueManager::setConfig('test', [ 'url' => 'null:', ]); - $this->assertNull($result); $config = QueueManager::getConfig('test'); $this->assertSame('null:', $config['url']); } - public function testSetConfigInvalidValue() + public function testSetMultipleConfigs() + { + QueueManager::setConfig('test', [ + 'url' => 'null:', + 'uniqueCache' => [ + 'engine' => 'File', + ], + 'logger' => 'debug', + ]); + + QueueManager::setConfig('other', [ + 'url' => 'null:', + 'uniqueCache' => [ + 'engine' => 'File', + ], + 'logger' => 'debug', + ]); + + $testConfig = QueueManager::getConfig('test'); + $this->assertSame('null:', $testConfig['url']); + + $otherConfig = QueueManager::getConfig('other'); + $this->assertSame('null:', $otherConfig['url']); + + QueueManager::drop('other'); + } + + public function testSetConfigWithInvalidConfigValue() { $this->expectException(LogicException::class); QueueManager::setConfig('test', null); } + public function testSetConfigInvalidKeyValue() + { + $this->expectException(LogicException::class); + QueueManager::setConfig(['test' => []], 'default'); + } + public function testSetConfigNoUrl() { $this->expectException(BadMethodCallException::class); From 32320fc4624139041529e476715eb36badf47c99 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Sat, 18 Mar 2023 15:36:24 -0400 Subject: [PATCH 25/90] Update base image --- docs.Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs.Dockerfile b/docs.Dockerfile index 97d2bbb..a112f30 100644 --- a/docs.Dockerfile +++ b/docs.Dockerfile @@ -1,5 +1,5 @@ # Generate the HTML output. -FROM markstory/cakephp-docs-builder as builder +FROM ghcr.io/cakephp/docs-builder as builder COPY docs /data/docs @@ -8,7 +8,7 @@ RUN cd /data/docs-builder && \ make website LANGS="en" SOURCE=/data/docs DEST=/data/website/ # Build a small nginx container with just the static site in it. -FROM markstory/cakephp-docs-builder:runtime as runtime +FROM ghcr.io/cakephp/docs-builder:runtime as runtime # Configure search index script ENV LANGS="en" From fa054c677aafcf1c75db7f10315955553e1d9de1 Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Tue, 25 Apr 2023 19:43:51 +0200 Subject: [PATCH 26/90] update CI config --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73a69c3..b50ab2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ on: jobs: testsuite: - runs-on: ubuntu-18.04 + runs-on: ubuntu-22.04 strategy: fail-fast: false matrix: @@ -68,7 +68,7 @@ jobs: cs-stan: name: Coding Standard & Static Analysis - runs-on: ubuntu-18.04 + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v3 From 3945fc9eef55c0dc35e0acaad5733b0f058aa332 Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Fri, 5 May 2023 14:14:56 +0200 Subject: [PATCH 27/90] fix tests --- src/Model/Table/FailedJobsTable.php | 2 +- tests/bootstrap.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Model/Table/FailedJobsTable.php b/src/Model/Table/FailedJobsTable.php index 4358a0d..fb62f16 100644 --- a/src/Model/Table/FailedJobsTable.php +++ b/src/Model/Table/FailedJobsTable.php @@ -12,7 +12,7 @@ * @method \Cake\Queue\Model\Entity\FailedJob newEmptyEntity() * @method \Cake\Queue\Model\Entity\FailedJob newEntity(array $data, array $options = []) * @method \Cake\Queue\Model\Entity\FailedJob[] newEntities(array $data, array $options = []) - * @method \Cake\Queue\Model\Entity\FailedJob get($primaryKey, $options = []) + * @method \Cake\Queue\Model\Entity\FailedJob get(mixed $primaryKey, array|string $finder = 'all', \Psr\SimpleCache\CacheInterface|string|null $cache = null, \Closure|string|null $cacheKey = null, ...$args) * @method \Cake\Queue\Model\Entity\FailedJob findOrCreate($search, ?callable $callback = null, $options = []) * @method \Cake\Queue\Model\Entity\FailedJob patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = []) * @method \Cake\Queue\Model\Entity\FailedJob[] patchEntities(iterable $entities, array $data, array $options = []) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 2435ee2..6bdf7f7 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -19,6 +19,7 @@ use Cake\Core\Configure; use Cake\Routing\Router; use Cake\TestSuite\Fixture\SchemaLoader; +use function Cake\Core\env; $findRoot = function ($root) { do { @@ -34,7 +35,6 @@ unset($findRoot); chdir($root); -require_once 'vendor/cakephp/cakephp/src/basics.php'; require_once 'vendor/autoload.php'; define('CORE_PATH', $root . DS . 'vendor' . DS . 'cakephp' . DS . 'cakephp' . DS); From 84af5a1bc77e392c32ece0f12bbca155e4ee76b5 Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Sat, 20 May 2023 21:32:56 +0200 Subject: [PATCH 28/90] adjust CI to not ignore-platform-reqs --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b50ab2f..b7f490f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,8 +48,6 @@ jobs: run: | if ${{ matrix.prefer-lowest == 'prefer-lowest' }}; then composer update --prefer-lowest --prefer-stable - elif ${{ matrix.php-version == '8.1' }}; then - composer update --ignore-platform-reqs else composer update fi From 83f694584526a98ff0f0c9bbec802f7c2edfa41b Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Sat, 20 May 2023 21:39:09 +0200 Subject: [PATCH 29/90] add PHP 8.2 to CI config --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7f490f..98f55bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['7.2', '7.4', '8.0', '8.1'] + php-version: ['7.2', '7.4', '8.0', '8.1', '8.2'] prefer-lowest: [''] include: - php-version: '7.2' From 9f0d9ed9e4bcb9527c62de9bd9f9a2f69cbef86c Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Wed, 31 May 2023 18:36:01 +0200 Subject: [PATCH 30/90] upgrade to PHPUnit 10 --- .gitignore | 2 +- composer.json | 2 +- phpunit.xml.dist | 16 ++-- tests/TestCase/Command/WorkerCommandTest.php | 6 +- .../Listener/FailedJobsListenerTest.php | 2 +- tests/TestCase/Queue/ProcessorTest.php | 89 ++---------------- tests/bootstrap.php | 3 +- tests/test_app/src/TestProcessor.php | 90 +++++++++++++++++++ 8 files changed, 112 insertions(+), 98 deletions(-) create mode 100644 tests/test_app/src/TestProcessor.php diff --git a/.gitignore b/.gitignore index deffac9..8c998b3 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ error.log /tests/test_app/config/Queue/* /tests/test_app/config/TestsQueue/schema-dump-test.lock /tests/test_app/Plugin/TestBlog/config/Queue/* -.phpunit.result.cache +.phpunit.cache # IDE and editor specific files # ################################# diff --git a/composer.json b/composer.json index 7bb9db0..dba672e 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "cakephp/bake": "3.x-dev as 3.0.0", "cakephp/cakephp-codesniffer": "^5.0", "enqueue/fs": "^0.10", - "phpunit/phpunit": "^9.5.19" + "phpunit/phpunit": "^10.1.0" }, "autoload": { "psr-4": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 6ec7295..fa7442a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,9 +1,9 @@ - + tests/TestCase @@ -11,15 +11,15 @@ - + - + ./src/ - + diff --git a/tests/TestCase/Command/WorkerCommandTest.php b/tests/TestCase/Command/WorkerCommandTest.php index 0a42214..0b9d752 100644 --- a/tests/TestCase/Command/WorkerCommandTest.php +++ b/tests/TestCase/Command/WorkerCommandTest.php @@ -60,7 +60,7 @@ public function testQueueProcessesStart() ], ]); $this->exec('queue worker --max-runtime=0'); - $this->assertEmpty($this->getActualOutput()); + $this->assertEmpty($this->output()); } /** @@ -78,7 +78,7 @@ public function testQueueProcessesWithListener() ], ]); $this->exec('queue worker --max-runtime=0'); - $this->assertEmpty($this->getActualOutput()); + $this->assertEmpty($this->output()); } /** @@ -146,7 +146,7 @@ public function testQueueProcessesWithLogger() * * @return array */ - public function dataProviderCallableTypes(): array + public static function dataProviderCallableTypes(): array { return [ 'Job Class' => [LogToDebugJob::class], diff --git a/tests/TestCase/Listener/FailedJobsListenerTest.php b/tests/TestCase/Listener/FailedJobsListenerTest.php index 89193bc..b126260 100644 --- a/tests/TestCase/Listener/FailedJobsListenerTest.php +++ b/tests/TestCase/Listener/FailedJobsListenerTest.php @@ -104,7 +104,7 @@ public function testFailedJobIsAddedWhenEventIsFired() * * @return array[] */ - public function storeFailedJobExceptionDataProvider() + public static function storeFailedJobExceptionDataProvider() { return [ [['exception' => 'some message'], '`logger` was not defined on Consumption.LimitAttemptsExtension.failed event'], diff --git a/tests/TestCase/Queue/ProcessorTest.php b/tests/TestCase/Queue/ProcessorTest.php index 7f76af8..4a09e5e 100644 --- a/tests/TestCase/Queue/ProcessorTest.php +++ b/tests/TestCase/Queue/ProcessorTest.php @@ -24,8 +24,8 @@ use Cake\TestSuite\TestCase; use Enqueue\Null\NullConnectionFactory; use Enqueue\Null\NullMessage; -use Exception; use Interop\Queue\Processor as InteropProcessor; +use TestApp\TestProcessor; class ProcessorTest extends TestCase { @@ -36,7 +36,7 @@ class ProcessorTest extends TestCase * * @return array */ - public function dataProviderTestProcess(): array + public static function dataProviderTestProcess(): array { return [ 'ack' => ['processReturnAck', InteropProcessor::ACK, 'Message processed sucessfully', 'Processor.message.success'], @@ -60,7 +60,7 @@ public function dataProviderTestProcess(): array public function testProcess($jobMethod, $expected, $logMessage, $dispatchedEvent) { $messageBody = [ - 'class' => [static::class, $jobMethod], + 'class' => [TestProcessor::class, $jobMethod], 'args' => [], ]; $connectionFactory = new NullConnectionFactory(); @@ -140,7 +140,7 @@ public function testProcessWillRequeueOnException() { $method = 'processAndThrowException'; $messageBody = [ - 'class' => [static::class, $method], + 'class' => [TestProcessor::class, $method], 'data' => ['sample_data' => 'a value', 'key' => md5($method)], ]; $connectionFactory = new NullConnectionFactory(); @@ -195,7 +195,7 @@ public function testProcessJobObject() public function testProcessMessage() { $messageBody = [ - 'class' => [static::class, 'processReturnAck'], + 'class' => [TestProcessor::class, 'processReturnAck'], 'args' => [], ]; $connectionFactory = new NullConnectionFactory(); @@ -206,83 +206,6 @@ public function testProcessMessage() $result = $processor->processMessage($message); $this->assertSame(InteropProcessor::ACK, $result); - $this->assertNotEmpty(static::$lastProcessMessage); - } - - /** - * Job to be used in test testProcessMessageCallableIsString - * - * @param \Cake\Queue\Job\Message $message The message to process - * @return null - */ - public static function processReturnNull(Message $message) - { - static::$lastProcessMessage = $message; - - return null; - } - - /** - * Job to be used in test testProcessMessageCallableIsString - * - * @param Message $message The message to process - * @return null - * @throws Exception - */ - public static function processAndThrowException(Message $message) - { - throw new Exception('Something went wrong'); - } - - /** - * Job to be used in test testProcessMessageCallableIsString - * - * @param \Cake\Queue\Message $message The message to process - * @return null - */ - public static function processReturnReject(Message $message) - { - static::$lastProcessMessage = $message; - - return InteropProcessor::REJECT; - } - - /** - * Job to be used in test testProcessMessageCallableIsString - * - * @param \Cake\Queue\Message $message The message to process - * @return null - */ - public static function processReturnAck(Message $message) - { - static::$lastProcessMessage = $message; - - return InteropProcessor::ACK; - } - - /** - * Job to be used in test testProcessMessageCallableIsString - * - * @param \Cake\Queue\Message $message The message to process - * @return null - */ - public static function processReturnRequeue(Message $message) - { - static::$lastProcessMessage = $message; - - return InteropProcessor::REQUEUE; - } - - /** - * Job to be used in test testProcessMessageCallableIsString - * - * @param \Cake\Queue\Message $message The message to process - * @return null - */ - public static function processReturnString(Message $message) - { - static::$lastProcessMessage = $message; - - return 'invalid value'; + $this->assertNotEmpty(TestProcessor::$lastProcessMessage); } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 6bdf7f7..3cfd1b2 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -17,6 +17,7 @@ use Cake\Cache\Cache; use Cake\Core\Configure; +use Cake\Datasource\ConnectionManager; use Cake\Routing\Router; use Cake\TestSuite\Fixture\SchemaLoader; use function Cake\Core\env; @@ -94,7 +95,7 @@ putenv('db_dsn=sqlite:///:memory:'); } -Cake\Datasource\ConnectionManager::setConfig('test', [ +ConnectionManager::setConfig('test', [ 'url' => getenv('db_dsn'), 'timezone' => 'UTC', ]); diff --git a/tests/test_app/src/TestProcessor.php b/tests/test_app/src/TestProcessor.php new file mode 100644 index 0000000..d2cc69a --- /dev/null +++ b/tests/test_app/src/TestProcessor.php @@ -0,0 +1,90 @@ + Date: Sun, 2 Jul 2023 14:43:39 +0200 Subject: [PATCH 31/90] update stan --- .phive/phars.xml | 4 ++-- psalm.xml | 3 +++ src/Job/Message.php | 1 - 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.phive/phars.xml b/.phive/phars.xml index b3c9592..faba153 100644 --- a/.phive/phars.xml +++ b/.phive/phars.xml @@ -1,5 +1,5 @@ - - + + diff --git a/psalm.xml b/psalm.xml index 5b6b969..b231aa7 100644 --- a/psalm.xml +++ b/psalm.xml @@ -8,6 +8,9 @@ autoloader="tests/bootstrap.php" usePhpDocMethodsWithoutMagicCall="true" errorBaseline="psalm-baseline.xml" + findUnusedPsalmSuppress="true" + findUnusedBaselineEntry="true" + findUnusedCode="false" > diff --git a/src/Job/Message.php b/src/Job/Message.php index 4f3d8a4..e4c7a7e 100644 --- a/src/Job/Message.php +++ b/src/Job/Message.php @@ -158,7 +158,6 @@ public function getMaxAttempts(): ?int /** * @return string - * @psalm-suppress InvalidToString */ public function __toString(): string { From 54c0fc418d084ee4f0da926dba1ae4dbe874c014 Mon Sep 17 00:00:00 2001 From: Alejandro Ibarra Date: Fri, 14 Jul 2023 10:33:40 +0200 Subject: [PATCH 32/90] Add default processor if argument is not passed to avoid processor hash exception --- src/Command/WorkerCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Command/WorkerCommand.php b/src/Command/WorkerCommand.php index a32d896..3efcc27 100644 --- a/src/Command/WorkerCommand.php +++ b/src/Command/WorkerCommand.php @@ -204,7 +204,7 @@ public function execute(Arguments $args, ConsoleIo $io) $queue = $args->getOption('queue') ? (string)$args->getOption('queue') : Configure::read("Queue.{$config}.queue", 'default'); - $processorName = $args->getOption('processor') ? (string)$args->getOption('processor') : null; + $processorName = $args->getOption('processor') ? (string)$args->getOption('processor') : 'default'; $client->bindTopic($queue, $processor, $processorName); $client->consume($extension); From 7ec8c4b26608ee5af19d7cfe74b0563146ee61c4 Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Sat, 29 Jul 2023 22:15:12 +0200 Subject: [PATCH 33/90] fix stan --- src/QueueManager.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/QueueManager.php b/src/QueueManager.php index 97e4710..664ec31 100644 --- a/src/QueueManager.php +++ b/src/QueueManager.php @@ -94,7 +94,6 @@ public static function setConfig(string|array $key, ?array $config = null): void } if (isset(static::$_config[$key])) { - /** @psalm-suppress PossiblyInvalidArgument */ throw new BadMethodCallException(sprintf('Cannot reconfigure existing key `%s`', $key)); } @@ -134,7 +133,6 @@ public static function setConfig(string|array $key, ?array $config = null): void Cache::setConfig($config['uniqueCacheKey'], $cacheConfig); } - /** @psalm-suppress InvalidPropertyAssignmentValue */ static::$_config[$key] = $config; } From da2a8d71e44043173491c71714163d02350b1770 Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Sat, 29 Jul 2023 22:17:22 +0200 Subject: [PATCH 34/90] fix tests --- tests/TestCase/QueueManagerTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/TestCase/QueueManagerTest.php b/tests/TestCase/QueueManagerTest.php index b9f9874..ba3bdc6 100644 --- a/tests/TestCase/QueueManagerTest.php +++ b/tests/TestCase/QueueManagerTest.php @@ -25,6 +25,7 @@ use LogicException; use TestApp\Job\LogToDebugJob; use TestApp\Job\UniqueJob; +use TypeError; /** * QueueManager test @@ -102,7 +103,7 @@ public function testSetConfigWithInvalidConfigValue() public function testSetConfigInvalidKeyValue() { - $this->expectException(LogicException::class); + $this->expectException(TypeError::class); QueueManager::setConfig(['test' => []], 'default'); } From 85071f014f677acbf39caddbd9558a7a112bcfae Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Sat, 12 Aug 2023 18:25:24 +0200 Subject: [PATCH 35/90] allow manually dispatching ci workflow --- .github/workflows/ci.yml | 1 + .github/workflows/deploy_docs_1x.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98f55bc..db7fbc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: pull_request: branches: - '*' + workflow_dispatch: jobs: testsuite: diff --git a/.github/workflows/deploy_docs_1x.yml b/.github/workflows/deploy_docs_1x.yml index 097bf45..bf8e586 100644 --- a/.github/workflows/deploy_docs_1x.yml +++ b/.github/workflows/deploy_docs_1x.yml @@ -5,6 +5,7 @@ on: push: branches: - master + workflow_dispatch: jobs: deploy: From b0fc8e533e6e96941e12f7b877bfe1e7631a1c9b Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Sat, 9 Sep 2023 19:30:04 +0200 Subject: [PATCH 36/90] change composer.json to cake5 stable version --- composer.json | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index dba672e..874a6f7 100644 --- a/composer.json +++ b/composer.json @@ -19,12 +19,12 @@ }, "require": { "php": ">=8.1", - "cakephp/cakephp": "5.x-dev as 5.0.0", + "cakephp/cakephp": "^5.0.0", "enqueue/simple-client": "^0.10", "psr/log": "^3.0" }, "require-dev": { - "cakephp/bake": "3.x-dev as 3.0.0", + "cakephp/bake": "^3.0.0", "cakephp/cakephp-codesniffer": "^5.0", "enqueue/fs": "^0.10", "phpunit/phpunit": "^10.1.0" @@ -64,8 +64,6 @@ "test": "phpunit", "test-coverage": "phpunit --coverage-clover=clover.xml" }, - "minimum-stability": "dev", - "prefer-stable": true, "config": { "sort-packages": true, "allow-plugins": { From 7e0941533a7dca946d8069be2765dda762bf4af7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Sep 2023 02:19:42 +0000 Subject: [PATCH 37/90] Bump actions/checkout from 3 to 4 Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- .github/workflows/deploy_docs_1x.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db7fbc7..cbad1f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: prefer-lowest: 'prefer-lowest' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -70,7 +70,7 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/deploy_docs_1x.yml b/.github/workflows/deploy_docs_1x.yml index bf8e586..5299440 100644 --- a/.github/workflows/deploy_docs_1x.yml +++ b/.github/workflows/deploy_docs_1x.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Cloning repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 From 32025b64fcf83c8382d521d30e6aabe5615b26e9 Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Sat, 16 Sep 2023 20:11:11 +0200 Subject: [PATCH 38/90] adjustments to make 2.x docs deploy work --- .github/workflows/deploy_docs_2x.yml | 23 +++++++++++++++++++++++ Dockerfile | 4 ++-- README.md | 2 +- docs.Dockerfile | 2 +- docs/config/all.py | 9 +++++---- 5 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/deploy_docs_2x.yml diff --git a/.github/workflows/deploy_docs_2x.yml b/.github/workflows/deploy_docs_2x.yml new file mode 100644 index 0000000..0be7621 --- /dev/null +++ b/.github/workflows/deploy_docs_2x.yml @@ -0,0 +1,23 @@ +--- +name: 'deploy_docs_2x' + +on: + push: + branches: + - cake5 + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Cloning repo + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Push to dokku + uses: dokku/github-action@master + with: + git_remote_url: 'ssh://dokku@apps.cakephp.org:22/queue-docs-2' + ssh_private_key: ${{ secrets.DOKKU_SSH_PRIVATE_KEY }} diff --git a/Dockerfile b/Dockerfile index 478f14c..70ceb41 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # Basic docker based environment # Necessary to trick dokku into building the documentation # using dockerfile instead of herokuish -FROM ubuntu:17.04 +FROM ubuntu:22.04 # Add basic tools RUN apt-get update && \ @@ -14,7 +14,7 @@ RUN apt-get update && \ libssl-dev && \ LC_ALL=C.UTF-8 add-apt-repository ppa:ondrej/php && \ apt-get update && \ - apt-get install -y php7.2-cli php7.2-mbstring php7.2-xml php7.2-zip php7.2-intl php7.2-opcache php7.2-sqlite &&\ + apt-get install -y php8.1-cli php8.1-mbstring php8.1-xml php8.1-zip php8.1-intl php8.1-opcache php8.1-sqlite &&\ apt-get clean &&\ rm -rf /var/lib/apt/lists/* diff --git a/README.md b/README.md index 8835527..0895556 100644 --- a/README.md +++ b/README.md @@ -45,4 +45,4 @@ Additionally, you will need to configure the ``default`` queue configuration in ## Documentation -Full documentation of the plugin can be found on the [CakePHP Cookbook](https://book.cakephp.org/queue/1/). +Full documentation of the plugin can be found on the [CakePHP Cookbook](https://book.cakephp.org/queue/2/). diff --git a/docs.Dockerfile b/docs.Dockerfile index a112f30..14a8e7f 100644 --- a/docs.Dockerfile +++ b/docs.Dockerfile @@ -13,7 +13,7 @@ FROM ghcr.io/cakephp/docs-builder:runtime as runtime # Configure search index script ENV LANGS="en" ENV SEARCH_SOURCE="/usr/share/nginx/html" -ENV SEARCH_URL_PREFIX="/queue/1" +ENV SEARCH_URL_PREFIX="/queue/2" COPY --from=builder /data/docs /data/docs COPY --from=builder /data/website /data/website diff --git a/docs/config/all.py b/docs/config/all.py index f04051a..7588559 100644 --- a/docs/config/all.py +++ b/docs/config/all.py @@ -10,10 +10,10 @@ # # The full version, including alpha/beta/rc tags. -release = '1.x' +release = '2.x' # The search index version. -search_version = 'queue-1' +search_version = 'queue-2' # The marketing display name for the book. version_name = '' @@ -23,7 +23,8 @@ # Other versions that display in the version picker menu. version_list = [ - {'name': '1.x', 'number': '/queue/1/', 'title': '1.x', 'current': True}, + {'name': '1.x', 'number': '/queue/1/', 'title': '1.x'}, + {'name': '2.x', 'number': '/queue/2/', 'title': '2.x', 'current': True}, ] # Languages available. @@ -34,7 +35,7 @@ branch = 'master' # Current version being built -version = '1.x' +version = '2.x' show_root_link = True From 74b1a890d9885c42d6066fce89ffe4edac6309b4 Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Sat, 16 Sep 2023 21:00:07 +0200 Subject: [PATCH 39/90] adjust deploy action to 2.x branch name --- .github/workflows/deploy_docs_2x.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy_docs_2x.yml b/.github/workflows/deploy_docs_2x.yml index 0be7621..e775085 100644 --- a/.github/workflows/deploy_docs_2x.yml +++ b/.github/workflows/deploy_docs_2x.yml @@ -4,7 +4,7 @@ name: 'deploy_docs_2x' on: push: branches: - - cake5 + - 2.x workflow_dispatch: jobs: From c073121a4c9e0c1db3f5e75bcf5ca24dafde07b3 Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Sat, 16 Sep 2023 21:05:04 +0200 Subject: [PATCH 40/90] adjust github actions to 1.x branch name --- .github/workflows/ci.yml | 2 +- .github/workflows/deploy_docs_1x.yml | 2 +- docs/config/all.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbad1f4..b77b9b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: push: branches: - - master + - 1.x pull_request: branches: - '*' diff --git a/.github/workflows/deploy_docs_1x.yml b/.github/workflows/deploy_docs_1x.yml index 5299440..ef1b849 100644 --- a/.github/workflows/deploy_docs_1x.yml +++ b/.github/workflows/deploy_docs_1x.yml @@ -4,7 +4,7 @@ name: 'deploy_docs_1x' on: push: branches: - - master + - 1.x workflow_dispatch: jobs: diff --git a/docs/config/all.py b/docs/config/all.py index f04051a..b39513d 100644 --- a/docs/config/all.py +++ b/docs/config/all.py @@ -31,7 +31,7 @@ # The GitHub branch name for this version of the docs # for edit links to point at. -branch = 'master' +branch = '1.x' # Current version being built version = '1.x' From c8750a4eb42fe6236f415931249690613545d2c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Pustu=C5=82ka?= Date: Mon, 9 Oct 2023 09:16:30 +0200 Subject: [PATCH 41/90] Fix transport config to match the docs --- src/Mailer/Transport/QueueTransport.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Mailer/Transport/QueueTransport.php b/src/Mailer/Transport/QueueTransport.php index 2c1cc34..fff7b89 100644 --- a/src/Mailer/Transport/QueueTransport.php +++ b/src/Mailer/Transport/QueueTransport.php @@ -85,7 +85,7 @@ protected function prepareData(Message $message): array { return [ 'transport' => $this->getConfig('transport'), - 'config' => $this->getConfig(), + 'config' => is_array($this->getConfig('config')) ? $this->getConfig('config') : $this->getConfig(), 'emailMessage' => json_encode($message), ]; } From f5a3a5bed54286e86d45b6e19e9b31755bcd3f8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Pustu=C5=82ka?= Date: Mon, 9 Oct 2023 09:29:42 +0200 Subject: [PATCH 42/90] Test that the default transport is set. --- .../Mailer/Transport/QueueTransportTest.php | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/tests/TestCase/Mailer/Transport/QueueTransportTest.php b/tests/TestCase/Mailer/Transport/QueueTransportTest.php index 45fe4ef..258c27d 100644 --- a/tests/TestCase/Mailer/Transport/QueueTransportTest.php +++ b/tests/TestCase/Mailer/Transport/QueueTransportTest.php @@ -22,6 +22,18 @@ class QueueTransportTest extends TestCase { + private $fsQueuePath = TMP . DS . 'queue'; + + private function getFsQueueUrl(): string + { + return 'file:///' . $this->fsQueuePath; + } + + private function getFsQueueFile(): string + { + return $this->getFsQueueUrl() . DS . 'enqueue.app.default'; + } + /** * Test send * @@ -31,7 +43,7 @@ public function testSend() { QueueManager::setConfig('default', [ 'queue' => 'default', - 'url' => 'null:', + 'url' => $this->getFsQueueUrl(), ]); $message = (new \Cake\Mailer\Message()) ->setFrom('from@example.com') @@ -58,6 +70,23 @@ public function testSend() $expected = ['headers' => $headers, 'message' => 'Message has been enqueued']; $this->assertEquals($expected, $result); + + $fsQueueFile = $this->getFsQueueFile(); + $this->assertFileExists($fsQueueFile); + + $content = file_get_contents($fsQueueFile); + $this->assertStringContainsString('MailTransport', $content); + QueueManager::drop('default'); } + + protected function tearDown(): void + { + parent::tearDown(); + + $fsQueueFile = $this->getFsQueueFile(); + if (file_exists($fsQueueFile)) { + unlink($fsQueueFile); + } + } } From 973bcf13db6cce771f0699e47a0879e1186a1e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Pustu=C5=82ka?= Date: Mon, 9 Oct 2023 09:37:58 +0200 Subject: [PATCH 43/90] Test that the transport config is passed correctly --- .../Mailer/Transport/QueueTransportTest.php | 64 ++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/tests/TestCase/Mailer/Transport/QueueTransportTest.php b/tests/TestCase/Mailer/Transport/QueueTransportTest.php index 258c27d..576a60a 100644 --- a/tests/TestCase/Mailer/Transport/QueueTransportTest.php +++ b/tests/TestCase/Mailer/Transport/QueueTransportTest.php @@ -16,6 +16,8 @@ */ namespace Cake\Queue\Test\TestCase\Job; +use Cake\Mailer\Message; +use Cake\Mailer\Transport\SmtpTransport; use Cake\Queue\Mailer\Transport\QueueTransport; use Cake\Queue\QueueManager; use Cake\TestSuite\TestCase; @@ -45,7 +47,7 @@ public function testSend() 'queue' => 'default', 'url' => $this->getFsQueueUrl(), ]); - $message = (new \Cake\Mailer\Message()) + $message = (new Message()) ->setFrom('from@example.com') ->setTo('to@example.com') ->setSubject('Sample Subject'); @@ -80,6 +82,66 @@ public function testSend() QueueManager::drop('default'); } + /** + * Test send custom transport + * + * @return void + */ + public function testSendCustomTransport() + { + QueueManager::setConfig('default', [ + 'queue' => 'default', + 'url' => $this->getFsQueueUrl(), + ]); + $message = (new Message()); + + $transport = new QueueTransport([ + 'transport' => SmtpTransport::class, + 'config' => [ + 'host' => 'mail.example.com', + ], + ]); + $transport->send($message); + + $fsQueueFile = $this->getFsQueueFile(); + $this->assertFileExists($fsQueueFile); + + $content = file_get_contents($fsQueueFile); + $this->assertStringContainsString('SmtpTransport', $content); + $this->assertStringContainsString('"config\":{\"host\":\"mail.example.com\"}', $content); + + QueueManager::drop('default'); + } + + /** + * Test send backwards compatibility transport config + * + * @return void + */ + public function testSendBcTransport() + { + QueueManager::setConfig('default', [ + 'queue' => 'default', + 'url' => $this->getFsQueueUrl(), + ]); + $message = (new Message()); + + $transport = new QueueTransport([ + 'transport' => SmtpTransport::class, + 'host' => 'mail.example.com', + ]); + $transport->send($message); + + $fsQueueFile = $this->getFsQueueFile(); + $this->assertFileExists($fsQueueFile); + + $content = file_get_contents($fsQueueFile); + $this->assertStringContainsString('SmtpTransport', $content); + $this->assertStringContainsString('"host\":\"mail.example.com\"', $content); + + QueueManager::drop('default'); + } + protected function tearDown(): void { parent::tearDown(); From 2658c6e4741eb8227bb7821c622e6d5a7186f01e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Pustu=C5=82ka?= Date: Mon, 9 Oct 2023 09:52:41 +0200 Subject: [PATCH 44/90] Add support for passing transport name --- src/Job/SendMailJob.php | 8 ++++++-- tests/TestCase/Job/SendMailJobTest.php | 27 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/Job/SendMailJob.php b/src/Job/SendMailJob.php index 785fba7..da0b529 100644 --- a/src/Job/SendMailJob.php +++ b/src/Job/SendMailJob.php @@ -18,6 +18,7 @@ use Cake\Log\Log; use Cake\Mailer\AbstractTransport; +use Cake\Mailer\TransportFactory; use Cake\Queue\Queue\Processor; /** @@ -65,12 +66,15 @@ public function execute(Message $message): ?string */ protected function getTransport(string $transportClassName, array $config): AbstractTransport { + if ($transportClassName === '') { + throw new \InvalidArgumentException('Transport class name is empty.'); + } + if ( - empty($transportClassName) || !class_exists($transportClassName) || !method_exists($transportClassName, 'send') ) { - throw new \InvalidArgumentException(sprintf('Transport class name is not valid: %s', $transportClassName)); + return TransportFactory::get($transportClassName); } $transport = new $transportClassName($config); diff --git a/tests/TestCase/Job/SendMailJobTest.php b/tests/TestCase/Job/SendMailJobTest.php index 7ca551c..4f02396 100644 --- a/tests/TestCase/Job/SendMailJobTest.php +++ b/tests/TestCase/Job/SendMailJobTest.php @@ -18,6 +18,7 @@ use Cake\Mailer\Mailer; use Cake\Mailer\Transport\DebugTransport; +use Cake\Mailer\TransportFactory; use Cake\Queue\Job\Message; use Cake\Queue\Job\SendMailJob; use Cake\Queue\Queue\Processor; @@ -78,6 +79,32 @@ public function testExecute() $this->assertSame(Processor::ACK, $actual); } + /** + * Test execute method with transport name + * + * @return void + */ + public function testExecuteTransportName() + { + $job = new SendMailJob(); + $message = $this->createMessage('foo', [], $this->message); + $emailMessage = new \Cake\Mailer\Message(); + $data = json_decode($message->getArgument('emailMessage'), true); + $emailMessage->createFromArray($data); + + $transport = $this->getMockBuilder(DebugTransport::class)->getMock(); + $transport->expects($this->once()) + ->method('send') + ->with($emailMessage) + ->willReturn(['message' => 'test', 'headers' => []]); + TransportFactory::getRegistry()->set('foo', $transport); + + $actual = $job->execute($message); + $this->assertSame(Processor::ACK, $actual); + + TransportFactory::drop('foo'); + } + /** * Test execute with attachments method * From 83f83fe7a4c49f38be80d56fac706df09e646e60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Pustu=C5=82ka?= Date: Mon, 9 Oct 2023 10:01:33 +0200 Subject: [PATCH 45/90] Fix access --- tests/TestCase/Mailer/Transport/QueueTransportTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/TestCase/Mailer/Transport/QueueTransportTest.php b/tests/TestCase/Mailer/Transport/QueueTransportTest.php index 576a60a..6539e0b 100644 --- a/tests/TestCase/Mailer/Transport/QueueTransportTest.php +++ b/tests/TestCase/Mailer/Transport/QueueTransportTest.php @@ -142,7 +142,7 @@ public function testSendBcTransport() QueueManager::drop('default'); } - protected function tearDown(): void + public function tearDown(): void { parent::tearDown(); From a9a31aa77b3fdee2f49ff83ff96058c946041d77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Pustu=C5=82ka?= Date: Tue, 10 Oct 2023 10:51:37 +0200 Subject: [PATCH 46/90] Update index.rst Update documentation with new transport config example. --- docs/en/index.rst | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/en/index.rst b/docs/en/index.rst index 1552425..d47b7fe 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -299,14 +299,15 @@ queue jobs, you can use the ``QueueTransport``. In your application's return [ // ... other configuration - 'EmailTransport' => [ + 'EmailTransport' => [ + 'default' => [ + 'className' => MailTransport::class, + // Configuration for MailTransport. + ] 'queue' => [ 'className' => QueueTransport::class, - // The transport to use inside the queue job. - 'transport' => MailTransport::class, - 'config' => [ - // Configuration for MailTransport. - ] + // The transport name to use inside the queue job. + 'transport' => 'default', ] ], 'Email' => [ @@ -320,7 +321,7 @@ queue jobs, you can use the ``QueueTransport``. In your application's With this configuration in place, any time you send an email with the ``default`` email profile CakePHP will generate a queue message. Once that queue message is -processed the ``MailTransport`` will be used to deliver the email messages. +processed the default ``MailTransport`` will be used to deliver the email messages. Run the worker ============== From c5b5b18dc535e9a71cd53aa912e2a1e278d9d29a Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Mon, 23 Oct 2023 19:20:38 +0200 Subject: [PATCH 47/90] Normalize path --- phpunit.xml.dist | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 3481e82..d02727a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -7,12 +7,12 @@ - ./src/ + src/ - tests/TestCase + tests/TestCase/ From edb610520a373502a0221ee033ca19d11bd1706d Mon Sep 17 00:00:00 2001 From: mscherer Date: Mon, 23 Oct 2023 22:46:14 +0200 Subject: [PATCH 48/90] PHPStan level 8 --- README.md | 4 ++-- composer.json | 2 +- phpstan.neon | 6 +++++- src/Job/Message.php | 3 ++- src/Listener/FailedJobsListener.php | 2 +- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8835527..5e3a08e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Coverage Status](https://img.shields.io/codecov/c/github/cakephp/queue/master.svg?style=flat-square)](https://codecov.io/github/cakephp/queue?branch=master) [![Total Downloads](https://img.shields.io/packagist/dt/cakephp/queue.svg?style=flat-square)](https://packagist.org/packages/cakephp/queue) -This is a Queue system for CakePHP 4. +This is a Queue system for CakePHP. The plugin consists of a CakePHP shell wrapper and Queueing libraries for the [php-queue](https://php-enqueue.github.io) queue library. @@ -34,7 +34,7 @@ bin/cake plugin load Cake/Queue Or you can manually add the loading statement in the **src/Application.php** file of your application: ```php -public function bootstrap() +public function bootstrap(): void { parent::bootstrap(); $this->addPlugin('Cake/Queue'); diff --git a/composer.json b/composer.json index 7e788c3..cf9ccea 100644 --- a/composer.json +++ b/composer.json @@ -52,7 +52,7 @@ ], "cs-check": "phpcs --colors -p src/ tests/", "cs-fix": "phpcbf --colors -p src/ tests/", - "stan": "phpstan analyse src/ && psalm.phar --show-info=false", + "stan": "phpstan analyse && psalm.phar --show-info=false", "stan-setup": "cp composer.json composer.backup && composer require --dev phpstan/phpstan:^1.5 psalm/phar:~4.22 && mv composer.backup composer.json", "test": "phpunit", "test-coverage": "phpunit --coverage-clover=clover.xml" diff --git a/phpstan.neon b/phpstan.neon index f5a1214..29af53d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,7 +1,11 @@ parameters: - level: 6 + level: 8 checkMissingIterableValueType: false + checkGenericClassInNonGenericObjectType: false bootstrapFiles: - tests/bootstrap.php paths: - src/ + ignoreErrors: + - '#Parameter \#1 \$callback of static method Closure::fromCallable\(\) expects callable\(\): mixed, array\{mixed, string\} given.#' + - '#Method Cake\\Queue\\Job\\Message::getTarget\(\) should return array\{class-string, string\} but returns non-empty-array.#' diff --git a/src/Job/Message.php b/src/Job/Message.php index be37a6f..66c731e 100644 --- a/src/Job/Message.php +++ b/src/Job/Message.php @@ -122,6 +122,7 @@ public function getCallable() */ public function getTarget(): array { + /** @var array|null $target */ $target = $this->parsedBody['class'] ?? null; if (!is_array($target) || count($target) !== 2) { @@ -176,7 +177,7 @@ public function getMaxAttempts(): ?int */ public function __toString() { - return json_encode($this); + return (string)json_encode($this); } /** diff --git a/src/Listener/FailedJobsListener.php b/src/Listener/FailedJobsListener.php index 89f5616..90a59dc 100644 --- a/src/Listener/FailedJobsListener.php +++ b/src/Listener/FailedJobsListener.php @@ -38,7 +38,7 @@ public function implementedEvents(): array } /** - * @param object $event EventInterface. + * @param \Cake\Event\EventInterface $event EventInterface. * @return void */ public function storeFailedJob($event): void From c5fba905e33d50a20fc0f5453e0386aabcb0100e Mon Sep 17 00:00:00 2001 From: mscherer Date: Mon, 23 Oct 2023 22:49:04 +0200 Subject: [PATCH 49/90] PHPStan level 8 --- src/Model/Table/FailedJobsTable.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Model/Table/FailedJobsTable.php b/src/Model/Table/FailedJobsTable.php index 4358a0d..31b7d7f 100644 --- a/src/Model/Table/FailedJobsTable.php +++ b/src/Model/Table/FailedJobsTable.php @@ -29,7 +29,7 @@ class FailedJobsTable extends Table /** * Initialize method * - * @param array $config The configuration for the Table. + * @param array $config The configuration for the Table. * @return void */ public function initialize(array $config): void From 3d7e9ab3c70b0f41d363acfcdb980b00cbb39b62 Mon Sep 17 00:00:00 2001 From: mscherer Date: Mon, 23 Oct 2023 22:53:37 +0200 Subject: [PATCH 50/90] Fix up Psalm --- src/Model/Table/FailedJobsTable.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Model/Table/FailedJobsTable.php b/src/Model/Table/FailedJobsTable.php index 31b7d7f..9e0e8ea 100644 --- a/src/Model/Table/FailedJobsTable.php +++ b/src/Model/Table/FailedJobsTable.php @@ -11,17 +11,17 @@ * * @method \Cake\Queue\Model\Entity\FailedJob newEmptyEntity() * @method \Cake\Queue\Model\Entity\FailedJob newEntity(array $data, array $options = []) - * @method \Cake\Queue\Model\Entity\FailedJob[] newEntities(array $data, array $options = []) + * @method array<\Cake\Queue\Model\Entity\FailedJob> newEntities(array $data, array $options = []) * @method \Cake\Queue\Model\Entity\FailedJob get($primaryKey, $options = []) * @method \Cake\Queue\Model\Entity\FailedJob findOrCreate($search, ?callable $callback = null, $options = []) * @method \Cake\Queue\Model\Entity\FailedJob patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = []) - * @method \Cake\Queue\Model\Entity\FailedJob[] patchEntities(iterable $entities, array $data, array $options = []) + * @method array<\Cake\Queue\Model\Entity\FailedJob> patchEntities(iterable $entities, array $data, array $options = []) * @method \Cake\Queue\Model\Entity\FailedJob|false save(\Cake\Datasource\EntityInterface $entity, $options = []) * @method \Cake\Queue\Model\Entity\FailedJob saveOrFail(\Cake\Datasource\EntityInterface $entity, $options = []) - * @method \Cake\Queue\Model\Entity\FailedJob[]|\Cake\Datasource\ResultSetInterface|false saveMany(iterable $entities, $options = []) - * @method \Cake\Queue\Model\Entity\FailedJob[]|\Cake\Datasource\ResultSetInterface saveManyOrFail(iterable $entities, $options = []) - * @method \Cake\Queue\Model\Entity\FailedJob[]|\Cake\Datasource\ResultSetInterface|false deleteMany(iterable $entities, $options = []) - * @method \Cake\Queue\Model\Entity\FailedJob[]|\Cake\Datasource\ResultSetInterface deleteManyOrFail(iterable $entities, $options = []) + * @method iterable<\Cake\Queue\Model\Entity\FailedJob>|false saveMany(iterable $entities, $options = []) + * @method iterable<\Cake\Queue\Model\Entity\FailedJob> saveManyOrFail(iterable $entities, $options = []) + * @method iterable<\Cake\Queue\Model\Entity\FailedJob>|false deleteMany(iterable $entities, $options = []) + * @method iterable<\Cake\Queue\Model\Entity\FailedJob> deleteManyOrFail(iterable $entities, $options = []) * @mixin \Cake\ORM\Behavior\TimestampBehavior */ class FailedJobsTable extends Table From 9fc1030c3696ee442eda04fbdb5f7082624fe6a5 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 27 Nov 2023 22:58:44 +0000 Subject: [PATCH 51/90] Run composer normalize over composer.json --- composer.json | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/composer.json b/composer.json index cf9ccea..e5ada73 100644 --- a/composer.json +++ b/composer.json @@ -1,16 +1,19 @@ { "name": "cakephp/queue", "description": "Queue plugin for CakePHP", - "keywords": ["cakephp", "queue"], - "homepage": "https://github.com/cakephp/queue", - "type": "cakephp-plugin", "license": "MIT", + "type": "cakephp-plugin", + "keywords": [ + "cakephp", + "queue" + ], "authors": [ { "name": "CakePHP Community", "homepage": "https://github.com/cakephp/queue/graphs/contributors" } ], + "homepage": "https://github.com/cakephp/queue", "support": { "issues": "https://github.com/cakephp/queue/issues", "forum": "https://stackoverflow.com/tags/cakephp", @@ -29,6 +32,10 @@ "enqueue/fs": "^0.10", "phpunit/phpunit": "^8.5 || ^9.3" }, + "suggest": { + "cakephp/bake": "Required if you want to generate jobs.", + "cakephp/migrations": "Needed for running the migrations necessary for using Failed Jobs." + }, "autoload": { "psr-4": { "Cake\\Queue\\": "src/" @@ -36,14 +43,16 @@ }, "autoload-dev": { "psr-4": { - "Cake\\Test\\": "vendor/cakephp/cakephp/tests/", "Cake\\Queue\\Test\\": "tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/", "TestApp\\": "tests/test_app/src/" } }, - "suggest": { - "cakephp/bake": "Required if you want to generate jobs.", - "cakephp/migrations": "Needed for running the migrations necessary for using Failed Jobs." + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + }, + "sort-packages": true }, "scripts": { "check": [ @@ -56,11 +65,5 @@ "stan-setup": "cp composer.json composer.backup && composer require --dev phpstan/phpstan:^1.5 psalm/phar:~4.22 && mv composer.backup composer.json", "test": "phpunit", "test-coverage": "phpunit --coverage-clover=clover.xml" - }, - "config": { - "sort-packages": true, - "allow-plugins": { - "dealerdirect/phpcodesniffer-composer-installer": true - } } } From bbcda8e71492483840e8e0bf215662408dac809d Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Sun, 21 Jan 2024 17:39:28 +0100 Subject: [PATCH 52/90] stan update --- .phive/phars.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.phive/phars.xml b/.phive/phars.xml index faba153..1f56b2b 100644 --- a/.phive/phars.xml +++ b/.phive/phars.xml @@ -1,5 +1,5 @@ - - + + From 5f9efc9adaa7b239da815e6346d83b790e5a8a63 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jan 2024 02:03:36 +0000 Subject: [PATCH 53/90] Bump actions/cache from 3 to 4 Bumps [actions/cache](https://github.com/actions/cache) from 3 to 4. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/cache dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b77b9b6..35cdeb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: run: echo "::set-output name=date::$(date +'%Y-%m')" - name: Cache composer dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }} From 943b3def1010bf93804888bdb7ee4fefc807c188 Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Sat, 10 Aug 2024 15:16:47 +0200 Subject: [PATCH 54/90] add phpunit 11 support --- composer.json | 6 +-- tests/TestCase/Command/WorkerCommandTest.php | 37 +++++++------------ .../LimitAttemptsExtensionTest.php | 7 ++-- ...emoveUniqueJobIdFromCacheExtensionTest.php | 7 ++-- .../Listener/FailedJobsListenerTest.php | 3 +- tests/TestCase/Mailer/QueueTraitTest.php | 5 +-- tests/TestCase/Queue/ProcessorTest.php | 5 ++- 7 files changed, 30 insertions(+), 40 deletions(-) diff --git a/composer.json b/composer.json index a9fb670..38288a6 100644 --- a/composer.json +++ b/composer.json @@ -22,15 +22,15 @@ }, "require": { "php": ">=8.1", - "cakephp/cakephp": "^5.0.0", + "cakephp/cakephp": "dev-5.next as 5.1.0", "enqueue/simple-client": "^0.10", "psr/log": "^3.0" }, "require-dev": { - "cakephp/bake": "^3.0.0", + "cakephp/bake": "dev-3.next", "cakephp/cakephp-codesniffer": "^5.0", "enqueue/fs": "^0.10", - "phpunit/phpunit": "^10.1.0" + "phpunit/phpunit": "^10.5.5 || ^11.1.3" }, "suggest": { "cakephp/bake": "Required if you want to generate jobs.", diff --git a/tests/TestCase/Command/WorkerCommandTest.php b/tests/TestCase/Command/WorkerCommandTest.php index 38f458a..5bc49b0 100644 --- a/tests/TestCase/Command/WorkerCommandTest.php +++ b/tests/TestCase/Command/WorkerCommandTest.php @@ -23,6 +23,8 @@ use Cake\Queue\Test\test_app\src\Job\LogToDebugWithServiceJob; use Cake\Queue\Test\TestCase\DebugLogTrait; use Cake\TestSuite\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use TestApp\Job\LogToDebugJob; use TestApp\Job\RequeueJob; use TestApp\WelcomeMailerListener; @@ -48,9 +50,8 @@ public function testDescriptionOutput() /** * Test that queue will run for one second - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueProcessesStart() { Configure::write('Queue', [ @@ -65,9 +66,8 @@ public function testQueueProcessesStart() /** * Test that queue will run for one second with valid listener - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueProcessesWithListener() { Configure::write('Queue', [ @@ -83,9 +83,8 @@ public function testQueueProcessesWithListener() /** * Test that queue will abort when the passed config is not present in the app configuration. - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueWillAbortWithMissingConfig() { Configure::write('Queue', [ @@ -102,9 +101,8 @@ public function testQueueWillAbortWithMissingConfig() /** * Test that queue will abort with invalid listener - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueProcessesWithInvalidListener() { Configure::write('Queue', [ @@ -121,9 +119,8 @@ public function testQueueProcessesWithInvalidListener() /** * Test that queue will write to specified logger option - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueProcessesWithLogger() { Configure::write('Queue', [ @@ -156,10 +153,9 @@ public static function dataProviderCallableTypes(): array /** * Start up the worker queue, push a job, and see that it processes - * - * @dataProvider dataProviderCallableTypes - * @runInSeparateProcess */ + #[DataProvider('dataProviderCallableTypes')] + #[RunInSeparateProcess] public function testQueueProcessesJob($callable) { $config = [ @@ -185,9 +181,8 @@ public function testQueueProcessesJob($callable) /** * Set the processor name, Start up the worker queue, push a job, and see that it processes - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueProcessesJobWithProcessor() { $config = [ @@ -212,9 +207,8 @@ public function testQueueProcessesJobWithProcessor() /** * Test non-default queue name - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueProcessesJobWithOtherQueue() { $config = [ @@ -240,9 +234,8 @@ public function testQueueProcessesJobWithOtherQueue() /** * Test max-attempts option - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueProcessesJobWithMaxAttempts() { $config = [ @@ -268,9 +261,8 @@ public function testQueueProcessesJobWithMaxAttempts() /** * Test DI service injection works in tasks - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueProcessesJobWithDIService() { $this->skipIf(version_compare(Configure::version(), '4.2', '<'), 'DI Container is only available since CakePHP 4.2'); @@ -296,9 +288,8 @@ public function testQueueProcessesJobWithDIService() /** * Test that queue will process when a unique cache is configured. - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueProcessesWithUniqueCacheConfigured() { $config = [ diff --git a/tests/TestCase/Consumption/LimitAttemptsExtensionTest.php b/tests/TestCase/Consumption/LimitAttemptsExtensionTest.php index 7094d47..709dcac 100644 --- a/tests/TestCase/Consumption/LimitAttemptsExtensionTest.php +++ b/tests/TestCase/Consumption/LimitAttemptsExtensionTest.php @@ -13,6 +13,8 @@ use Cake\Queue\Test\TestCase\DebugLogTrait; use Cake\TestSuite\TestCase; use Enqueue\Consumption\ChainExtension; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\BeforeClass; use Psr\Log\NullLogger; use TestApp\Job\MaxAttemptsIsThreeJob; use TestApp\Job\RequeueJob; @@ -28,10 +30,7 @@ public function setUp(): void EventManager::instance()->setEventList(new EventList()); } - /** - * @beforeClass - * @after - */ + #[BeforeClass, After] public static function dropConfigs() { Log::drop('debug'); diff --git a/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php b/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php index 9d09e2a..01102df 100644 --- a/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php +++ b/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php @@ -11,15 +11,14 @@ use Cake\Queue\QueueManager; use Cake\TestSuite\TestCase; use Enqueue\Consumption\ChainExtension; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\BeforeClass; use Psr\Log\NullLogger; use TestApp\Job\UniqueJob; class RemoveUniqueJobIdFromCacheExtensionTest extends TestCase { - /** - * @beforeClass - * @after - */ + #[BeforeClass, After] public static function dropConfigs() { Log::drop('debug'); diff --git a/tests/TestCase/Listener/FailedJobsListenerTest.php b/tests/TestCase/Listener/FailedJobsListenerTest.php index b126260..d08f0cf 100644 --- a/tests/TestCase/Listener/FailedJobsListenerTest.php +++ b/tests/TestCase/Listener/FailedJobsListenerTest.php @@ -28,6 +28,7 @@ use Cake\TestSuite\TestCase; use Enqueue\Null\NullConnectionFactory; use Enqueue\Null\NullMessage; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use stdClass; use TestApp\Job\LogToDebugJob; @@ -113,9 +114,9 @@ public static function storeFailedJobExceptionDataProvider() } /** - * @dataProvider storeFailedJobExceptionDataProvider * @return void */ + #[DataProvider('storeFailedJobExceptionDataProvider')] public function testStoreFailedJobException($eventData, $exceptionMessage) { $tableLocator = $this diff --git a/tests/TestCase/Mailer/QueueTraitTest.php b/tests/TestCase/Mailer/QueueTraitTest.php index 93d067b..f706aae 100644 --- a/tests/TestCase/Mailer/QueueTraitTest.php +++ b/tests/TestCase/Mailer/QueueTraitTest.php @@ -19,6 +19,7 @@ use Cake\Mailer\Exception\MissingActionException; use Cake\Queue\QueueManager; use Cake\TestSuite\TestCase; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use TestApp\WelcomeMailer; class QueueTraitTest extends TestCase @@ -38,10 +39,8 @@ public function testQueueTraitTestThrowsMissingActionException() /** * Test that QueueTrait calls push - * - * @runInSeparateProcess - * @return @void */ + #[RunInSeparateProcess] public function testQueueTraitCallsPush() { $queue = new WelcomeMailer(); diff --git a/tests/TestCase/Queue/ProcessorTest.php b/tests/TestCase/Queue/ProcessorTest.php index ec8bbab..3baf907 100644 --- a/tests/TestCase/Queue/ProcessorTest.php +++ b/tests/TestCase/Queue/ProcessorTest.php @@ -25,6 +25,7 @@ use Enqueue\Null\NullConnectionFactory; use Enqueue\Null\NullMessage; use Interop\Queue\Processor as InteropProcessor; +use PHPUnit\Framework\Attributes\DataProvider; use TestApp\TestProcessor; class ProcessorTest extends TestCase @@ -53,10 +54,10 @@ public static function dataProviderTestProcess(): array * @param string $jobMethod The method name to run * @param string $expected The expected process result. * @param string $logMessage The log message based on process result. - * @param string $dispacthedEvent The dispatched event based on process result. - * @dataProvider dataProviderTestProcess + * @param string $dispatchedEvent The dispatched event based on process result. * @return void */ + #[DataProvider('dataProviderTestProcess')] public function testProcess($jobMethod, $expected, $logMessage, $dispatchedEvent) { $messageBody = [ From 91c560c3ad66f05beb0e446c01e54c1454d51757 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 02:12:14 +0000 Subject: [PATCH 55/90] Bump codecov/codecov-action from 3 to 5 Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3 to 5. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v3...v5) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35cdeb3..95476ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: - name: Code Coverage Report if: matrix.php-version == '7.4' - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 cs-stan: name: Coding Standard & Static Analysis From 07d912a6b1e420eb6475ad3e7bb5f9384198caf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20M=2E=20Gonz=C3=A1lez=20Mart=C3=ADn?= Date: Wed, 11 Jun 2025 18:01:18 +0100 Subject: [PATCH 56/90] Update index.rst small improvement to get rid of deprecation messages in predis --- docs/en/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/en/index.rst b/docs/en/index.rst index d47b7fe..3de9203 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -25,7 +25,7 @@ pure-php redis: .. code-block:: bash - composer require enqueue/redis predis/predis:^1 + composer require enqueue/redis predis/predis:^3 Ensure that the plugin is loaded in your ``src/Application.php`` file, within the ``Application::bootstrap()`` function:: From b20275f3eb62c1ea65b45282fbc604e9a8c5db8d Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Sun, 22 Jun 2025 12:00:05 +0200 Subject: [PATCH 57/90] update stan --- .phive/phars.xml | 3 +- composer.json | 7 +---- phpstan-baseline.neon | 7 +++++ phpstan.neon | 10 ++++--- psalm-baseline.xml | 3 -- psalm.xml | 28 ------------------- .../RemoveUniqueJobIdFromCacheExtension.php | 1 - src/Job/Message.php | 5 ++-- src/QueueManager.php | 2 -- 9 files changed, 17 insertions(+), 49 deletions(-) create mode 100644 phpstan-baseline.neon delete mode 100644 psalm-baseline.xml delete mode 100644 psalm.xml diff --git a/.phive/phars.xml b/.phive/phars.xml index 1f56b2b..f5aa330 100644 --- a/.phive/phars.xml +++ b/.phive/phars.xml @@ -1,5 +1,4 @@ - - + diff --git a/composer.json b/composer.json index a9fb670..fa2db36 100644 --- a/composer.json +++ b/composer.json @@ -61,14 +61,9 @@ ], "cs-check": "phpcs --colors -p src/ tests/", "cs-fix": "phpcbf --colors -p src/ tests/", - "stan": [ - "@phpstan", - "@psalm" - ], + "stan": "@phpstan", "phpstan": "tools/phpstan analyse", - "psalm": "tools/psalm --show-info=false", "stan-baseline": "tools/phpstan --generate-baseline", - "psalm-baseline": "tools/psalm --set-baseline=psalm-baseline.xml", "stan-setup": "phive install", "test": "phpunit", "test-coverage": "phpunit --coverage-clover=clover.xml" diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..b2d742e --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,7 @@ +parameters: + ignoreErrors: + - + message: '#^Parameter \#1 \$callback of static method Closure\:\:fromCallable\(\) expects callable\(\)\: mixed, array\{mixed, string\} given\.$#' + identifier: argument.type + count: 1 + path: src/Job/Message.php diff --git a/phpstan.neon b/phpstan.neon index 621db66..82f9a15 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,12 +1,14 @@ +includes: + - phpstan-baseline.neon + parameters: level: 8 - checkMissingIterableValueType: false - checkGenericClassInNonGenericObjectType: false treatPhpDocTypesAsCertain: false bootstrapFiles: - tests/bootstrap.php paths: - src/ ignoreErrors: - - '#Parameter \#1 \$callback of static method Closure::fromCallable\(\) expects callable\(\): mixed, array\{mixed, string\} given.#' - - '#Method Cake\\Queue\\Job\\Message::getTarget\(\) should return array\{class-string, string\} but returns non-empty-array.#' + - identifier: missingType.iterableValue + - identifier: missingType.generics + - identifier: trait.unused diff --git a/psalm-baseline.xml b/psalm-baseline.xml deleted file mode 100644 index 396c873..0000000 --- a/psalm-baseline.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/psalm.xml b/psalm.xml deleted file mode 100644 index b231aa7..0000000 --- a/psalm.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php b/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php index 9aeb8d5..74ff3ad 100644 --- a/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php +++ b/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php @@ -39,7 +39,6 @@ public function onResult(MessageResult $context): void [$class, $method] = $jobMessage->getTarget(); - /** @psalm-suppress InvalidPropertyFetch */ if (empty($class::$shouldBeUnique)) { return; } diff --git a/src/Job/Message.php b/src/Job/Message.php index 5627b74..6416011 100644 --- a/src/Job/Message.php +++ b/src/Job/Message.php @@ -104,11 +104,11 @@ public function getCallable(): Closure * Get the target class and method. * * @return array{string, string} - * @psalm-return array{class-string, string} + * @phpstan-return array{class-string, string} */ public function getTarget(): array { - /** @var array|null $target */ + /** @var array{class-string, string}|null $target */ $target = $this->parsedBody['class'] ?? null; if (!is_array($target) || count($target) !== 2) { @@ -153,7 +153,6 @@ public function getMaxAttempts(): ?int $class = $target[0]; - /** @psalm-suppress InvalidPropertyFetch */ return $class::$maxAttempts ?? null; } diff --git a/src/QueueManager.php b/src/QueueManager.php index 664ec31..38d3077 100644 --- a/src/QueueManager.php +++ b/src/QueueManager.php @@ -228,7 +228,6 @@ public static function push(string|array $className, array $data = [], array $op $logger = $config['logger'] ? Log::engine($config['logger']) : null; - /** @psalm-suppress InvalidPropertyFetch */ if (!empty($class::$shouldBeUnique)) { if (empty($config['uniqueCache'])) { throw new InvalidArgumentException( @@ -277,7 +276,6 @@ public static function push(string|array $className, array $data = [], array $op $client = static::engine($name); $client->sendEvent($queue, $message); - /** @psalm-suppress InvalidPropertyFetch */ if (!empty($class::$shouldBeUnique)) { $uniqueId = static::getUniqueId($class, $method, $data); From 9bd695f018f3e4f0715c6c1375fd59e36ab3a5c1 Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Sun, 22 Jun 2025 12:01:38 +0200 Subject: [PATCH 58/90] fix CS --- src/Command/RequeueCommand.php | 2 +- src/Command/WorkerCommand.php | 2 +- src/Consumption/LimitAttemptsExtension.php | 6 +++--- src/Consumption/LimitConsumedMessagesExtension.php | 2 +- src/Job/Message.php | 2 +- src/Listener/FailedJobsListener.php | 4 ++-- src/Mailer/Transport/QueueTransport.php | 4 ++-- src/Plugin.php | 2 +- src/QueueManager.php | 4 ++-- tests/TestCase/Job/MailerJobTest.php | 10 +++++----- tests/TestCase/Listener/FailedJobsListenerTest.php | 4 ++-- tests/TestCase/Mailer/Transport/QueueTransportTest.php | 6 +++--- tests/TestCase/Task/JobTaskTest.php | 6 +++--- 13 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/Command/RequeueCommand.php b/src/Command/RequeueCommand.php index de6cb88..b9efd02 100644 --- a/src/Command/RequeueCommand.php +++ b/src/Command/RequeueCommand.php @@ -138,7 +138,7 @@ public function execute(Arguments $args, ConsoleIo $io): void 'config' => $failedJob->config, 'priority' => $failedJob->priority, 'queue' => $failedJob->queue, - ] + ], ); $failedJobsTable->deleteOrFail($failedJob); diff --git a/src/Command/WorkerCommand.php b/src/Command/WorkerCommand.php index f97148b..6541f19 100644 --- a/src/Command/WorkerCommand.php +++ b/src/Command/WorkerCommand.php @@ -110,7 +110,7 @@ public function getOptionParser(): ConsoleOptionParser 'short' => 'a', ]); $parser->setDescription( - 'Runs a queue worker that consumes from the named queue.' + 'Runs a queue worker that consumes from the named queue.', ); return $parser; diff --git a/src/Consumption/LimitAttemptsExtension.php b/src/Consumption/LimitAttemptsExtension.php index 3d2da34..c3a0af6 100644 --- a/src/Consumption/LimitAttemptsExtension.php +++ b/src/Consumption/LimitAttemptsExtension.php @@ -65,7 +65,7 @@ public function onResult(MessageResult $context): void if ($attemptNumber >= $maxAttempts) { $context->changeResult( - Result::reject(sprintf('The maximum number of %d allowed attempts was reached.', $maxAttempts)) + Result::reject(sprintf('The maximum number of %d allowed attempts was reached.', $maxAttempts)), ); $exception = (string)$message->getProperty('jobException'); @@ -73,7 +73,7 @@ public function onResult(MessageResult $context): void $this->dispatchEvent( 'Consumption.LimitAttemptsExtension.failed', ['exception' => $exception, 'logger' => $context->getLogger()], - $jobMessage + $jobMessage, ); return; @@ -88,7 +88,7 @@ public function onResult(MessageResult $context): void $producer->send($consumer->getQueue(), $newMessage); $context->changeResult( - Result::reject('A copy of the message was sent with an incremented attempt count.') + Result::reject('A copy of the message was sent with an incremented attempt count.'), ); } } diff --git a/src/Consumption/LimitConsumedMessagesExtension.php b/src/Consumption/LimitConsumedMessagesExtension.php index 680f8b4..e202fbe 100644 --- a/src/Consumption/LimitConsumedMessagesExtension.php +++ b/src/Consumption/LimitConsumedMessagesExtension.php @@ -79,7 +79,7 @@ protected function shouldBeStopped(LoggerInterface $logger): bool $logger->debug(sprintf( '[LimitConsumedMessagesExtension] Message consumption is interrupted since the message limit ' . 'reached. limit: "%s"', - $this->messageLimit + $this->messageLimit, )); return true; diff --git a/src/Job/Message.php b/src/Job/Message.php index 6416011..7da6218 100644 --- a/src/Job/Message.php +++ b/src/Job/Message.php @@ -114,7 +114,7 @@ public function getTarget(): array if (!is_array($target) || count($target) !== 2) { throw new RuntimeException(sprintf( 'Message class should be in the form `[class, method]` got `%s`', - json_encode($target) + json_encode($target), )); } diff --git a/src/Listener/FailedJobsListener.php b/src/Listener/FailedJobsListener.php index 1064f37..f60d15e 100644 --- a/src/Listener/FailedJobsListener.php +++ b/src/Listener/FailedJobsListener.php @@ -83,7 +83,7 @@ public function storeFailedJob(object $event): void throw new RuntimeException( sprintf('`logger` was not defined on %s event.', $event->getName()), 0, - $e + $e, ); } @@ -91,7 +91,7 @@ public function storeFailedJob(object $event): void throw new RuntimeException( sprintf('`logger` is not an instance of `LoggerInterface` on %s event.', $event->getName()), 0, - $e + $e, ); } diff --git a/src/Mailer/Transport/QueueTransport.php b/src/Mailer/Transport/QueueTransport.php index 8c7688b..c798d53 100644 --- a/src/Mailer/Transport/QueueTransport.php +++ b/src/Mailer/Transport/QueueTransport.php @@ -54,7 +54,7 @@ public function send(Message $message): array 'returnPath', 'cc', 'bcc', - ] + ], ); return ['headers' => $headers, 'message' => 'Message has been enqueued']; @@ -72,7 +72,7 @@ protected function enqueueJob(array $data, array $options): void QueueManager::push( [SendMailJob::class, 'execute'], $data, - $options + $options, ); } diff --git a/src/Plugin.php b/src/Plugin.php index 413fb5c..d607ef0 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -53,7 +53,7 @@ public function bootstrap(PluginApplicationInterface $app): void if (!Configure::read('Queue')) { throw new InvalidArgumentException( 'Missing `Queue` configuration key, please check the CakePHP Queue documentation' . - ' to complete the plugin setup.' + ' to complete the plugin setup.', ); } diff --git a/src/QueueManager.php b/src/QueueManager.php index 38d3077..aac4739 100644 --- a/src/QueueManager.php +++ b/src/QueueManager.php @@ -231,7 +231,7 @@ public static function push(string|array $className, array $data = [], array $op if (!empty($class::$shouldBeUnique)) { if (empty($config['uniqueCache'])) { throw new InvalidArgumentException( - "$class::\$shouldBeUnique is set to `true` but `uniqueCache` configuration is missing." + "$class::\$shouldBeUnique is set to `true` but `uniqueCache` configuration is missing.", ); } @@ -240,7 +240,7 @@ public static function push(string|array $className, array $data = [], array $op if (Cache::read($uniqueId, $config['uniqueCacheKey'])) { if ($logger) { $logger->debug( - "An identical instance of $class already exists on the queue. This push will be ignored." + "An identical instance of $class already exists on the queue. This push will be ignored.", ); } diff --git a/tests/TestCase/Job/MailerJobTest.php b/tests/TestCase/Job/MailerJobTest.php index 112a69d..9a617b9 100644 --- a/tests/TestCase/Job/MailerJobTest.php +++ b/tests/TestCase/Job/MailerJobTest.php @@ -71,7 +71,7 @@ public function testExecute() ->with( $this->equalTo('welcome'), $this->equalTo($this->args), - $this->equalTo($this->headers) + $this->equalTo($this->headers), ) ->willReturn(['Message sent']); @@ -79,7 +79,7 @@ public function testExecute() ->method('getMailer') ->with( $this->equalTo('SampleTest'), - $this->equalTo($this->mailerConfig) + $this->equalTo($this->mailerConfig), )->willReturn($this->mailer); $message = $this->createMessage(); @@ -101,7 +101,7 @@ public function testExecuteMissingMailerException() ->method('getMailer') ->with( $this->equalTo('SampleTest'), - $this->equalTo($this->mailerConfig) + $this->equalTo($this->mailerConfig), )->willThrowException(new MissingMailerException('Missing mailer for testExecuteMissingMailerException')); $message = $this->createMessage(); @@ -121,7 +121,7 @@ public function testExecuteBadMethodCallException() ->with( $this->equalTo('welcome'), $this->equalTo($this->args), - $this->equalTo($this->headers) + $this->equalTo($this->headers), ) ->willThrowException(new BadMethodCallException('Welcome is not a valid method')); @@ -129,7 +129,7 @@ public function testExecuteBadMethodCallException() ->method('getMailer') ->with( $this->equalTo('SampleTest'), - $this->equalTo($this->mailerConfig) + $this->equalTo($this->mailerConfig), )->willReturn($this->mailer); $message = $this->createMessage(); diff --git a/tests/TestCase/Listener/FailedJobsListenerTest.php b/tests/TestCase/Listener/FailedJobsListenerTest.php index b126260..9a9a71c 100644 --- a/tests/TestCase/Listener/FailedJobsListenerTest.php +++ b/tests/TestCase/Listener/FailedJobsListenerTest.php @@ -76,7 +76,7 @@ public function testFailedJobIsAddedWhenEventIsFired() $event = new Event( 'Consumption.LimitAttemptsExtension.failed', $message, - ['exception' => 'some message'] + ['exception' => 'some message'], ); /** @var \Cake\Queue\Model\Table\FailedJobsTable $failedJobsTable */ @@ -160,7 +160,7 @@ public function testStoreFailedJobException($eventData, $exceptionMessage) $event = new Event( 'Consumption.LimitAttemptsExtension.failed', $message, - $eventData + $eventData, ); $this->expectException(RuntimeException::class); diff --git a/tests/TestCase/Mailer/Transport/QueueTransportTest.php b/tests/TestCase/Mailer/Transport/QueueTransportTest.php index 6539e0b..61ff9a5 100644 --- a/tests/TestCase/Mailer/Transport/QueueTransportTest.php +++ b/tests/TestCase/Mailer/Transport/QueueTransportTest.php @@ -67,7 +67,7 @@ public function testSend() 'returnPath', 'cc', 'bcc', - ] + ], ); $expected = ['headers' => $headers, 'message' => 'Message has been enqueued']; @@ -93,7 +93,7 @@ public function testSendCustomTransport() 'queue' => 'default', 'url' => $this->getFsQueueUrl(), ]); - $message = (new Message()); + $message = new Message(); $transport = new QueueTransport([ 'transport' => SmtpTransport::class, @@ -124,7 +124,7 @@ public function testSendBcTransport() 'queue' => 'default', 'url' => $this->getFsQueueUrl(), ]); - $message = (new Message()); + $message = new Message(); $transport = new QueueTransport([ 'transport' => SmtpTransport::class, diff --git a/tests/TestCase/Task/JobTaskTest.php b/tests/TestCase/Task/JobTaskTest.php index 8236c86..a49324f 100644 --- a/tests/TestCase/Task/JobTaskTest.php +++ b/tests/TestCase/Task/JobTaskTest.php @@ -73,7 +73,7 @@ public function testMain() $this->assertOutputContains('Creating file ' . $this->generatedFile); $this->assertSameAsFile( $this->comparisonDir . 'JobTask.php', - file_get_contents($this->generatedFile) + file_get_contents($this->generatedFile), ); } @@ -87,7 +87,7 @@ public function testMainWithUnique() $this->assertOutputContains('Creating file ' . $this->generatedFile); $this->assertSameAsFile( $this->comparisonDir . 'JobTaskWithUnique.php', - file_get_contents($this->generatedFile) + file_get_contents($this->generatedFile), ); } @@ -101,7 +101,7 @@ public function testMainWithMaxAttempts() $this->assertOutputContains('Creating file ' . $this->generatedFile); $this->assertSameAsFile( $this->comparisonDir . 'JobTaskWithMaxAttempts.php', - file_get_contents($this->generatedFile) + file_get_contents($this->generatedFile), ); } } From 0c204423a9ac8af5aef6fa87d2786d39fe5cbb84 Mon Sep 17 00:00:00 2001 From: Yevgeny Tomenko Date: Tue, 15 Jul 2025 03:26:22 +0300 Subject: [PATCH 59/90] Add configurable processor class support --- docs/en/index.rst | 161 +++++++++++++++++- src/Command/WorkerCommand.php | 37 +++- tests/TestCase/Command/WorkerCommandTest.php | 131 ++++++++++++++ .../src/Queue/TestCustomProcessor.php | 124 ++++++++++++++ 4 files changed, 446 insertions(+), 7 deletions(-) create mode 100644 tests/test_app/src/Queue/TestCustomProcessor.php diff --git a/docs/en/index.rst b/docs/en/index.rst index 3de9203..9ca35e5 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -54,6 +54,10 @@ The following configuration should be present in the config array of your **conf // The name of an event listener class to associate with the worker 'listener' => \App\Listener\WorkerListener::class, + // (optional) The processor class to use for processing messages. + // Must implement Interop\Queue\Processor. Defaults to Cake\Queue\Queue\Processor + 'processor' => \App\Queue\CustomProcessor::class, + // The amount of time in milliseconds to sleep if no jobs are currently available. default: 10000 'receiveTimeout' => 10000, @@ -119,14 +123,14 @@ A simple job that logs received messages would look like:: /** * The maximum number of times the job may be attempted. (optional property) - * + * * @var int|null */ public static $maxAttempts = 3; /** * Whether there should be only one instance of a job on the queue at a time. (optional property) - * + * * @var bool */ public static $shouldBeUnique = false; @@ -299,7 +303,7 @@ queue jobs, you can use the ``QueueTransport``. In your application's return [ // ... other configuration - 'EmailTransport' => [ + 'EmailTransport' => [ 'default' => [ 'className' => MailTransport::class, // Configuration for MailTransport. @@ -323,6 +327,153 @@ With this configuration in place, any time you send an email with the ``default` email profile CakePHP will generate a queue message. Once that queue message is processed the default ``MailTransport`` will be used to deliver the email messages. +Custom Processors +================ + +You can customize how messages are processed by specifying a custom processor class +in your queue configuration. Custom processors must implement the ``Interop\Queue\Processor`` +interface. + +Example custom processor that extends the main Processor:: + + dispatchEvent('Processor.message.seen', ['queueMessage' => $message]); + + $jobMessage = new Message($message, $context, $this->container); + try { + $jobMessage->getCallable(); + } catch (RuntimeException | Error $e) { + $this->logger->debug('Invalid callable for message. Rejecting message from queue.'); + $this->dispatchEvent('Processor.message.invalid', ['message' => $jobMessage]); + + return InteropProcessor::REJECT; + } + + $startTime = microtime(true) * 1000; + $this->dispatchEvent('Processor.message.start', ['message' => $jobMessage]); + + try { + $response = $this->processMessage($jobMessage); + } catch (Throwable $e) { + $message->setProperty('jobException', $e); + + $this->logger->debug(sprintf('Message encountered exception: %s', $e->getMessage())); + $this->dispatchEvent('Processor.message.exception', [ + 'message' => $jobMessage, + 'exception' => $e, + 'duration' => (int)((microtime(true) * 1000) - $startTime), + ]); + + return Result::requeue('Exception occurred while processing message'); + } + + $duration = (int)((microtime(true) * 1000) - $startTime); + + if ($response === InteropProcessor::ACK) { + $this->logger->debug('Message processed successfully'); + $this->dispatchEvent('Processor.message.success', [ + 'message' => $jobMessage, + 'duration' => $duration, + ]); + + return InteropProcessor::ACK; + } + + if ($response === InteropProcessor::REJECT) { + $this->logger->debug('Message processed with rejection'); + $this->dispatchEvent('Processor.message.reject', [ + 'message' => $jobMessage, + 'duration' => $duration, + ]); + + return InteropProcessor::REJECT; + } + + $this->logger->debug('Message processed with failure, requeuing'); + $this->dispatchEvent('Processor.message.failure', [ + 'message' => $jobMessage, + 'duration' => $duration, + ]); + + return InteropProcessor::REQUEUE; + } + } + +Configuration example:: + + 'Queue' => [ + 'default' => [ + 'url' => 'redis://localhost:6379', + 'queue' => 'default', + // No processor specified - uses default Processor class + ], + 'timed' => [ + 'url' => 'redis://localhost:6379', + 'queue' => 'timed', + 'processor' => \App\Queue\TimedProcessor::class, // Custom processor with timing + ], + ], + +**Note**: If no processor is specified in the configuration, the default +``Cake\Queue\Queue\Processor`` class will be used. Custom processors are useful +for adding custom logging, metrics collection, or specialized message handling. + +**Important**: The `--processor` command line option is different from the `processor` configuration option: + +- **Configuration `processor`**: Specifies the processor class to use for processing messages +- **Command line `--processor`**: Specifies the processor name for Enqueue topic binding (used in `bindTopic()`) + +Example usage:: + + # Use custom processor class from config + bin/cake queue worker --config=timed + + # Use custom processor class AND specify topic binding name + bin/cake queue worker --config=timed --processor=my-topic-processor + Run the worker ============== @@ -336,7 +487,7 @@ This shell can take a few different options: - ``--config`` (default: default): Name of a queue config to use - ``--queue`` (default: default): Name of queue to bind to -- ``--processor`` (default: ``null``): Name of processor to bind to +- ``--processor`` (default: ``null``): Name of processor to bind to (for Enqueue topic binding, not the processor class) - ``--logger`` (default: ``stdout``): Name of a configured logger - ``--max-jobs`` (default: ``null``): Maximum number of jobs to process. Worker will exit after limit is reached. - ``--max-runtime`` (default: ``null``): Maximum number of seconds to run. Worker will exit after limit is reached. @@ -370,7 +521,7 @@ Requeue Failed Jobs Push jobs back onto the queue and remove them from the ``queue_failed_jobs`` table. If a job fails to requeue it is not guaranteed that the job was not run. - + .. code-block:: bash bin/cake queue requeue diff --git a/src/Command/WorkerCommand.php b/src/Command/WorkerCommand.php index 6541f19..049eaf4 100644 --- a/src/Command/WorkerCommand.php +++ b/src/Command/WorkerCommand.php @@ -34,8 +34,10 @@ use Enqueue\Consumption\Extension\LimitConsumptionTimeExtension; use Enqueue\Consumption\Extension\LoggerExtension; use Enqueue\Consumption\ExtensionInterface; +use Interop\Queue\Processor as InteropProcessor; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use RuntimeException; /** * Worker command. @@ -170,6 +172,34 @@ protected function getLogger(Arguments $args): LoggerInterface return $logger ?? new NullLogger(); } + /** + * Creates and returns a Processor object + * + * @param \Cake\Console\Arguments $args Arguments + * @param \Cake\Console\ConsoleIo $io ConsoleIo + * @param \Psr\Log\LoggerInterface $logger Logger instance + * @return \Interop\Queue\Processor + */ + protected function getProcessor(Arguments $args, ConsoleIo $io, LoggerInterface $logger): InteropProcessor + { + $configKey = (string)$args->getOption('config'); + $config = QueueManager::getConfig($configKey); + + $processorClass = $config['processor'] ?? Processor::class; + + if (!class_exists($processorClass)) { + $io->error(sprintf(sprintf('Processor class %s not found', $processorClass))); + $this->abort(); + } + + if (!is_subclass_of($processorClass, InteropProcessor::class)) { + $io->error(sprintf(sprintf('Processor class %s must implement Interop\Queue\Processor', $processorClass))); + $this->abort(); + } + + return new $processorClass($logger, $this->container); + } + /** * @param \Cake\Console\Arguments $args Arguments * @param \Cake\Console\ConsoleIo $io ConsoleIo @@ -184,7 +214,7 @@ public function execute(Arguments $args, ConsoleIo $io): int } $logger = $this->getLogger($args); - $processor = new Processor($logger, $this->container); + $processor = $this->getProcessor($args, $io, $logger); $extension = $this->getQueueExtension($args, $logger); $hasListener = Configure::check(sprintf('Queue.%s.listener', $config)); @@ -197,7 +227,10 @@ public function execute(Arguments $args, ConsoleIo $io): int /** @var \Cake\Event\EventListenerInterface $listener */ $listener = new $listenerClassName(); - $processor->getEventManager()->on($listener); + + if ($processor instanceof Processor) { + $processor->getEventManager()->on($listener); + } } $client = QueueManager::engine($config); $queue = $args->getOption('queue') diff --git a/tests/TestCase/Command/WorkerCommandTest.php b/tests/TestCase/Command/WorkerCommandTest.php index 38f458a..5eb1623 100644 --- a/tests/TestCase/Command/WorkerCommandTest.php +++ b/tests/TestCase/Command/WorkerCommandTest.php @@ -21,6 +21,7 @@ use Cake\Log\Log; use Cake\Queue\QueueManager; use Cake\Queue\Test\test_app\src\Job\LogToDebugWithServiceJob; +use Cake\Queue\Test\test_app\src\Queue\TestCustomProcessor; use Cake\Queue\Test\TestCase\DebugLogTrait; use Cake\TestSuite\TestCase; use TestApp\Job\LogToDebugJob; @@ -320,4 +321,134 @@ public function testQueueProcessesWithUniqueCacheConfigured() $this->assertDebugLogContains('Consumption has started'); } + + /** + * Test that queue uses default processor when no processor is specified. + * + * @runInSeparateProcess + */ + public function testQueueUsesDefaultProcessor() + { + $config = [ + 'queue' => 'default', + 'url' => 'file:///' . TMP . DS . 'queue', + 'receiveTimeout' => 100, + ]; + Configure::write('Queue', ['default' => $config]); + + Log::setConfig('debug', [ + 'className' => 'Array', + 'levels' => ['notice', 'info', 'debug'], + ]); + + QueueManager::setConfig('default', $config); + QueueManager::push(LogToDebugJob::class); + QueueManager::drop('default'); + + $this->exec('queue worker --max-jobs=1 --logger=debug --verbose'); + + $this->assertDebugLogContains('Debug job was run'); + } + + /** + * Test that queue uses custom processor when specified in configuration. + * + * @runInSeparateProcess + */ + public function testQueueUsesCustomProcessor() + { + $config = [ + 'queue' => 'default', + 'url' => 'file:///' . TMP . DS . 'queue', + 'receiveTimeout' => 100, + 'processor' => TestCustomProcessor::class, + ]; + Configure::write('Queue', ['default' => $config]); + + Log::setConfig('debug', [ + 'className' => 'Array', + 'levels' => ['notice', 'info', 'debug'], + ]); + + QueueManager::setConfig('default', $config); + QueueManager::push(LogToDebugJob::class); + QueueManager::drop('default'); + + $this->exec('queue worker --max-jobs=1 --logger=debug --verbose'); + + $this->assertDebugLogContains('Debug job was run'); + $this->assertDebugLogContains('TestCustomProcessor processing message'); + $this->assertDebugLogContains('Message processed successfully by TestCustomProcessor'); + } + + /** + * Test that queue aborts when custom processor class does not exist. + * + * @runInSeparateProcess + */ + public function testQueueAbortsWithNonExistentProcessor() + { + $config = [ + 'queue' => 'default', + 'url' => 'file:///' . TMP . DS . 'queue', + 'receiveTimeout' => 100, + 'processor' => 'NonExistentProcessor', + ]; + Configure::write('Queue', ['default' => $config]); + + $this->exec('queue worker --max-runtime=0'); + + $this->assertErrorContains('Processor class NonExistentProcessor not found'); + } + + /** + * Test that queue aborts when custom processor does not implement Interop\Queue\Processor. + * + * @runInSeparateProcess + */ + public function testQueueAbortsWithInvalidProcessor() + { + $config = [ + 'queue' => 'default', + 'url' => 'file:///' . TMP . DS . 'queue', + 'receiveTimeout' => 100, + 'processor' => 'stdClass', + ]; + Configure::write('Queue', ['default' => $config]); + + $this->exec('queue worker --max-runtime=0'); + + $this->assertErrorContains('Processor class stdClass must implement Interop\Queue\Processor'); + } + + /** + * Test that custom processor works with listener configuration. + * + * @runInSeparateProcess + */ + public function testCustomProcessorWithListener() + { + $config = [ + 'queue' => 'default', + 'url' => 'file:///' . TMP . DS . 'queue', + 'receiveTimeout' => 100, + 'processor' => TestCustomProcessor::class, + 'listener' => WelcomeMailerListener::class, + ]; + Configure::write('Queue', ['default' => $config]); + + Log::setConfig('debug', [ + 'className' => 'Array', + 'levels' => ['notice', 'info', 'debug'], + ]); + + QueueManager::setConfig('default', $config); + QueueManager::push(LogToDebugJob::class); + QueueManager::drop('default'); + + $this->exec('queue worker --max-jobs=1 --logger=debug --verbose'); + + $this->assertDebugLogContains('Debug job was run'); + $this->assertDebugLogContains('TestCustomProcessor processing message'); + } } diff --git a/tests/test_app/src/Queue/TestCustomProcessor.php b/tests/test_app/src/Queue/TestCustomProcessor.php new file mode 100644 index 0000000..56f72eb --- /dev/null +++ b/tests/test_app/src/Queue/TestCustomProcessor.php @@ -0,0 +1,124 @@ +logger = $logger ?: new NullLogger(); + $this->container = $container; + } + + /** + * Process a message from the queue + * + * @param \Interop\Queue\Message $message The queue message + * @param \Interop\Queue\Context $context The queue context + * @return string|object Processing result + */ + public function process(QueueMessage $message, Context $context): string|object + { + $this->logger->debug('TestCustomProcessor processing message'); + + $jobMessage = new Message($message, $context, $this->container); + try { + $jobMessage->getCallable(); + } catch (RuntimeException | Error $e) { + $this->logger->debug('Invalid callable for message. Rejecting message from queue.'); + $this->dispatchEvent('Processor.message.invalid', ['message' => $jobMessage]); + + return InteropProcessor::REJECT; + } + + $this->dispatchEvent('Processor.message.start', ['message' => $jobMessage]); + + try { + $response = $this->processMessage($jobMessage); + } catch (Throwable $e) { + $message->setProperty('jobException', $e); + + $this->logger->debug(sprintf('Message encountered exception: %s', $e->getMessage())); + $this->dispatchEvent('Processor.message.exception', [ + 'message' => $jobMessage, + 'exception' => $e, + ]); + + return Result::requeue('Exception occurred while processing message'); + } + + if ($response === InteropProcessor::ACK) { + $this->logger->debug('Message processed successfully by TestCustomProcessor'); + $this->dispatchEvent('Processor.message.success', ['message' => $jobMessage]); + + return InteropProcessor::ACK; + } + + if ($response === InteropProcessor::REJECT) { + $this->logger->debug('Message processed with rejection by TestCustomProcessor'); + $this->dispatchEvent('Processor.message.reject', ['message' => $jobMessage]); + + return InteropProcessor::REJECT; + } + + $this->logger->debug('Message processed with failure, requeuing by TestCustomProcessor'); + $this->dispatchEvent('Processor.message.failure', ['message' => $jobMessage]); + + return InteropProcessor::REQUEUE; + } + + /** + * Process the job message and return the result + * + * @param \Cake\Queue\Job\Message $message The job message + * @return string|object Processing result + */ + public function processMessage(Message $message): string|object + { + $callable = $message->getCallable(); + $response = $callable($message); + if ($response === null) { + $response = InteropProcessor::ACK; + } + + return $response; + } +} From 33e6e052f9e425433e51be1cef90e8437999ec15 Mon Sep 17 00:00:00 2001 From: Yevgeny Tomenko Date: Tue, 15 Jul 2025 03:33:21 +0300 Subject: [PATCH 60/90] code style fixes --- src/Command/WorkerCommand.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Command/WorkerCommand.php b/src/Command/WorkerCommand.php index 049eaf4..e755b34 100644 --- a/src/Command/WorkerCommand.php +++ b/src/Command/WorkerCommand.php @@ -37,7 +37,6 @@ use Interop\Queue\Processor as InteropProcessor; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; -use RuntimeException; /** * Worker command. From 7b9506e6141505171c22a9e08234444731e4ea09 Mon Sep 17 00:00:00 2001 From: Yevgeny Tomenko Date: Tue, 15 Jul 2025 03:53:14 +0300 Subject: [PATCH 61/90] Add timing (duration) to Processor event payloads --- src/Queue/Processor.php | 19 ++++++++++++++++--- tests/TestCase/Queue/ProcessorTest.php | 13 +++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/Queue/Processor.php b/src/Queue/Processor.php index 515cf1c..b90601d 100644 --- a/src/Queue/Processor.php +++ b/src/Queue/Processor.php @@ -79,6 +79,7 @@ public function process(QueueMessage $message, Context $context): string|object return InteropProcessor::REJECT; } + $startTime = microtime(true) * 1000; $this->dispatchEvent('Processor.message.start', ['message' => $jobMessage]); try { @@ -90,27 +91,39 @@ public function process(QueueMessage $message, Context $context): string|object $this->dispatchEvent('Processor.message.exception', [ 'message' => $jobMessage, 'exception' => $e, + 'duration' => (int)((microtime(true) * 1000) - $startTime), ]); return Result::requeue('Exception occurred while processing message'); } + $duration = (int)((microtime(true) * 1000) - $startTime); + if ($response === InteropProcessor::ACK) { $this->logger->debug('Message processed successfully'); - $this->dispatchEvent('Processor.message.success', ['message' => $jobMessage]); + $this->dispatchEvent('Processor.message.success', [ + 'message' => $jobMessage, + 'duration' => $duration, + ]); return InteropProcessor::ACK; } if ($response === InteropProcessor::REJECT) { $this->logger->debug('Message processed with rejection'); - $this->dispatchEvent('Processor.message.reject', ['message' => $jobMessage]); + $this->dispatchEvent('Processor.message.reject', [ + 'message' => $jobMessage, + 'duration' => $duration, + ]); return InteropProcessor::REJECT; } $this->logger->debug('Message processed with failure, requeuing'); - $this->dispatchEvent('Processor.message.failure', ['message' => $jobMessage]); + $this->dispatchEvent('Processor.message.failure', [ + 'message' => $jobMessage, + 'duration' => $duration, + ]); return InteropProcessor::REQUEUE; } diff --git a/tests/TestCase/Queue/ProcessorTest.php b/tests/TestCase/Queue/ProcessorTest.php index ec8bbab..0da6b9c 100644 --- a/tests/TestCase/Queue/ProcessorTest.php +++ b/tests/TestCase/Queue/ProcessorTest.php @@ -95,6 +95,11 @@ public function testProcess($jobMethod, $expected, $logMessage, $dispatchedEvent $data = $events[2]->getData(); $this->assertArrayHasKey('message', $data); $this->assertSame($message->jsonSerialize(), $data['message']->jsonSerialize()); + + // Verify timing information is present in completion events + $this->assertArrayHasKey('duration', $data); + $this->assertIsInt($data['duration']); + $this->assertGreaterThanOrEqual(0, $data['duration']); } /** @@ -154,6 +159,14 @@ public function testProcessWillRequeueOnException() $result = $processor->process($queueMessage, $context); $this->assertEquals(InteropProcessor::REQUEUE, $result); + + // Verify timing information is present in exception event + $this->assertSame(3, $events->count()); + $this->assertSame('Processor.message.exception', $events[2]->getName()); + $data = $events[2]->getData(); + $this->assertArrayHasKey('duration', $data); + $this->assertIsInt($data['duration']); + $this->assertGreaterThanOrEqual(0, $data['duration']); } /** From 0823a1919e2b7e650ad9286abf7b974e2ca4458b Mon Sep 17 00:00:00 2001 From: Evgeny Tomenko Date: Fri, 22 Aug 2025 15:18:49 +0300 Subject: [PATCH 62/90] Update composer.json --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index fa2db36..410e2e3 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,8 @@ }, "suggest": { "cakephp/bake": "Required if you want to generate jobs.", - "cakephp/migrations": "Needed for running the migrations necessary for using Failed Jobs." + "cakephp/migrations": "Needed for running the migrations necessary for using Failed Jobs.", + "cakedc/cakephp-enqueue": "Required if you want store jobs in database." }, "autoload": { "psr-4": { From 3eeebe093f17457401122b2523e07e0379029e75 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Oct 2025 08:08:50 +0000 Subject: [PATCH 63/90] Bump actions/checkout from 3 to 5 Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 5. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v5) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/deploy_docs_1x.yml | 2 +- .github/workflows/deploy_docs_2x.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy_docs_1x.yml b/.github/workflows/deploy_docs_1x.yml index ef1b849..54166f4 100644 --- a/.github/workflows/deploy_docs_1x.yml +++ b/.github/workflows/deploy_docs_1x.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Cloning repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 diff --git a/.github/workflows/deploy_docs_2x.yml b/.github/workflows/deploy_docs_2x.yml index e775085..e48e52e 100644 --- a/.github/workflows/deploy_docs_2x.yml +++ b/.github/workflows/deploy_docs_2x.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Cloning repo - uses: actions/checkout@v3 + uses: actions/checkout@v5 with: fetch-depth: 0 From bbb91e61ba20b1860c0b3cb7f943d3e83a7a451c Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Wed, 22 Oct 2025 10:11:40 +0200 Subject: [PATCH 64/90] readme: fix build status badge --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 269e467..3543135 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # Queue plugin for CakePHP -![Build Status](https://github.com/cakephp/queue/actions/workflows/ci.yml/badge.svg?branch=master) +[![CI](https://github.com/cakephp/queue/actions/workflows/ci.yml/badge.svg)](https://github.com/cakephp/queue/actions/workflows/ci.yml) [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.txt) [![Coverage Status](https://img.shields.io/codecov/c/github/cakephp/queue/master.svg?style=flat-square)](https://codecov.io/github/cakephp/queue?branch=master) +[![PHPStan Level 8](https://img.shields.io/badge/PHPStan-level%208-brightgreen)](https://github.com/cakephp/queue) [![Total Downloads](https://img.shields.io/packagist/dt/cakephp/queue.svg?style=flat-square)](https://packagist.org/packages/cakephp/queue) This is a Queue system for CakePHP. From 585c81572191098b58c8781439e3566ef7d8bf92 Mon Sep 17 00:00:00 2001 From: ADmad Date: Wed, 22 Oct 2025 13:46:09 +0530 Subject: [PATCH 65/90] Fix CI errors --- .phive/phars.xml | 2 +- phpcs.xml.dist | 4 +++- tests/bootstrap.php | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.phive/phars.xml b/.phive/phars.xml index f5aa330..a95d33f 100644 --- a/.phive/phars.xml +++ b/.phive/phars.xml @@ -1,4 +1,4 @@ - + diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 3edb1fc..0f8d406 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -1,7 +1,9 @@ - + src/ + tests/ + tests/comparisons/* diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 75c23fd..ecf4710 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -55,9 +55,9 @@ // phpcs:enable Cache::setConfig([ - '_cake_core_' => [ + '_cake_translations_' => [ 'engine' => 'File', - 'prefix' => 'cake_core_', + 'prefix' => '_cake_translations_', 'serialize' => true, ], '_cake_model_' => [ From 66d9ae4d367efe2a35aaa6ac9548ed1cd4d982a0 Mon Sep 17 00:00:00 2001 From: ADmad Date: Wed, 22 Oct 2025 13:50:54 +0530 Subject: [PATCH 66/90] Allow higher phpunit versions --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 410e2e3..2d8bfe5 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "cakephp/bake": "^3.0.0", "cakephp/cakephp-codesniffer": "^5.0", "enqueue/fs": "^0.10", - "phpunit/phpunit": "^10.1.0" + "phpunit/phpunit": "^10.1.0 || ^11.1.3 || ^12.0.9" }, "suggest": { "cakephp/bake": "Required if you want to generate jobs.", From d75a4c3c6adbd950c77c8b6da4bee6bbdf298cbd Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Wed, 22 Oct 2025 11:11:15 +0200 Subject: [PATCH 67/90] Fix tests - Update tests to be compatible with phpunit 11/12 - Implement QueueMananger::configured() to allow easier lookup and thus also teardowns in tests - Conslidated DebugLogTrait into new QueueTestTrait - Bump min phpunit to 10.5.32 --- composer.json | 2 +- src/QueueManager.php | 10 ++ tests/TestCase/Command/RequeueCommandTest.php | 4 +- tests/TestCase/Command/WorkerCommandTest.php | 57 ++++----- .../LimitAttemptsExtensionTest.php | 14 +-- ...emoveUniqueJobIdFromCacheExtensionTest.php | 18 +-- tests/TestCase/DebugLogTrait.php | 36 ------ .../Listener/FailedJobsListenerTest.php | 14 +-- tests/TestCase/Mailer/QueueTraitTest.php | 8 +- .../Mailer/Transport/QueueTransportTest.php | 3 + tests/TestCase/Queue/ProcessorTest.php | 7 +- tests/TestCase/QueueManagerTest.php | 3 +- tests/TestCase/QueueTestTrait.php | 116 ++++++++++++++++++ 13 files changed, 177 insertions(+), 115 deletions(-) delete mode 100644 tests/TestCase/DebugLogTrait.php create mode 100644 tests/TestCase/QueueTestTrait.php diff --git a/composer.json b/composer.json index 2d8bfe5..13f8fe7 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "cakephp/bake": "^3.0.0", "cakephp/cakephp-codesniffer": "^5.0", "enqueue/fs": "^0.10", - "phpunit/phpunit": "^10.1.0 || ^11.1.3 || ^12.0.9" + "phpunit/phpunit": "^10.5.32 || ^11.1.3 || ^12.0.9" }, "suggest": { "cakephp/bake": "Required if you want to generate jobs.", diff --git a/src/QueueManager.php b/src/QueueManager.php index aac4739..32cd914 100644 --- a/src/QueueManager.php +++ b/src/QueueManager.php @@ -147,6 +147,16 @@ public static function getConfig(string $key): mixed return static::$_config[$key] ?? null; } + /** + * Get the configured queue keys. + * + * @return array List of configured queue configuration keys. + */ + public static function configured(): array + { + return array_keys(static::$_config); + } + /** * Remove a configured queue adapter. * diff --git a/tests/TestCase/Command/RequeueCommandTest.php b/tests/TestCase/Command/RequeueCommandTest.php index 51929d5..480b9e9 100644 --- a/tests/TestCase/Command/RequeueCommandTest.php +++ b/tests/TestCase/Command/RequeueCommandTest.php @@ -20,7 +20,7 @@ use Cake\Core\Configure; use Cake\Log\Log; use Cake\Queue\QueueManager; -use Cake\Queue\Test\TestCase\DebugLogTrait; +use Cake\Queue\Test\TestCase\QueueTestTrait; use Cake\TestSuite\TestCase; use TestApp\Job\LogToDebugJob; @@ -32,7 +32,7 @@ class RequeueCommandTest extends TestCase { use ConsoleIntegrationTestTrait; - use DebugLogTrait; + use QueueTestTrait; protected array $fixtures = [ 'plugin.Cake/Queue.FailedJobs', diff --git a/tests/TestCase/Command/WorkerCommandTest.php b/tests/TestCase/Command/WorkerCommandTest.php index 5eb1623..297fca6 100644 --- a/tests/TestCase/Command/WorkerCommandTest.php +++ b/tests/TestCase/Command/WorkerCommandTest.php @@ -14,6 +14,7 @@ * @since 0.1.0 * @license https://opensource.org/licenses/MIT MIT License */ + namespace Cake\Queue\Test\TestCase\Command; use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; @@ -22,8 +23,10 @@ use Cake\Queue\QueueManager; use Cake\Queue\Test\test_app\src\Job\LogToDebugWithServiceJob; use Cake\Queue\Test\test_app\src\Queue\TestCustomProcessor; -use Cake\Queue\Test\TestCase\DebugLogTrait; +use Cake\Queue\Test\TestCase\QueueTestTrait; use Cake\TestSuite\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use TestApp\Job\LogToDebugJob; use TestApp\Job\RequeueJob; use TestApp\WelcomeMailerListener; @@ -36,7 +39,7 @@ class WorkerCommandTest extends TestCase { use ConsoleIntegrationTestTrait; - use DebugLogTrait; + use QueueTestTrait; /** * Test that command description prints out @@ -49,9 +52,8 @@ public function testDescriptionOutput() /** * Test that queue will run for one second - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueProcessesStart() { Configure::write('Queue', [ @@ -66,9 +68,8 @@ public function testQueueProcessesStart() /** * Test that queue will run for one second with valid listener - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueProcessesWithListener() { Configure::write('Queue', [ @@ -84,9 +85,8 @@ public function testQueueProcessesWithListener() /** * Test that queue will abort when the passed config is not present in the app configuration. - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueWillAbortWithMissingConfig() { Configure::write('Queue', [ @@ -103,9 +103,8 @@ public function testQueueWillAbortWithMissingConfig() /** * Test that queue will abort with invalid listener - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueProcessesWithInvalidListener() { Configure::write('Queue', [ @@ -122,9 +121,8 @@ public function testQueueProcessesWithInvalidListener() /** * Test that queue will write to specified logger option - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueProcessesWithLogger() { Configure::write('Queue', [ @@ -157,10 +155,9 @@ public static function dataProviderCallableTypes(): array /** * Start up the worker queue, push a job, and see that it processes - * - * @dataProvider dataProviderCallableTypes - * @runInSeparateProcess */ + #[RunInSeparateProcess] + #[DataProvider('dataProviderCallableTypes')] public function testQueueProcessesJob($callable) { $config = [ @@ -186,9 +183,8 @@ public function testQueueProcessesJob($callable) /** * Set the processor name, Start up the worker queue, push a job, and see that it processes - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueProcessesJobWithProcessor() { $config = [ @@ -213,9 +209,8 @@ public function testQueueProcessesJobWithProcessor() /** * Test non-default queue name - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueProcessesJobWithOtherQueue() { $config = [ @@ -241,9 +236,8 @@ public function testQueueProcessesJobWithOtherQueue() /** * Test max-attempts option - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueProcessesJobWithMaxAttempts() { $config = [ @@ -269,9 +263,8 @@ public function testQueueProcessesJobWithMaxAttempts() /** * Test DI service injection works in tasks - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueProcessesJobWithDIService() { $this->skipIf(version_compare(Configure::version(), '4.2', '<'), 'DI Container is only available since CakePHP 4.2'); @@ -297,9 +290,8 @@ public function testQueueProcessesJobWithDIService() /** * Test that queue will process when a unique cache is configured. - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueProcessesWithUniqueCacheConfigured() { $config = [ @@ -324,9 +316,8 @@ public function testQueueProcessesWithUniqueCacheConfigured() /** * Test that queue uses default processor when no processor is specified. - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueUsesDefaultProcessor() { $config = [ @@ -352,9 +343,8 @@ public function testQueueUsesDefaultProcessor() /** * Test that queue uses custom processor when specified in configuration. - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueUsesCustomProcessor() { $config = [ @@ -383,9 +373,8 @@ public function testQueueUsesCustomProcessor() /** * Test that queue aborts when custom processor class does not exist. - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueAbortsWithNonExistentProcessor() { $config = [ @@ -403,9 +392,8 @@ public function testQueueAbortsWithNonExistentProcessor() /** * Test that queue aborts when custom processor does not implement Interop\Queue\Processor. - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testQueueAbortsWithInvalidProcessor() { $config = [ @@ -423,9 +411,8 @@ public function testQueueAbortsWithInvalidProcessor() /** * Test that custom processor works with listener configuration. - * - * @runInSeparateProcess */ + #[RunInSeparateProcess] public function testCustomProcessorWithListener() { $config = [ diff --git a/tests/TestCase/Consumption/LimitAttemptsExtensionTest.php b/tests/TestCase/Consumption/LimitAttemptsExtensionTest.php index 7094d47..70ec9ab 100644 --- a/tests/TestCase/Consumption/LimitAttemptsExtensionTest.php +++ b/tests/TestCase/Consumption/LimitAttemptsExtensionTest.php @@ -10,7 +10,7 @@ use Cake\Queue\Consumption\LimitConsumedMessagesExtension; use Cake\Queue\Queue\Processor as QueueProcessor; use Cake\Queue\QueueManager; -use Cake\Queue\Test\TestCase\DebugLogTrait; +use Cake\Queue\Test\TestCase\QueueTestTrait; use Cake\TestSuite\TestCase; use Enqueue\Consumption\ChainExtension; use Psr\Log\NullLogger; @@ -19,7 +19,7 @@ class LimitAttemptsExtensionTest extends TestCase { - use DebugLogTrait; + use QueueTestTrait; public function setUp(): void { @@ -28,16 +28,6 @@ public function setUp(): void EventManager::instance()->setEventList(new EventList()); } - /** - * @beforeClass - * @after - */ - public static function dropConfigs() - { - Log::drop('debug'); - QueueManager::drop('default'); - } - public function testMessageShouldBeRequeuedIfMaxAttemptsIsNotSet() { $consume = $this->setupQueue(); diff --git a/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php b/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php index 9d09e2a..ff8be08 100644 --- a/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php +++ b/tests/TestCase/Consumption/RemoveUniqueJobIdFromCacheExtensionTest.php @@ -9,6 +9,7 @@ use Cake\Queue\Consumption\RemoveUniqueJobIdFromCacheExtension; use Cake\Queue\Queue\Processor as QueueProcessor; use Cake\Queue\QueueManager; +use Cake\Queue\Test\TestCase\QueueTestTrait; use Cake\TestSuite\TestCase; use Enqueue\Consumption\ChainExtension; use Psr\Log\NullLogger; @@ -16,22 +17,7 @@ class RemoveUniqueJobIdFromCacheExtensionTest extends TestCase { - /** - * @beforeClass - * @after - */ - public static function dropConfigs() - { - Log::drop('debug'); - - QueueManager::drop('default'); - - $cacheKey = QueueManager::getConfig('default')['uniqueCacheKey'] ?? null; - if ($cacheKey) { - Cache::clear($cacheKey); - Cache::drop($cacheKey); - } - } + use QueueTestTrait; public function testJobIsRemovedFromCacheAfterProcessing() { diff --git a/tests/TestCase/DebugLogTrait.php b/tests/TestCase/DebugLogTrait.php deleted file mode 100644 index e50006d..0000000 --- a/tests/TestCase/DebugLogTrait.php +++ /dev/null @@ -1,36 +0,0 @@ -debugLogCount($expected); - - $this->assertGreaterThanOrEqual(1, $found, "Did not find `{$expected}` in logs."); - } - - protected function assertDebugLogContainsExactly($expected, $times): void - { - $found = $this->debugLogCount($expected); - - $this->assertSame($times, $found, "Did not find `{$expected}` exactly {$times} times in logs."); - } - - protected function debugLogCount($seach) - { - $log = Log::engine('debug'); - $found = 0; - foreach ($log->read() as $line) { - if (strpos($line, $seach) !== false) { - $found++; - } - } - - return $found; - } -} diff --git a/tests/TestCase/Listener/FailedJobsListenerTest.php b/tests/TestCase/Listener/FailedJobsListenerTest.php index 9a9a71c..ab181a0 100644 --- a/tests/TestCase/Listener/FailedJobsListenerTest.php +++ b/tests/TestCase/Listener/FailedJobsListenerTest.php @@ -14,6 +14,7 @@ * @since 0.1.0 * @license https://opensource.org/licenses/MIT MIT License */ + namespace Cake\Queue\Test\TestCase\Mailer; use Cake\Event\Event; @@ -25,15 +26,19 @@ use Cake\Queue\Listener\FailedJobsListener; use Cake\Queue\Model\Table\FailedJobsTable; use Cake\Queue\QueueManager; +use Cake\Queue\Test\TestCase\QueueTestTrait; use Cake\TestSuite\TestCase; use Enqueue\Null\NullConnectionFactory; use Enqueue\Null\NullMessage; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use stdClass; use TestApp\Job\LogToDebugJob; class FailedJobsListenerTest extends TestCase { + use QueueTestTrait; + protected array $fixtures = [ 'plugin.Cake/Queue.FailedJobs', ]; @@ -48,13 +53,6 @@ public function setUp(): void ]); } - public function tearDown(): void - { - parent::tearDown(); - - QueueManager::drop('example_config'); - } - public function testFailedJobIsAddedWhenEventIsFired() { $parsedBody = [ @@ -113,9 +111,9 @@ public static function storeFailedJobExceptionDataProvider() } /** - * @dataProvider storeFailedJobExceptionDataProvider * @return void */ + #[DataProvider('storeFailedJobExceptionDataProvider')] public function testStoreFailedJobException($eventData, $exceptionMessage) { $tableLocator = $this diff --git a/tests/TestCase/Mailer/QueueTraitTest.php b/tests/TestCase/Mailer/QueueTraitTest.php index 93d067b..df5f241 100644 --- a/tests/TestCase/Mailer/QueueTraitTest.php +++ b/tests/TestCase/Mailer/QueueTraitTest.php @@ -18,11 +18,15 @@ use Cake\Mailer\Exception\MissingActionException; use Cake\Queue\QueueManager; +use Cake\Queue\Test\TestCase\QueueTestTrait; use Cake\TestSuite\TestCase; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use TestApp\WelcomeMailer; class QueueTraitTest extends TestCase { + use QueueTestTrait; + /** * Test that a MissingActionException is being thrown when * the push action is not found on the object with the QueueTrait @@ -38,10 +42,8 @@ public function testQueueTraitTestThrowsMissingActionException() /** * Test that QueueTrait calls push - * - * @runInSeparateProcess - * @return @void */ + #[RunInSeparateProcess] public function testQueueTraitCallsPush() { $queue = new WelcomeMailer(); diff --git a/tests/TestCase/Mailer/Transport/QueueTransportTest.php b/tests/TestCase/Mailer/Transport/QueueTransportTest.php index 61ff9a5..7da8041 100644 --- a/tests/TestCase/Mailer/Transport/QueueTransportTest.php +++ b/tests/TestCase/Mailer/Transport/QueueTransportTest.php @@ -20,10 +20,13 @@ use Cake\Mailer\Transport\SmtpTransport; use Cake\Queue\Mailer\Transport\QueueTransport; use Cake\Queue\QueueManager; +use Cake\Queue\Test\TestCase\QueueTestTrait; use Cake\TestSuite\TestCase; class QueueTransportTest extends TestCase { + use QueueTestTrait; + private $fsQueuePath = TMP . DS . 'queue'; private function getFsQueueUrl(): string diff --git a/tests/TestCase/Queue/ProcessorTest.php b/tests/TestCase/Queue/ProcessorTest.php index 0da6b9c..c2eccf9 100644 --- a/tests/TestCase/Queue/ProcessorTest.php +++ b/tests/TestCase/Queue/ProcessorTest.php @@ -14,6 +14,7 @@ * @since 0.1.0 * @license https://opensource.org/licenses/MIT MIT License */ + namespace Cake\Queue\Test\TestCase\Queue; use Cake\Event\EventList; @@ -21,14 +22,18 @@ use Cake\Log\Log; use Cake\Queue\Job\Message; use Cake\Queue\Queue\Processor; +use Cake\Queue\Test\TestCase\QueueTestTrait; use Cake\TestSuite\TestCase; use Enqueue\Null\NullConnectionFactory; use Enqueue\Null\NullMessage; use Interop\Queue\Processor as InteropProcessor; +use PHPUnit\Framework\Attributes\DataProvider; use TestApp\TestProcessor; class ProcessorTest extends TestCase { + use QueueTestTrait; + public static $lastProcessMessage; /** @@ -54,9 +59,9 @@ public static function dataProviderTestProcess(): array * @param string $expected The expected process result. * @param string $logMessage The log message based on process result. * @param string $dispacthedEvent The dispatched event based on process result. - * @dataProvider dataProviderTestProcess * @return void */ + #[DataProvider('dataProviderTestProcess')] public function testProcess($jobMethod, $expected, $logMessage, $dispatchedEvent) { $messageBody = [ diff --git a/tests/TestCase/QueueManagerTest.php b/tests/TestCase/QueueManagerTest.php index ba3bdc6..04576a0 100644 --- a/tests/TestCase/QueueManagerTest.php +++ b/tests/TestCase/QueueManagerTest.php @@ -14,6 +14,7 @@ * @since 0.1.0 * @license https://www.opensource.org/licenses/mit-license.php MIT License */ + namespace Cake\Queue\Test\TestCase; use BadMethodCallException; @@ -32,7 +33,7 @@ */ class QueueManagerTest extends TestCase { - use DebugLogTrait; + use QueueTestTrait; private $fsQueuePath = TMP . DS . 'queue'; diff --git a/tests/TestCase/QueueTestTrait.php b/tests/TestCase/QueueTestTrait.php new file mode 100644 index 0000000..2f697eb --- /dev/null +++ b/tests/TestCase/QueueTestTrait.php @@ -0,0 +1,116 @@ + + */ +trait QueueTestTrait +{ + /** + * Clean up QueueManager, Cache, and Log configurations after each test + * + * This is automatically called after each test via the #[After] attribute. + * It drops all QueueManager configs and their associated cache configs, + * and resets all log configurations. + * + * @return void + */ + #[After] + public function cleanupQueueManagerConfigs(): void + { + // Drop all QueueManager configurations and their associated caches + foreach (QueueManager::configured() as $config) { + // Drop associated cache config if it exists + $queueConfig = QueueManager::getConfig($config); + if ($queueConfig && isset($queueConfig['uniqueCacheKey'])) { + $cacheKey = $queueConfig['uniqueCacheKey']; + if (Cache::configured($cacheKey)) { + Cache::drop($cacheKey); + } + } + + QueueManager::drop($config); + } + + // Reset log configurations + Log::reset(); + } + + /** + * Assert that a message was found in debug logs + * + * @param string $expected The message to search for in logs + * @return void + */ + protected function assertDebugLogContains($expected): void + { + $found = $this->debugLogCount($expected); + + $this->assertGreaterThanOrEqual(1, $found, "Did not find `{$expected}` in logs."); + } + + /** + * Assert that a message was found exactly N times in debug logs + * + * @param string $expected The message to search for in logs + * @param int $times The exact number of times the message should appear + * @return void + */ + protected function assertDebugLogContainsExactly($expected, $times): void + { + $found = $this->debugLogCount($expected); + + $this->assertSame($times, $found, "Did not find `{$expected}` exactly {$times} times in logs."); + } + + /** + * Count occurrences of a message in debug logs + * + * @param string $search The message to search for + * @return int The number of times the message was found + */ + protected function debugLogCount($search) + { + $log = Log::engine('debug'); + $found = 0; + foreach ($log->read() as $line) { + if (strpos($line, $search) !== false) { + $found++; + } + } + + return $found; + } +} From 6ca40e1390c348e1a54e7574d0e36448aefe8361 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Wed, 22 Oct 2025 13:56:18 +0200 Subject: [PATCH 68/90] add twig-view constraint: fixes twig complitation errors on twig <= 3.4.0 --- composer.json | 1 + templates/bake/job.twig | 20 ++++++++++---------- tests/bootstrap.php | 6 +++++- tests/comparisons/JobTaskWithMaxAttempts.php | 2 +- tests/comparisons/JobTaskWithUnique.php | 2 +- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/composer.json b/composer.json index 13f8fe7..b7a7a3a 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "psr/log": "^3.0" }, "require-dev": { + "cakephp/twig-view": "^2.0.2", "cakephp/bake": "^3.0.0", "cakephp/cakephp-codesniffer": "^5.0", "enqueue/fs": "^0.10", diff --git a/templates/bake/job.twig b/templates/bake/job.twig index 3a4705b..7dc6873 100644 --- a/templates/bake/job.twig +++ b/templates/bake/job.twig @@ -13,14 +13,14 @@ * @license https://opensource.org/licenses/MIT MIT License */ #} - [ + $cache_key => [ 'engine' => 'File', 'prefix' => '_cake_translations_', 'serialize' => true, diff --git a/tests/comparisons/JobTaskWithMaxAttempts.php b/tests/comparisons/JobTaskWithMaxAttempts.php index 99c4f4e..d8f4c91 100644 --- a/tests/comparisons/JobTaskWithMaxAttempts.php +++ b/tests/comparisons/JobTaskWithMaxAttempts.php @@ -14,7 +14,7 @@ class UploadJob implements JobInterface { /** * The maximum number of times the job may be attempted. - * + * * @var int|null */ public static $maxAttempts = 3; diff --git a/tests/comparisons/JobTaskWithUnique.php b/tests/comparisons/JobTaskWithUnique.php index cae4020..f0faf36 100644 --- a/tests/comparisons/JobTaskWithUnique.php +++ b/tests/comparisons/JobTaskWithUnique.php @@ -14,7 +14,7 @@ class UploadJob implements JobInterface { /** * Whether there should be only one instance of a job on the queue at a time. (optional property) - * + * * @var bool */ public static $shouldBeUnique = true; From 4f91cce3f2100e47eecc485d9631dd383eba96d2 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Thu, 23 Oct 2025 10:06:00 +0200 Subject: [PATCH 69/90] Bump bake requirement to to 3.5.1 --- composer.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index b7a7a3a..5557843 100644 --- a/composer.json +++ b/composer.json @@ -27,11 +27,10 @@ "psr/log": "^3.0" }, "require-dev": { - "cakephp/twig-view": "^2.0.2", - "cakephp/bake": "^3.0.0", + "cakephp/bake": "^3.5.1", "cakephp/cakephp-codesniffer": "^5.0", "enqueue/fs": "^0.10", - "phpunit/phpunit": "^10.5.32 || ^11.1.3 || ^12.0.9" + "phpunit/phpunit": "^10.5.32 || ^11.3.3 || ^12.0.9" }, "suggest": { "cakephp/bake": "Required if you want to generate jobs.", From 1f174887532958ac27289e8dc8aa3cb269f23bb2 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Thu, 23 Oct 2025 10:19:19 +0200 Subject: [PATCH 70/90] Clean up docblock --- tests/TestCase/QueueTestTrait.php | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/TestCase/QueueTestTrait.php b/tests/TestCase/QueueTestTrait.php index 2f697eb..c056223 100644 --- a/tests/TestCase/QueueTestTrait.php +++ b/tests/TestCase/QueueTestTrait.php @@ -28,13 +28,6 @@ * Provides: * - Configuration cleanup for QueueManager, Cache, and Log * - Debug log assertion helpers - * - * Usage in your test class: - * - * ```php - * use QueueTestTrait; - * ``` - */ trait QueueTestTrait { From 8c31f735f059e220ecc7db2e7e09224ab1322a1c Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Thu, 23 Oct 2025 10:40:06 +0200 Subject: [PATCH 71/90] Bump cake to 5.1.0 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 5557843..c9d1609 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ }, "require": { "php": ">=8.1", - "cakephp/cakephp": "^5.0.0", + "cakephp/cakephp": "^5.1.0", "enqueue/simple-client": "^0.10", "psr/log": "^3.0" }, From 059365b13246c4139ea5db49b36ba693c60b3b8d Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Tue, 4 Nov 2025 07:44:20 +0100 Subject: [PATCH 72/90] Cleaning things up (#175) * Cleanup up - Use constructor promotion where possible - Use readonly properties where possible - Add Rectoring - Update some - Use spread operator wher possible - Use strict comparisons where possible - Use null coalescing operator where possible * Remove ReturnTypeWillChange attribute Restore check * Re-introduce @return in dockblocks * Fix rector rule --- composer.json | 3 ++ rector.php | 35 ++++++++++++++ src/Command/PurgeFailedCommand.php | 16 ++++--- src/Command/RequeueCommand.php | 26 +++++----- src/Command/WorkerCommand.php | 22 ++++----- src/Consumption/LimitAttemptsExtension.php | 16 ++----- .../LimitConsumedMessagesExtension.php | 15 ++---- .../RemoveUniqueJobIdFromCacheExtension.php | 13 ++--- src/Job/MailerJob.php | 4 +- src/Job/Message.php | 38 ++++++++------- src/Job/SendMailJob.php | 7 +-- src/Listener/FailedJobsListener.php | 8 ++-- src/Mailer/QueueTrait.php | 4 +- src/Mailer/Transport/QueueTransport.php | 6 +-- src/Model/Entity/FailedJob.php | 2 +- src/Plugin.php | 3 +- src/Queue/Processor.php | 30 ++++-------- src/QueueManager.php | 47 ++++++++++--------- .../Command/PurgeFailedCommandTest.php | 5 +- tests/TestCase/Command/RequeueCommandTest.php | 5 +- tests/TestCase/Command/WorkerCommandTest.php | 2 +- tests/TestCase/Job/MailerJobTest.php | 9 ++-- tests/TestCase/Job/MessageTest.php | 9 ++-- tests/TestCase/Job/SendMailJobTest.php | 3 +- .../Listener/FailedJobsListenerTest.php | 3 ++ tests/TestCase/Queue/ProcessorTest.php | 5 +- tests/TestCase/QueueTestTrait.php | 8 ++-- tests/TestCase/Task/JobTaskTest.php | 2 - tests/bootstrap.php | 4 +- .../src/Queue/TestCustomProcessor.php | 14 ++---- tests/test_app/src/TestProcessor.php | 2 +- tests/test_app/src/WelcomeMailerListener.php | 2 +- 32 files changed, 194 insertions(+), 174 deletions(-) create mode 100644 rector.php diff --git a/composer.json b/composer.json index c9d1609..5a5934f 100644 --- a/composer.json +++ b/composer.json @@ -66,6 +66,9 @@ "phpstan": "tools/phpstan analyse", "stan-baseline": "tools/phpstan --generate-baseline", "stan-setup": "phive install", + "rector-setup": "cp composer.json composer.backup && composer require --dev rector/rector:\"^2.2\" && mv composer.backup composer.json", + "rector-check": "vendor/bin/rector process --dry-run", + "rector-fix": "vendor/bin/rector process", "test": "phpunit", "test-coverage": "phpunit --coverage-clover=clover.xml" } diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..330dc43 --- /dev/null +++ b/rector.php @@ -0,0 +1,35 @@ +withPhpVersion(PhpVersion::PHP_82) + ->withPaths([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + ->withSkip([ + DisallowedEmptyRuleFixerRector::class, + SimplifyIfElseToTernaryRector::class, + MakeInheritedMethodVisibilitySameAsParentRector::class, + RemoveNullTagValueNodeRector::class, + RemoveUselessReturnTagRector::class, + DocblockReturnArrayFromDirectArrayInstanceRector::class => [ + __DIR__ . '/src/Mailer/Transport/QueueTransport.php', + ], + ]) + ->withParallel() + ->withPreparedSets( + deadCode: true, + codeQuality: true, + codingStyle: true, + typeDeclarationDocblocks: true, + ); diff --git a/src/Command/PurgeFailedCommand.php b/src/Command/PurgeFailedCommand.php index f2c0d3a..c54cc8d 100644 --- a/src/Command/PurgeFailedCommand.php +++ b/src/Command/PurgeFailedCommand.php @@ -72,9 +72,9 @@ public function getOptionParser(): ConsoleOptionParser /** * @param \Cake\Console\Arguments $args Arguments * @param \Cake\Console\ConsoleIo $io ConsoleIo - * @return void + * @return int */ - public function execute(Arguments $args, ConsoleIo $io): void + public function execute(Arguments $args, ConsoleIo $io): int { /** @var \Cake\Queue\Model\Table\FailedJobsTable $failedJobsTable */ $failedJobsTable = $this->getTableLocator()->get('Cake/Queue.FailedJobs'); @@ -108,21 +108,23 @@ public function execute(Arguments $args, ConsoleIo $io): void if (!$deletingCount) { $io->out('0 jobs found.'); - return; + return self::CODE_SUCCESS; } if (!$args->getOption('force')) { - $confirmed = $io->askChoice("Delete {$deletingCount} jobs?", ['y', 'n'], 'n'); + $confirmed = $io->askChoice(sprintf('Delete %s jobs?', $deletingCount), ['y', 'n'], 'n'); if ($confirmed !== 'y') { - return; + return self::CODE_SUCCESS; } } - $io->out("Deleting {$deletingCount} jobs."); + $io->out(sprintf('Deleting %s jobs.', $deletingCount)); $failedJobsTable->deleteManyOrFail($jobsToDelete); - $io->success("{$deletingCount} jobs deleted."); + $io->success($deletingCount . ' jobs deleted.'); + + return self::CODE_SUCCESS; } } diff --git a/src/Command/RequeueCommand.php b/src/Command/RequeueCommand.php index b9efd02..ce00f7c 100644 --- a/src/Command/RequeueCommand.php +++ b/src/Command/RequeueCommand.php @@ -74,9 +74,9 @@ public function getOptionParser(): ConsoleOptionParser /** * @param \Cake\Console\Arguments $args Arguments * @param \Cake\Console\ConsoleIo $io ConsoleIo - * @return void + * @return int */ - public function execute(Arguments $args, ConsoleIo $io): void + public function execute(Arguments $args, ConsoleIo $io): int { /** @var \Cake\Queue\Model\Table\FailedJobsTable $failedJobsTable */ $failedJobsTable = $this->getTableLocator()->get('Cake/Queue.FailedJobs'); @@ -110,18 +110,18 @@ public function execute(Arguments $args, ConsoleIo $io): void if (!$requeueingCount) { $io->out('0 jobs found.'); - return; + return self::CODE_SUCCESS; } if (!$args->getOption('force')) { - $confirmed = $io->askChoice("Requeue {$requeueingCount} jobs?", ['y', 'n'], 'n'); + $confirmed = $io->askChoice(sprintf('Requeue %s jobs?', $requeueingCount), ['y', 'n'], 'n'); if ($confirmed !== 'y') { - return; + return self::CODE_SUCCESS; } } - $io->out("Requeueing {$requeueingCount} jobs."); + $io->out(sprintf('Requeueing %s jobs.', $requeueingCount)); $succeededCount = 0; $failedCount = 0; @@ -129,7 +129,7 @@ public function execute(Arguments $args, ConsoleIo $io): void /** @var array<\Cake\Queue\Model\Entity\FailedJob> $jobsToRequeue */ $jobsToRequeue = $jobsToRequeueQuery->all(); foreach ($jobsToRequeue as $failedJob) { - $io->verbose("Requeueing FailedJob with ID {$failedJob->id}."); + $io->verbose(sprintf('Requeueing FailedJob with ID %d.', $failedJob->id)); try { QueueManager::push( [$failedJob->class, $failedJob->method], @@ -145,19 +145,21 @@ public function execute(Arguments $args, ConsoleIo $io): void $succeededCount++; } catch (Exception $e) { - $io->err("Exception occurred while requeueing FailedJob with ID {$failedJob->id}"); + $io->err('Exception occurred while requeueing FailedJob with ID ' . $failedJob->id); $io->err((string)$e); $failedCount++; } } - if ($failedCount) { - $io->err("Failed to requeue {$failedCount} jobs."); + if ($failedCount !== 0) { + $io->err(sprintf('Failed to requeue %d jobs.', $failedCount)); } - if ($succeededCount) { - $io->success("{$succeededCount} jobs requeued."); + if ($succeededCount !== 0) { + $io->success($succeededCount . ' jobs requeued.'); } + + return self::CODE_SUCCESS; } } diff --git a/src/Command/WorkerCommand.php b/src/Command/WorkerCommand.php index e755b34..44605d1 100644 --- a/src/Command/WorkerCommand.php +++ b/src/Command/WorkerCommand.php @@ -43,17 +43,12 @@ */ class WorkerCommand extends Command { - /** - * @var \Cake\Core\ContainerInterface|null - */ - protected ?ContainerInterface $container = null; - /** * @param \Cake\Core\ContainerInterface|null $container DI container instance */ - public function __construct(?ContainerInterface $container = null) - { - $this->container = $container; + public function __construct( + protected readonly ?ContainerInterface $container = null, + ) { } /** @@ -138,12 +133,12 @@ protected function getQueueExtension(Arguments $args, LoggerInterface $logger): $limitAttempsExtension, ]; - if (!is_null($args->getOption('max-jobs'))) { + if ($args->getOption('max-jobs') !== null) { $maxJobs = (int)$args->getOption('max-jobs'); $extensions[] = new LimitConsumedMessagesExtension($maxJobs); } - if (!is_null($args->getOption('max-runtime'))) { + if ($args->getOption('max-runtime') !== null) { $endTime = new DateTime(sprintf('+%d seconds', (int)$args->getOption('max-runtime'))); $extensions[] = new LimitConsumptionTimeExtension($endTime); } @@ -187,12 +182,12 @@ protected function getProcessor(Arguments $args, ConsoleIo $io, LoggerInterface $processorClass = $config['processor'] ?? Processor::class; if (!class_exists($processorClass)) { - $io->error(sprintf(sprintf('Processor class %s not found', $processorClass))); + $io->error(sprintf('Processor class %s not found', $processorClass)); $this->abort(); } if (!is_subclass_of($processorClass, InteropProcessor::class)) { - $io->error(sprintf(sprintf('Processor class %s must implement Interop\Queue\Processor', $processorClass))); + $io->error(sprintf('Processor class %s must implement Interop\Queue\Processor', $processorClass)); $this->abort(); } @@ -231,10 +226,11 @@ public function execute(Arguments $args, ConsoleIo $io): int $processor->getEventManager()->on($listener); } } + $client = QueueManager::engine($config); $queue = $args->getOption('queue') ? (string)$args->getOption('queue') - : Configure::read("Queue.{$config}.queue", 'default'); + : Configure::read(sprintf('Queue.%s.queue', $config), 'default'); $processorName = $args->getOption('processor') ? (string)$args->getOption('processor') : 'default'; $client->bindTopic($queue, $processor, $processorName); diff --git a/src/Consumption/LimitAttemptsExtension.php b/src/Consumption/LimitAttemptsExtension.php index c3a0af6..0a87881 100644 --- a/src/Consumption/LimitAttemptsExtension.php +++ b/src/Consumption/LimitAttemptsExtension.php @@ -25,20 +25,12 @@ class LimitAttemptsExtension implements MessageResultExtensionInterface public const ATTEMPTS_PROPERTY = 'attempts'; /** - * The maximum number of times a job may be attempted. $maxAttempts defined on a - * Job will override this value. - * - * @var int|null - */ - protected ?int $maxAttempts = null; - - /** - * @param int|null $maxAttempts The maximum number of times a job may be attempted. + * @param int|null $maxAttempts The maximum number of times a job may be attempted. $maxAttempts defined on a Job will override this value. * @return void */ - public function __construct(?int $maxAttempts = null) - { - $this->maxAttempts = $maxAttempts; + public function __construct( + protected readonly ?int $maxAttempts = null, + ) { } /** diff --git a/src/Consumption/LimitConsumedMessagesExtension.php b/src/Consumption/LimitConsumedMessagesExtension.php index e202fbe..c5c7bd8 100644 --- a/src/Consumption/LimitConsumedMessagesExtension.php +++ b/src/Consumption/LimitConsumedMessagesExtension.php @@ -18,22 +18,15 @@ */ class LimitConsumedMessagesExtension implements PreConsumeExtensionInterface, PostConsumeExtensionInterface { - /** - * @var int - */ - protected int $messageLimit; - - /** - * @var int - */ protected int $messageConsumed = 0; /** * @param int $messageLimit The number of messages to process before exiting. + * @return void */ - public function __construct(int $messageLimit) - { - $this->messageLimit = $messageLimit; + public function __construct( + protected readonly int $messageLimit, + ) { } /** diff --git a/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php b/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php index 74ff3ad..8ad1fc6 100644 --- a/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php +++ b/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php @@ -11,20 +11,13 @@ class RemoveUniqueJobIdFromCacheExtension implements MessageResultExtensionInterface { - /** - * Cache engine name. - * - * @var string - */ - protected string $cache; - /** * @param string $cache Cache engine name. * @return void */ - public function __construct(string $cache) - { - $this->cache = $cache; + public function __construct( + protected readonly string $cache, + ) { } /** diff --git a/src/Job/MailerJob.php b/src/Job/MailerJob.php index 66db133..3d6d71e 100644 --- a/src/Job/MailerJob.php +++ b/src/Job/MailerJob.php @@ -41,13 +41,13 @@ public function execute(Message $message): ?string try { $mailer = $this->getMailer($mailerName, $mailerConfig); - } catch (MissingMailerException $e) { + } catch (MissingMailerException $missingMailerException) { return Processor::REJECT; } try { $mailer->send($action, $args, $headers); - } catch (BadMethodCallException $e) { + } catch (BadMethodCallException $badMethodCallException) { return Processor::REJECT; } diff --git a/src/Job/Message.php b/src/Job/Message.php index 7da6218..f1b504c 100644 --- a/src/Job/Message.php +++ b/src/Job/Message.php @@ -22,35 +22,33 @@ use Interop\Queue\Context; use Interop\Queue\Message as QueueMessage; use JsonSerializable; -use ReturnTypeWillChange; use RuntimeException; class Message implements JsonSerializable { - protected Context $context; - - protected QueueMessage $originalMessage; - + /** + * @var array + */ protected array $parsedBody; protected ?Closure $callable = null; - protected ?ContainerInterface $container = null; - /** * @param \Interop\Queue\Message $originalMessage Queue message. * @param \Interop\Queue\Context $context Context. * @param \Cake\Core\ContainerInterface|null $container DI container instance */ - public function __construct(QueueMessage $originalMessage, Context $context, ?ContainerInterface $container = null) - { - $this->context = $context; - $this->originalMessage = $originalMessage; + public function __construct( + protected readonly QueueMessage $originalMessage, + protected readonly Context $context, + protected readonly ?ContainerInterface $container = null, + ) { $this->parsedBody = json_decode($originalMessage->getBody(), true); - $this->container = $container; } /** + * Get the queue context. + * * @return \Interop\Queue\Context */ public function getContext(): Context @@ -59,6 +57,8 @@ public function getContext(): Context } /** + * Get the original queue message. + * * @return \Interop\Queue\Message */ public function getOriginalMessage(): QueueMessage @@ -67,7 +67,9 @@ public function getOriginalMessage(): QueueMessage } /** - * @return array + * Get the parsed message body. + * + * @return array */ public function getParsedBody(): array { @@ -84,7 +86,7 @@ public function getParsedBody(): array */ public function getCallable(): Closure { - if ($this->callable) { + if ($this->callable instanceof Closure) { return $this->callable; } @@ -128,6 +130,7 @@ public function getTarget(): array */ public function getArgument(mixed $key = null, mixed $default = null): mixed { + // support old jobs that still use args key if (array_key_exists('data', $this->parsedBody)) { $data = $this->parsedBody['data']; } else { @@ -157,6 +160,8 @@ public function getMaxAttempts(): ?int } /** + * Convert the message to a string representation. + * * @return string */ public function __toString(): string @@ -165,9 +170,10 @@ public function __toString(): string } /** - * @return array + * Serialize the message to JSON. + * + * @return array */ - #[ReturnTypeWillChange] public function jsonSerialize(): array { return $this->parsedBody; diff --git a/src/Job/SendMailJob.php b/src/Job/SendMailJob.php index 3ba9325..efb7ea7 100644 --- a/src/Job/SendMailJob.php +++ b/src/Job/SendMailJob.php @@ -46,10 +46,11 @@ public function execute(Message $message): ?string if (!is_array($data)) { throw new InvalidArgumentException('Email Message cannot be decoded.'); } + $emailMessage->createFromArray($data); $result = $transport->send($emailMessage); - } catch (Exception $e) { - Log::error(sprintf('An error has occurred processing message: %s', $e->getMessage())); + } catch (Exception $exception) { + Log::error(sprintf('An error has occurred processing message: %s', $exception->getMessage())); } if (!$result) { @@ -64,8 +65,8 @@ public function execute(Message $message): ?string * * @param string $transportClassName Transport class name * @param array $config Transport config - * @return \Cake\Mailer\AbstractTransport * @throws \InvalidArgumentException if empty transport class name, class does not exist or send method is not defined for class + * @return \Cake\Mailer\AbstractTransport */ protected function getTransport(string $transportClassName, array $config): AbstractTransport { diff --git a/src/Listener/FailedJobsListener.php b/src/Listener/FailedJobsListener.php index f60d15e..c983fb4 100644 --- a/src/Listener/FailedJobsListener.php +++ b/src/Listener/FailedJobsListener.php @@ -76,14 +76,14 @@ public function storeFailedJob(object $event): void try { $failedJobsTable->saveOrFail($failedJob); /** @phpstan-ignore-next-line */ - } catch (PersistenceFailedException $e) { + } catch (PersistenceFailedException $persistenceFailedException) { $logger = $event->getData('logger'); if (!$logger) { throw new RuntimeException( sprintf('`logger` was not defined on %s event.', $event->getName()), 0, - $e, + $persistenceFailedException, ); } @@ -91,11 +91,11 @@ public function storeFailedJob(object $event): void throw new RuntimeException( sprintf('`logger` is not an instance of `LoggerInterface` on %s event.', $event->getName()), 0, - $e, + $persistenceFailedException, ); } - $logger->error((string)$e); + $logger->error((string)$persistenceFailedException); } } } diff --git a/src/Mailer/QueueTrait.php b/src/Mailer/QueueTrait.php index b9a4a5d..fe94110 100644 --- a/src/Mailer/QueueTrait.php +++ b/src/Mailer/QueueTrait.php @@ -31,9 +31,9 @@ trait QueueTrait * @param string $action The name of the mailer action to trigger. * @param array $args Arguments to pass to the triggered mailer action. * @param array $headers Headers to set. - * @param array $options an array of options for publishing the job - * @return void + * @param array $options an array of options for publishing the job * @throws \Cake\Mailer\Exception\MissingActionException + * @return void */ public function push(string $action, array $args = [], array $headers = [], array $options = []): void { diff --git a/src/Mailer/Transport/QueueTransport.php b/src/Mailer/Transport/QueueTransport.php index c798d53..da45783 100644 --- a/src/Mailer/Transport/QueueTransport.php +++ b/src/Mailer/Transport/QueueTransport.php @@ -63,8 +63,8 @@ public function send(Message $message): array /** * Add job to queue * - * @param array $data Data to be sent to job - * @param array $options Job options + * @param array $data Data to be sent to job + * @param array $options Job options * @return void */ protected function enqueueJob(array $data, array $options): void @@ -80,7 +80,7 @@ protected function enqueueJob(array $data, array $options): void * Prepare data for job * * @param \Cake\Mailer\Message $message Email message - * @return array + * @return array */ protected function prepareData(Message $message): array { diff --git a/src/Model/Entity/FailedJob.php b/src/Model/Entity/FailedJob.php index a0e0c7a..62f1fc4 100644 --- a/src/Model/Entity/FailedJob.php +++ b/src/Model/Entity/FailedJob.php @@ -45,8 +45,8 @@ class FailedJob extends Entity ]; /** - * @return array * @see \Cake\Queue\Model\Entity\FailedJob::$decoded_data + * @return array */ protected function _getDecodedData(): array { diff --git a/src/Plugin.php b/src/Plugin.php index d607ef0..92cb0de 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -16,6 +16,7 @@ */ namespace Cake\Queue; +use Bake\Command\SimpleBakeCommand; use Cake\Console\CommandCollection; use Cake\Core\BasePlugin; use Cake\Core\Configure; @@ -72,7 +73,7 @@ public function bootstrap(PluginApplicationInterface $app): void */ public function console(CommandCollection $commands): CommandCollection { - if (class_exists('Bake\Command\SimpleBakeCommand')) { + if (class_exists(SimpleBakeCommand::class)) { $commands->add('bake job', JobCommand::class); } diff --git a/src/Queue/Processor.php b/src/Queue/Processor.php index b90601d..bff6628 100644 --- a/src/Queue/Processor.php +++ b/src/Queue/Processor.php @@ -37,25 +37,13 @@ class Processor implements InteropProcessor use EventDispatcherTrait; /** - * @var \Psr\Log\LoggerInterface - */ - protected LoggerInterface $logger; - - /** - * @var \Cake\Core\ContainerInterface|null - */ - protected ?ContainerInterface $container = null; - - /** - * Processor constructor - * - * @param \Psr\Log\LoggerInterface|null $logger Logger instance. + * @param \Psr\Log\LoggerInterface $logger Logger instance. * @param \Cake\Core\ContainerInterface|null $container DI container instance */ - public function __construct(?LoggerInterface $logger = null, ?ContainerInterface $container = null) - { - $this->logger = $logger ?: new NullLogger(); - $this->container = $container; + public function __construct( + protected readonly LoggerInterface $logger = new NullLogger(), + protected readonly ?ContainerInterface $container = null, + ) { } /** @@ -84,13 +72,13 @@ public function process(QueueMessage $message, Context $context): string|object try { $response = $this->processMessage($jobMessage); - } catch (Throwable $e) { - $message->setProperty('jobException', $e); + } catch (Throwable $throwable) { + $message->setProperty('jobException', $throwable); - $this->logger->debug(sprintf('Message encountered exception: %s', $e->getMessage())); + $this->logger->debug(sprintf('Message encountered exception: %s', $throwable->getMessage())); $this->dispatchEvent('Processor.message.exception', [ 'message' => $jobMessage, - 'exception' => $e, + 'exception' => $throwable, 'duration' => (int)((microtime(true) * 1000) - $startTime), ]); diff --git a/src/QueueManager.php b/src/QueueManager.php index 32cd914..976fd6c 100644 --- a/src/QueueManager.php +++ b/src/QueueManager.php @@ -24,20 +24,21 @@ use Enqueue\SimpleClient\SimpleClient; use InvalidArgumentException; use LogicException; +use Psr\Log\LoggerInterface; class QueueManager { /** * Configuration sets. * - * @var array + * @var array> */ protected static array $_config = []; /** * Queue clients * - * @var array + * @var array */ protected static array $_clients = []; @@ -72,8 +73,8 @@ class QueueManager * QueueManager::setConfig($arrayOfConfig); * ``` * - * @param array|string $key The name of the configuration, or an array of multiple configs. - * @param array $config An array of name => configuration data for adapter. + * @param array>|string $key The name of the configuration, or an array of multiple configs. + * @param array|null $config An array of name => configuration data for adapter. * @throws \BadMethodCallException When trying to modify an existing config. * @throws \LogicException When trying to store an invalid structured config array. * @return void @@ -84,6 +85,7 @@ public static function setConfig(string|array $key, ?array $config = null): void if (!is_array($key)) { throw new LogicException('If config is null, key must be an array.'); } + foreach ($key as $name => $settings) { static::setConfig($name, $settings); } @@ -126,9 +128,9 @@ public static function setConfig(string|array $key, ?array $config = null): void 'duration' => '+24 hours', ]; - $cacheConfig = array_merge($cacheDefaults, $config['uniqueCache']); + $cacheConfig = [...$cacheDefaults, ...$config['uniqueCache']]; - $config['uniqueCacheKey'] = "Cake/Queue.queueUnique.{$key}"; + $config['uniqueCacheKey'] = 'Cake/Queue.queueUnique.' . $key; Cache::setConfig($config['uniqueCacheKey'], $cacheConfig); } @@ -140,9 +142,9 @@ public static function setConfig(string|array $key, ?array $config = null): void * Reads existing configuration. * * @param string $key The name of the configuration. - * @return mixed Configuration data at the named key or null if the key does not exist. + * @return array|null Configuration data at the named key or null if the key does not exist. */ - public static function getConfig(string $key): mixed + public static function getConfig(string $key): ?array { return static::$_config[$key] ?? null; } @@ -150,7 +152,7 @@ public static function getConfig(string $key): mixed /** * Get the configured queue keys. * - * @return array List of configured queue configuration keys. + * @return array List of configured queue configuration keys. */ public static function configured(): array { @@ -190,7 +192,7 @@ public static function engine(string $name): SimpleClient static::$_clients[$name] = new SimpleClient($config['url'], $logger); static::$_clients[$name]->setupBroker(); - if (!is_null($config['receiveTimeout'])) { + if ($config['receiveTimeout'] !== null) { static::$_clients[$name]->getQueueConsumer()->setReceiveTimeout($config['receiveTimeout']); } @@ -200,11 +202,11 @@ public static function engine(string $name): SimpleClient /** * Push a single job onto the queue. * - * @param array|string $className The classname of a job that implements the + * @param array|string $className The classname of a job that implements the * \Cake\Queue\Job\JobInterface. The class will be constructed by * \Cake\Queue\Processor and have the execute method invoked. - * @param array $data An array of data that will be passed to the job. - * @param array $options An array of options for publishing the job: + * @param array $data An array of data that will be passed to the job. + * @param array $options An array of options for publishing the job: * - `config` - A queue config name. Defaults to 'default'. * - `delay` - Time (in integer seconds) to delay message, after which it * will be processed. Not all message brokers accept this. Default `null`. @@ -219,7 +221,6 @@ public static function engine(string $name): SimpleClient * - `\Enqueue\Client\MessagePriority::VERY_HIGH` * - `queue` - The name of a queue to use, from queue `config` array or * string 'default' if empty. - * @return void */ public static function push(string|array $className, array $data = [], array $options = []): void { @@ -227,7 +228,7 @@ public static function push(string|array $className, array $data = [], array $op $class = App::className($class, 'Job', 'Job'); if (is_null($class)) { - throw new InvalidArgumentException("`$class` class does not exist."); + throw new InvalidArgumentException(sprintf('`%s` class does not exist.', $class)); } $name = $options['config'] ?? 'default'; @@ -241,16 +242,19 @@ public static function push(string|array $className, array $data = [], array $op if (!empty($class::$shouldBeUnique)) { if (empty($config['uniqueCache'])) { throw new InvalidArgumentException( - "$class::\$shouldBeUnique is set to `true` but `uniqueCache` configuration is missing.", + $class . '::$shouldBeUnique is set to `true` but `uniqueCache` configuration is missing.', ); } $uniqueId = static::getUniqueId($class, $method, $data); if (Cache::read($uniqueId, $config['uniqueCacheKey'])) { - if ($logger) { + if ($logger instanceof LoggerInterface) { $logger->debug( - "An identical instance of $class already exists on the queue. This push will be ignored.", + sprintf( + 'An identical instance of %s already exists on the queue. This push will be ignored.', + $class, + ), ); } @@ -294,16 +298,15 @@ public static function push(string|array $className, array $data = [], array $op } /** - * @param string $class Class name + * @param class-string $class Class name * @param string $method Method name - * @param array $data Message data - * @return string + * @param array $data Message data */ public static function getUniqueId(string $class, string $method, array $data): string { sort($data); - $hashInput = implode([ + $hashInput = implode('', [ $class, $method, json_encode($data), diff --git a/tests/TestCase/Command/PurgeFailedCommandTest.php b/tests/TestCase/Command/PurgeFailedCommandTest.php index cf5cd6f..40af581 100644 --- a/tests/TestCase/Command/PurgeFailedCommandTest.php +++ b/tests/TestCase/Command/PurgeFailedCommandTest.php @@ -29,6 +29,9 @@ class PurgeFailedCommandTest extends TestCase { use ConsoleIntegrationTestTrait; + /** + * @var string[] + */ protected array $fixtures = [ 'plugin.Cake/Queue.FailedJobs', ]; @@ -73,7 +76,7 @@ public function testFailedJobsAreDeletedById() public function testFailedJobsAreDeletedByClass() { $class = LogToDebugJob::class; - $this->exec("queue purge_failed --class {$class} -f"); + $this->exec(sprintf('queue purge_failed --class %s -f', $class)); $this->assertOutputContains('Deleting 2 jobs.'); $this->assertOutputContains('2 jobs deleted.'); diff --git a/tests/TestCase/Command/RequeueCommandTest.php b/tests/TestCase/Command/RequeueCommandTest.php index 480b9e9..5887b51 100644 --- a/tests/TestCase/Command/RequeueCommandTest.php +++ b/tests/TestCase/Command/RequeueCommandTest.php @@ -34,6 +34,9 @@ class RequeueCommandTest extends TestCase use ConsoleIntegrationTestTrait; use QueueTestTrait; + /** + * @var string[] + */ protected array $fixtures = [ 'plugin.Cake/Queue.FailedJobs', ]; @@ -133,7 +136,7 @@ public function testJobsAreRequeuedByClass() $this->cleanupConsoleTrait(); $class = LogToDebugJob::class; - $this->exec("queue requeue --class {$class} --queue default -f"); + $this->exec(sprintf('queue requeue --class %s --queue default -f', $class)); $this->assertOutputContains('Requeueing 1 jobs.'); $this->assertOutputContains('1 jobs requeued.'); diff --git a/tests/TestCase/Command/WorkerCommandTest.php b/tests/TestCase/Command/WorkerCommandTest.php index 297fca6..f77552d 100644 --- a/tests/TestCase/Command/WorkerCommandTest.php +++ b/tests/TestCase/Command/WorkerCommandTest.php @@ -143,7 +143,7 @@ public function testQueueProcessesWithLogger() /** * Data provider for testQueueProcessesJob method * - * @return array + * @return array|string[]>> */ public static function dataProviderCallableTypes(): array { diff --git a/tests/TestCase/Job/MailerJobTest.php b/tests/TestCase/Job/MailerJobTest.php index 9a617b9..81dd8c8 100644 --- a/tests/TestCase/Job/MailerJobTest.php +++ b/tests/TestCase/Job/MailerJobTest.php @@ -32,12 +32,16 @@ class MailerJobTest extends TestCase * @var \Cake\Mailer\Mailer|\PHPUnit\Framework\MockObject\MockObject */ protected $mailer; + /** * @var \Cake\Queue\Job\MailerJob|\PHPUnit\Framework\MockObject\MockObject */ protected $job; + protected $mailerConfig; + protected $headers; + protected $args; /** @@ -139,8 +143,6 @@ public function testExecuteBadMethodCallException() /** * Create a simple message for testing. - * - * @return \Cake\Queue\Job\Message */ protected function createMessage(): Message { @@ -157,8 +159,7 @@ protected function createMessage(): Message $connectionFactory = new NullConnectionFactory(); $context = $connectionFactory->createContext(); $originalMessage = new NullMessage(json_encode($messageBody)); - $message = new Message($originalMessage, $context); - return $message; + return new Message($originalMessage, $context); } } diff --git a/tests/TestCase/Job/MessageTest.php b/tests/TestCase/Job/MessageTest.php index e828814..9d09471 100644 --- a/tests/TestCase/Job/MessageTest.php +++ b/tests/TestCase/Job/MessageTest.php @@ -23,6 +23,7 @@ use Enqueue\Null\NullMessage; use Error; use RuntimeException; +use TestApp\WelcomeMailer; class MessageTest extends TestCase { @@ -33,10 +34,10 @@ class MessageTest extends TestCase */ public function testConstructorAndGetters() { - $callable = ['TestApp\WelcomeMailer', 'welcome']; + $callable = [WelcomeMailer::class, 'welcome']; $time = 'sample data ' . time(); $id = 7; - $data = compact('id', 'time'); + $data = ['id' => $id, 'time' => $time]; $parsedBody = [ 'class' => $callable, 'data' => $data, @@ -71,7 +72,7 @@ public function testConstructorAndGetters() */ public function testLegacyArguments() { - $callable = ['TestApp\WelcomeMailer', 'welcome']; + $callable = [WelcomeMailer::class, 'welcome']; $args = [ 'first' => 1, 'second' => 'two', @@ -123,7 +124,7 @@ public function testGetCallableInvalidClass() public function testGetCallableInvalidType() { $parsedBody = [ - 'class' => ['TestApp\WelcomeMailer', 'trash', 'oops'], + 'class' => [WelcomeMailer::class, 'trash', 'oops'], 'args' => [], ]; $messageBody = json_encode($parsedBody); diff --git a/tests/TestCase/Job/SendMailJobTest.php b/tests/TestCase/Job/SendMailJobTest.php index 337c096..d60a219 100644 --- a/tests/TestCase/Job/SendMailJobTest.php +++ b/tests/TestCase/Job/SendMailJobTest.php @@ -109,6 +109,7 @@ public function testExecuteWithAttachments() { $emailMessage = clone $this->message; $emailMessage->addAttachments(['test.txt' => ROOT . 'files' . DS . 'test.txt']); + $message = $this->createMessage(DebugTransport::class, [], $emailMessage); $actual = $this->job->execute($message); $this->assertSame(Processor::ACK, $actual); @@ -147,8 +148,6 @@ public function testExecuteNoAbstractTransport() /** * Create a simple message for testing. - * - * @return \Cake\Queue\Job\Message */ protected function createMessage($transport, $config, $emailMessage): QueueJobMessage { diff --git a/tests/TestCase/Listener/FailedJobsListenerTest.php b/tests/TestCase/Listener/FailedJobsListenerTest.php index ab181a0..d5a56e9 100644 --- a/tests/TestCase/Listener/FailedJobsListenerTest.php +++ b/tests/TestCase/Listener/FailedJobsListenerTest.php @@ -39,6 +39,9 @@ class FailedJobsListenerTest extends TestCase { use QueueTestTrait; + /** + * @var string[] + */ protected array $fixtures = [ 'plugin.Cake/Queue.FailedJobs', ]; diff --git a/tests/TestCase/Queue/ProcessorTest.php b/tests/TestCase/Queue/ProcessorTest.php index c2eccf9..ff57b0b 100644 --- a/tests/TestCase/Queue/ProcessorTest.php +++ b/tests/TestCase/Queue/ProcessorTest.php @@ -29,6 +29,7 @@ use Interop\Queue\Processor as InteropProcessor; use PHPUnit\Framework\Attributes\DataProvider; use TestApp\TestProcessor; +use TestApp\WelcomeMailer; class ProcessorTest extends TestCase { @@ -39,7 +40,7 @@ class ProcessorTest extends TestCase /** * Data provider for testProcess method * - * @return array + * @return array */ public static function dataProviderTestProcess(): array { @@ -187,7 +188,7 @@ public function testProcessJobObject() ]); $messageBody = [ - 'class' => ['TestApp\WelcomeMailer', 'welcome'], + 'class' => [WelcomeMailer::class, 'welcome'], 'args' => [], ]; $connectionFactory = new NullConnectionFactory(); diff --git a/tests/TestCase/QueueTestTrait.php b/tests/TestCase/QueueTestTrait.php index c056223..f24f205 100644 --- a/tests/TestCase/QueueTestTrait.php +++ b/tests/TestCase/QueueTestTrait.php @@ -49,7 +49,7 @@ public function cleanupQueueManagerConfigs(): void $queueConfig = QueueManager::getConfig($config); if ($queueConfig && isset($queueConfig['uniqueCacheKey'])) { $cacheKey = $queueConfig['uniqueCacheKey']; - if (Cache::configured($cacheKey)) { + if (Cache::configured()) { Cache::drop($cacheKey); } } @@ -71,7 +71,7 @@ protected function assertDebugLogContains($expected): void { $found = $this->debugLogCount($expected); - $this->assertGreaterThanOrEqual(1, $found, "Did not find `{$expected}` in logs."); + $this->assertGreaterThanOrEqual(1, $found, sprintf('Did not find `%s` in logs.', $expected)); } /** @@ -85,7 +85,7 @@ protected function assertDebugLogContainsExactly($expected, $times): void { $found = $this->debugLogCount($expected); - $this->assertSame($times, $found, "Did not find `{$expected}` exactly {$times} times in logs."); + $this->assertSame($times, $found, sprintf('Did not find `%s` exactly %d times in logs.', $expected, $times)); } /** @@ -94,7 +94,7 @@ protected function assertDebugLogContainsExactly($expected, $times): void * @param string $search The message to search for * @return int The number of times the message was found */ - protected function debugLogCount($search) + protected function debugLogCount($search): int { $log = Log::engine('debug'); $found = 0; diff --git a/tests/TestCase/Task/JobTaskTest.php b/tests/TestCase/Task/JobTaskTest.php index a49324f..ec4f8f7 100644 --- a/tests/TestCase/Task/JobTaskTest.php +++ b/tests/TestCase/Task/JobTaskTest.php @@ -42,8 +42,6 @@ class JobTaskTest extends TestCase /** * setup method - * - * @return void */ public function setUp(): void { diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 5c6d7c9..e2f7ee2 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -30,13 +30,14 @@ return $root; } } while ($root !== $lastRoot); + throw new Exception('Cannot find the root of the application, unable to run tests'); }; $root = $findRoot(__FILE__); unset($findRoot); chdir($root); -require_once 'vendor/autoload.php'; +require_once $root . '/vendor/autoload.php'; define('CORE_PATH', $root . DS . 'vendor' . DS . 'cakephp' . DS . 'cakephp' . DS); define('ROOT', $root . DS . 'tests' . DS . 'test_app' . DS); @@ -58,6 +59,7 @@ if (Configure::version() <= '5.1.0') { $cache_key = '_cake_core_'; } + Cache::setConfig([ $cache_key => [ 'engine' => 'File', diff --git a/tests/test_app/src/Queue/TestCustomProcessor.php b/tests/test_app/src/Queue/TestCustomProcessor.php index 56f72eb..14afd6e 100644 --- a/tests/test_app/src/Queue/TestCustomProcessor.php +++ b/tests/test_app/src/Queue/TestCustomProcessor.php @@ -26,14 +26,8 @@ class TestCustomProcessor implements InteropProcessor { use EventDispatcherTrait; - /** - * @var \Psr\Log\LoggerInterface - */ protected LoggerInterface $logger; - /** - * @var \Cake\Core\ContainerInterface|null - */ protected ?ContainerInterface $container = null; /** @@ -73,13 +67,13 @@ public function process(QueueMessage $message, Context $context): string|object try { $response = $this->processMessage($jobMessage); - } catch (Throwable $e) { - $message->setProperty('jobException', $e); + } catch (Throwable $throwable) { + $message->setProperty('jobException', $throwable); - $this->logger->debug(sprintf('Message encountered exception: %s', $e->getMessage())); + $this->logger->debug(sprintf('Message encountered exception: %s', $throwable->getMessage())); $this->dispatchEvent('Processor.message.exception', [ 'message' => $jobMessage, - 'exception' => $e, + 'exception' => $throwable, ]); return Result::requeue('Exception occurred while processing message'); diff --git a/tests/test_app/src/TestProcessor.php b/tests/test_app/src/TestProcessor.php index d2cc69a..39a9cbf 100644 --- a/tests/test_app/src/TestProcessor.php +++ b/tests/test_app/src/TestProcessor.php @@ -15,8 +15,8 @@ class TestProcessor * Job to be used in test testProcessMessageCallableIsString * * @param Message $message The message to process - * @return null * @throws Exception + * @return null */ public static function processAndThrowException(Message $message) { diff --git a/tests/test_app/src/WelcomeMailerListener.php b/tests/test_app/src/WelcomeMailerListener.php index 2258bd8..243c2b5 100644 --- a/tests/test_app/src/WelcomeMailerListener.php +++ b/tests/test_app/src/WelcomeMailerListener.php @@ -26,7 +26,7 @@ class WelcomeMailerListener implements EventListenerInterface { /** - * @return array + * @return array{} */ public function implementedEvents(): array { From e0f0736f390f9ee843f272340ab45544884012b3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 02:02:37 +0000 Subject: [PATCH 73/90] Bump actions/checkout from 5 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/deploy_docs_1x.yml | 2 +- .github/workflows/deploy_docs_2x.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy_docs_1x.yml b/.github/workflows/deploy_docs_1x.yml index 54166f4..420424a 100644 --- a/.github/workflows/deploy_docs_1x.yml +++ b/.github/workflows/deploy_docs_1x.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Cloning repo - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/deploy_docs_2x.yml b/.github/workflows/deploy_docs_2x.yml index e48e52e..537aee4 100644 --- a/.github/workflows/deploy_docs_2x.yml +++ b/.github/workflows/deploy_docs_2x.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Cloning repo - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 From 52142ee9d84a3101b8ee89283a87c2be84178905 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sun, 14 Dec 2025 15:34:08 +0100 Subject: [PATCH 74/90] Rename Plugin to QueuePlugin --- src/{Plugin.php => QueuePlugin.php} | 2 +- tests/TestCase/PluginTest.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) rename src/{Plugin.php => QueuePlugin.php} (98%) diff --git a/src/Plugin.php b/src/QueuePlugin.php similarity index 98% rename from src/Plugin.php rename to src/QueuePlugin.php index 92cb0de..2dc6010 100644 --- a/src/Plugin.php +++ b/src/QueuePlugin.php @@ -31,7 +31,7 @@ /** * Plugin for Queue */ -class Plugin extends BasePlugin +class QueuePlugin extends BasePlugin { /** * Plugin name. diff --git a/tests/TestCase/PluginTest.php b/tests/TestCase/PluginTest.php index c48e060..d695069 100644 --- a/tests/TestCase/PluginTest.php +++ b/tests/TestCase/PluginTest.php @@ -4,8 +4,8 @@ namespace Cake\Queue\Test\TestCase; use Cake\Core\Configure; -use Cake\Queue\Plugin; use Cake\Queue\QueueManager; +use Cake\Queue\QueuePlugin; use Cake\TestSuite\TestCase; use InvalidArgumentException; use TestApp\Application; @@ -22,7 +22,7 @@ public function testBootstrapNoConfig() $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Missing `Queue` configuration key, please check the CakePHP Queue documentation to complete the plugin setup'); Configure::delete('Queue'); - $plugin = new Plugin(); + $plugin = new QueuePlugin(); $app = $this->getMockBuilder(Application::class)->disableOriginalConstructor()->getMock(); $plugin->bootstrap($app); } @@ -40,7 +40,7 @@ public function testBootstrapWithConfig() 'logger' => 'stdout', ]; Configure::write('Queue', ['default' => $queueConfig]); - $plugin = new Plugin(); + $plugin = new QueuePlugin(); $app = $this->getMockBuilder(Application::class)->disableOriginalConstructor()->getMock(); $plugin->bootstrap($app); $queueConfig['url'] = [ From 09fd0a4a85d4ac7107bad8b100c09e92ccad196d Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sun, 14 Dec 2025 16:00:17 +0100 Subject: [PATCH 75/90] Retain and deprecate Queue.php --- src/Plugin.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/Plugin.php diff --git a/src/Plugin.php b/src/Plugin.php new file mode 100644 index 0000000..65812ab --- /dev/null +++ b/src/Plugin.php @@ -0,0 +1,24 @@ + Date: Sun, 14 Dec 2025 16:15:01 +0100 Subject: [PATCH 76/90] Adjust @since on new plugin class --- src/QueuePlugin.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/QueuePlugin.php b/src/QueuePlugin.php index 2dc6010..9f7202b 100644 --- a/src/QueuePlugin.php +++ b/src/QueuePlugin.php @@ -11,7 +11,7 @@ * * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org/) * @link https://cakephp.org CakePHP(tm) Project - * @since 0.1.0 + * @since 2.2.1 * @license https://opensource.org/licenses/MIT MIT License */ namespace Cake\Queue; From f3f9592d81dd690d538e75b286a2465cf8159a3b Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 4 Jan 2026 03:30:41 +0100 Subject: [PATCH 77/90] Use BaseMigration instead of deprecated AbstractMigration --- config/Migrations/20221007202459_CreateFailedJobs.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/Migrations/20221007202459_CreateFailedJobs.php b/config/Migrations/20221007202459_CreateFailedJobs.php index a8ae8dc..b067bdc 100644 --- a/config/Migrations/20221007202459_CreateFailedJobs.php +++ b/config/Migrations/20221007202459_CreateFailedJobs.php @@ -1,15 +1,15 @@ Date: Thu, 22 Jan 2026 19:15:34 +0100 Subject: [PATCH 78/90] fix: PHPUnit deprecations, notices and PHPStan errors --- src/Consumption/LimitAttemptsExtension.php | 1 - .../LimitConsumedMessagesExtension.php | 1 - .../RemoveUniqueJobIdFromCacheExtension.php | 1 - tests/TestCase/PluginTest.php | 4 +- tests/TestCase/Queue/ProcessorTest.php | 84 ++++++++++++++----- tests/TestCase/QueueManagerTest.php | 2 +- 6 files changed, 64 insertions(+), 29 deletions(-) diff --git a/src/Consumption/LimitAttemptsExtension.php b/src/Consumption/LimitAttemptsExtension.php index 0a87881..721431e 100644 --- a/src/Consumption/LimitAttemptsExtension.php +++ b/src/Consumption/LimitAttemptsExtension.php @@ -26,7 +26,6 @@ class LimitAttemptsExtension implements MessageResultExtensionInterface /** * @param int|null $maxAttempts The maximum number of times a job may be attempted. $maxAttempts defined on a Job will override this value. - * @return void */ public function __construct( protected readonly ?int $maxAttempts = null, diff --git a/src/Consumption/LimitConsumedMessagesExtension.php b/src/Consumption/LimitConsumedMessagesExtension.php index c5c7bd8..608be83 100644 --- a/src/Consumption/LimitConsumedMessagesExtension.php +++ b/src/Consumption/LimitConsumedMessagesExtension.php @@ -22,7 +22,6 @@ class LimitConsumedMessagesExtension implements PreConsumeExtensionInterface, Po /** * @param int $messageLimit The number of messages to process before exiting. - * @return void */ public function __construct( protected readonly int $messageLimit, diff --git a/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php b/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php index 8ad1fc6..3a70e17 100644 --- a/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php +++ b/src/Consumption/RemoveUniqueJobIdFromCacheExtension.php @@ -13,7 +13,6 @@ class RemoveUniqueJobIdFromCacheExtension implements MessageResultExtensionInter { /** * @param string $cache Cache engine name. - * @return void */ public function __construct( protected readonly string $cache, diff --git a/tests/TestCase/PluginTest.php b/tests/TestCase/PluginTest.php index d695069..022ad82 100644 --- a/tests/TestCase/PluginTest.php +++ b/tests/TestCase/PluginTest.php @@ -23,7 +23,7 @@ public function testBootstrapNoConfig() $this->expectExceptionMessage('Missing `Queue` configuration key, please check the CakePHP Queue documentation to complete the plugin setup'); Configure::delete('Queue'); $plugin = new QueuePlugin(); - $app = $this->getMockBuilder(Application::class)->disableOriginalConstructor()->getMock(); + $app = $this->createStub(Application::class); $plugin->bootstrap($app); } @@ -41,7 +41,7 @@ public function testBootstrapWithConfig() ]; Configure::write('Queue', ['default' => $queueConfig]); $plugin = new QueuePlugin(); - $app = $this->getMockBuilder(Application::class)->disableOriginalConstructor()->getMock(); + $app = $this->createStub(Application::class); $plugin->bootstrap($app); $queueConfig['url'] = [ 'transport' => 'null:', diff --git a/tests/TestCase/Queue/ProcessorTest.php b/tests/TestCase/Queue/ProcessorTest.php index ff57b0b..d0161de 100644 --- a/tests/TestCase/Queue/ProcessorTest.php +++ b/tests/TestCase/Queue/ProcessorTest.php @@ -30,12 +30,39 @@ use PHPUnit\Framework\Attributes\DataProvider; use TestApp\TestProcessor; use TestApp\WelcomeMailer; +use Traversable; class ProcessorTest extends TestCase { use QueueTestTrait; - public static $lastProcessMessage; + /** + * Convert EventList to array in a backwards-compatible way. + * + * In CakePHP 5.3.0+ EventList implements Traversable but array access is deprecated. + * In older versions, EventList only supports array access. + * + * @param \Cake\Event\EventList $events The event list to convert. + * @return array<\Cake\Event\EventInterface> + */ + protected function eventListToArray(EventList $events): array + { + if ($events instanceof Traversable) { + /** @var array<\Cake\Event\EventInterface> */ + return iterator_to_array($events); + } + + $result = []; + $count = $events->count(); + for ($i = 0; $i < $count; $i++) { + $event = $events[$i]; + if ($event !== null) { + $result[] = $event; + } + } + + return $result; + } /** * Data provider for testProcess method @@ -59,11 +86,11 @@ public static function dataProviderTestProcess(): array * @param string $jobMethod The method name to run * @param string $expected The expected process result. * @param string $logMessage The log message based on process result. - * @param string $dispacthedEvent The dispatched event based on process result. + * @param string $dispatchedEvent The dispatched event based on process result. * @return void */ #[DataProvider('dataProviderTestProcess')] - public function testProcess($jobMethod, $expected, $logMessage, $dispatchedEvent) + public function testProcess(string $jobMethod, string $expected, string $logMessage, string $dispatchedEvent): void { $messageBody = [ 'class' => [TestProcessor::class, $jobMethod], @@ -71,13 +98,15 @@ public function testProcess($jobMethod, $expected, $logMessage, $dispatchedEvent ]; $connectionFactory = new NullConnectionFactory(); $context = $connectionFactory->createContext(); - $queueMessage = new NullMessage(json_encode($messageBody)); + $queueMessage = new NullMessage((string)json_encode($messageBody)); $message = new Message($queueMessage, $context); $events = new EventList(); $logger = new ArrayLog(); $processor = new Processor($logger); - $processor->getEventManager()->setEventList($events); + /** @var \Cake\Event\EventManager $eventManager */ + $eventManager = $processor->getEventManager(); + $eventManager->setEventList($events); $actual = $processor->process($queueMessage, $context); $this->assertSame($expected, $actual); @@ -88,17 +117,18 @@ public function testProcess($jobMethod, $expected, $logMessage, $dispatchedEvent $this->assertStringContainsString($logMessage, $logs[0]); $this->assertSame(3, $events->count()); - $this->assertSame('Processor.message.seen', $events[0]->getName()); - $this->assertEquals(['queueMessage' => $queueMessage], $events[0]->getData()); + $eventsList = $this->eventListToArray($events); + $this->assertSame('Processor.message.seen', $eventsList[0]->getName()); + $this->assertEquals(['queueMessage' => $queueMessage], $eventsList[0]->getData()); // Events should contain a message with the same payload. - $this->assertSame('Processor.message.start', $events[1]->getName()); - $data = $events[1]->getData(); + $this->assertSame('Processor.message.start', $eventsList[1]->getName()); + $data = $eventsList[1]->getData(); $this->assertArrayHasKey('message', $data); $this->assertSame($message->jsonSerialize(), $data['message']->jsonSerialize()); - $this->assertSame($dispatchedEvent, $events[2]->getName()); - $data = $events[2]->getData(); + $this->assertSame($dispatchedEvent, $eventsList[2]->getName()); + $data = $eventsList[2]->getData(); $this->assertArrayHasKey('message', $data); $this->assertSame($message->jsonSerialize(), $data['message']->jsonSerialize()); @@ -121,12 +151,14 @@ public function testProcessNotValidCallable() ]; $connectionFactory = new NullConnectionFactory(); $context = $connectionFactory->createContext(); - $queueMessage = new NullMessage(json_encode($messageBody)); + $queueMessage = new NullMessage((string)json_encode($messageBody)); $events = new EventList(); $logger = new ArrayLog(); $processor = new Processor($logger); - $processor->getEventManager()->setEventList($events); + /** @var \Cake\Event\EventManager $eventManager */ + $eventManager = $processor->getEventManager(); + $eventManager->setEventList($events); $result = $processor->process($queueMessage, $context); $this->assertSame(InteropProcessor::REJECT, $result); @@ -137,8 +169,9 @@ public function testProcessNotValidCallable() $this->assertStringContainsString('Invalid callable for message. Rejecting message from queue', $logs[0]); $this->assertSame(2, $events->count()); - $this->assertSame('Processor.message.seen', $events[0]->getName()); - $this->assertSame('Processor.message.invalid', $events[1]->getName()); + $eventsList = $this->eventListToArray($events); + $this->assertSame('Processor.message.seen', $eventsList[0]->getName()); + $this->assertSame('Processor.message.invalid', $eventsList[1]->getName()); } /** @@ -156,20 +189,23 @@ public function testProcessWillRequeueOnException() ]; $connectionFactory = new NullConnectionFactory(); $context = $connectionFactory->createContext(); - $queueMessage = new NullMessage(json_encode($messageBody)); + $queueMessage = new NullMessage((string)json_encode($messageBody)); $events = new EventList(); $logger = new ArrayLog(); $processor = new Processor($logger); - $processor->getEventManager()->setEventList($events); + /** @var \Cake\Event\EventManager $eventManager */ + $eventManager = $processor->getEventManager(); + $eventManager->setEventList($events); $result = $processor->process($queueMessage, $context); $this->assertEquals(InteropProcessor::REQUEUE, $result); // Verify timing information is present in exception event $this->assertSame(3, $events->count()); - $this->assertSame('Processor.message.exception', $events[2]->getName()); - $data = $events[2]->getData(); + $eventsList = $this->eventListToArray($events); + $this->assertSame('Processor.message.exception', $eventsList[2]->getName()); + $data = $eventsList[2]->getData(); $this->assertArrayHasKey('duration', $data); $this->assertIsInt($data['duration']); $this->assertGreaterThanOrEqual(0, $data['duration']); @@ -193,11 +229,13 @@ public function testProcessJobObject() ]; $connectionFactory = new NullConnectionFactory(); $context = $connectionFactory->createContext(); - $queueMessage = new NullMessage(json_encode($messageBody)); + $queueMessage = new NullMessage((string)json_encode($messageBody)); $processor = new Processor(); $result = $processor->process($queueMessage, $context); - $logs = Log::engine('debug')->read(); + /** @var \Cake\Log\Engine\ArrayLog $debugLog */ + $debugLog = Log::engine('debug'); + $logs = $debugLog->read(); Log::drop('debug'); $this->assertCount(1, $logs); @@ -219,12 +257,12 @@ public function testProcessMessage() ]; $connectionFactory = new NullConnectionFactory(); $context = $connectionFactory->createContext(); - $queueMessage = new NullMessage(json_encode($messageBody)); + $queueMessage = new NullMessage((string)json_encode($messageBody)); $message = new Message($queueMessage, $context); $processor = new Processor(); $result = $processor->processMessage($message); $this->assertSame(InteropProcessor::ACK, $result); - $this->assertNotEmpty(TestProcessor::$lastProcessMessage); + $this->assertInstanceOf(Message::class, TestProcessor::$lastProcessMessage); } } diff --git a/tests/TestCase/QueueManagerTest.php b/tests/TestCase/QueueManagerTest.php index 04576a0..85c8474 100644 --- a/tests/TestCase/QueueManagerTest.php +++ b/tests/TestCase/QueueManagerTest.php @@ -99,7 +99,7 @@ public function testSetMultipleConfigs() public function testSetConfigWithInvalidConfigValue() { $this->expectException(LogicException::class); - QueueManager::setConfig('test', null); + QueueManager::setConfig('test'); } public function testSetConfigInvalidKeyValue() From ceab987856fc64c4667ec5be9c233404b261499a Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Sun, 15 Mar 2026 15:56:48 +0100 Subject: [PATCH 79/90] cleanup CS (#183) --- .gitignore | 2 +- composer.json | 4 ++-- phpcs.xml | 12 ++++++++++++ rector.php | 1 + src/Job/SendMailJob.php | 1 - 5 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 phpcs.xml diff --git a/.gitignore b/.gitignore index 8c998b3..1df2e86 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ /tags /composer.lock /phpunit.xml -/phpcs.xml /tools /vendor *.mo @@ -16,6 +15,7 @@ error.log /tests/test_app/config/TestsQueue/schema-dump-test.lock /tests/test_app/Plugin/TestBlog/config/Queue/* .phpunit.cache +.phpcs.cache # IDE and editor specific files # ################################# diff --git a/composer.json b/composer.json index 5a5934f..a59d9b1 100644 --- a/composer.json +++ b/composer.json @@ -60,8 +60,8 @@ "@cs-check", "@test" ], - "cs-check": "phpcs --colors -p src/ tests/", - "cs-fix": "phpcbf --colors -p src/ tests/", + "cs-check": "phpcs", + "cs-fix": "phpcbf", "stan": "@phpstan", "phpstan": "tools/phpstan analyse", "stan-baseline": "tools/phpstan --generate-baseline", diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..007dfc4 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,12 @@ + + + src/ + tests/ + + + + + + + tests/comparisons/* + diff --git a/rector.php b/rector.php index 330dc43..0f74ba1 100644 --- a/rector.php +++ b/rector.php @@ -25,6 +25,7 @@ DocblockReturnArrayFromDirectArrayInstanceRector::class => [ __DIR__ . '/src/Mailer/Transport/QueueTransport.php', ], + 'tests/TestCase/Queue/ProcessorTest.php', ]) ->withParallel() ->withPreparedSets( diff --git a/src/Job/SendMailJob.php b/src/Job/SendMailJob.php index efb7ea7..5e76926 100644 --- a/src/Job/SendMailJob.php +++ b/src/Job/SendMailJob.php @@ -38,7 +38,6 @@ public function execute(Message $message): ?string try { $transportClassName = $message->getArgument('transport'); $config = $message->getArgument('config', []); - /** @var \Cake\Mailer\AbstractTransport $transport */ $transport = $this->getTransport($transportClassName, $config); $emailMessage = new MailerMessage(); From c1f38eeadec40aeb5c0100d5f27947d42030bda9 Mon Sep 17 00:00:00 2001 From: Evgeny Tomenko Date: Thu, 26 Mar 2026 15:26:06 +0300 Subject: [PATCH 80/90] Implements a comprehensive TestSuite for the Queue plugin. (#180) * Implements a comprehensive TestSuite for the Queue plugin. * Works on code coverage * Works on code coverage * Cleanup test queue client. --- src/TestSuite/Constraint/Queue/JobCount.php | 40 + src/TestSuite/Constraint/Queue/JobQueued.php | 48 ++ .../Constraint/Queue/JobQueuedTimes.php | 58 ++ .../Constraint/Queue/NoJobQueued.php | 37 + .../Constraint/Queue/QueueConstraintBase.php | 52 ++ src/TestSuite/QueueTrait.php | 299 ++++++++ src/TestSuite/TestQueueClient.php | 251 +++++++ .../Transport/TestConnectionFactory.php | 34 + src/TestSuite/Transport/TestConsumer.php | 85 +++ src/TestSuite/Transport/TestContext.php | 135 ++++ src/TestSuite/Transport/TestDestination.php | 62 ++ src/TestSuite/Transport/TestDriver.php | 30 + src/TestSuite/Transport/TestMessage.php | 264 +++++++ src/TestSuite/Transport/TestProducer.php | 125 ++++ .../Transport/TestSubscriptionConsumer.php | 55 ++ .../TestCase/TestSuite/QueueTestSuiteTest.php | 693 ++++++++++++++++++ .../TestSuite/Transport/TransportTest.php | 147 ++++ 17 files changed, 2415 insertions(+) create mode 100644 src/TestSuite/Constraint/Queue/JobCount.php create mode 100644 src/TestSuite/Constraint/Queue/JobQueued.php create mode 100644 src/TestSuite/Constraint/Queue/JobQueuedTimes.php create mode 100644 src/TestSuite/Constraint/Queue/NoJobQueued.php create mode 100644 src/TestSuite/Constraint/Queue/QueueConstraintBase.php create mode 100644 src/TestSuite/QueueTrait.php create mode 100644 src/TestSuite/TestQueueClient.php create mode 100644 src/TestSuite/Transport/TestConnectionFactory.php create mode 100644 src/TestSuite/Transport/TestConsumer.php create mode 100644 src/TestSuite/Transport/TestContext.php create mode 100644 src/TestSuite/Transport/TestDestination.php create mode 100644 src/TestSuite/Transport/TestDriver.php create mode 100644 src/TestSuite/Transport/TestMessage.php create mode 100644 src/TestSuite/Transport/TestProducer.php create mode 100644 src/TestSuite/Transport/TestSubscriptionConsumer.php create mode 100644 tests/TestCase/TestSuite/QueueTestSuiteTest.php create mode 100644 tests/TestCase/TestSuite/Transport/TransportTest.php diff --git a/src/TestSuite/Constraint/Queue/JobCount.php b/src/TestSuite/Constraint/Queue/JobCount.php new file mode 100644 index 0000000..913c9fc --- /dev/null +++ b/src/TestSuite/Constraint/Queue/JobCount.php @@ -0,0 +1,40 @@ +getJobs(); + + foreach ($jobs as $job) { + if ($job['jobClass'] === $jobClass) { + return true; + } + } + + return false; + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + if ($this->at !== null) { + return sprintf('job #%d was queued', $this->at); + } + + return 'job was queued'; + } +} diff --git a/src/TestSuite/Constraint/Queue/JobQueuedTimes.php b/src/TestSuite/Constraint/Queue/JobQueuedTimes.php new file mode 100644 index 0000000..9937308 --- /dev/null +++ b/src/TestSuite/Constraint/Queue/JobQueuedTimes.php @@ -0,0 +1,58 @@ +times = $times; + } + + /** + * Checks if job was queued the expected number of times + * + * @param mixed $other Job class name + * @return bool + */ + public function matches(mixed $other): bool + { + $jobClass = $other; + $jobs = TestQueueClient::getQueuedJobsByClass($jobClass); + $actualCount = count($jobs); + + return $actualCount === $this->times; + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return sprintf('job was queued %d times', $this->times); + } +} diff --git a/src/TestSuite/Constraint/Queue/NoJobQueued.php b/src/TestSuite/Constraint/Queue/NoJobQueued.php new file mode 100644 index 0000000..d698b31 --- /dev/null +++ b/src/TestSuite/Constraint/Queue/NoJobQueued.php @@ -0,0 +1,37 @@ +at = $at; + } + + /** + * Get the jobs or job to check + * + * @return array> + */ + protected function getJobs(): array + { + $jobs = TestQueueClient::getQueuedJobs(); + + if ($this->at !== null) { + if (!isset($jobs[$this->at])) { + return []; + } + + return [$jobs[$this->at]]; + } + + return $jobs; + } +} diff --git a/src/TestSuite/QueueTrait.php b/src/TestSuite/QueueTrait.php new file mode 100644 index 0000000..8d8686c --- /dev/null +++ b/src/TestSuite/QueueTrait.php @@ -0,0 +1,299 @@ + 'value']); + * + * $this->assertJobQueued('MyJob'); + * $this->assertJobQueuedWith('MyJob', ['data' => 'value']); + * } + * } + * ``` + */ +trait QueueTrait +{ + /** + * Setup test queue client + * + * Replaces all queue configs with test transport + * to capture jobs instead of queuing them. + * + * @return void + */ + #[Before] + public function setupTestQueueClient(): void + { + TestQueueClient::clearQueuedJobs(); + + if (!QueueManager::configured()) { + QueueManager::setConfig('default', [ + 'url' => 'null:', + ]); + } + + TestQueueClient::replaceAllClients(); + } + + /** + * Cleanup queued jobs + * + * Clears all captured jobs after each test. + * + * @return void + */ + #[After] + public function cleanupQueueTrait(): void + { + TestQueueClient::clearQueuedJobs(); + } + + /** + * Assert a job was queued + * + * @param string $jobClass Job class name + * @param string $message Optional assertion message + * @return void + */ + public function assertJobQueued(string $jobClass, string $message = ''): void + { + $this->assertThat($jobClass, new JobQueued(), $message); + } + + /** + * Assert a job was not queued + * + * @param string $jobClass Job class name + * @param string $message Optional assertion message + * @return void + */ + public function assertJobNotQueued(string $jobClass, string $message = ''): void + { + $jobs = TestQueueClient::getQueuedJobsByClass($jobClass); + $this->assertEmpty( + $jobs, + $message ?: "Job {$jobClass} was queued unexpectedly", + ); + } + + /** + * Assert no jobs were queued + * + * @param string $message Optional assertion message + * @return void + */ + public function assertNoJobsQueued(string $message = ''): void + { + $this->assertThat(null, new NoJobQueued(), $message); + } + + /** + * Assert a specific count of jobs were queued + * + * @param int $count Expected job count + * @param string $message Optional assertion message + * @return void + */ + public function assertJobCount(int $count, string $message = ''): void + { + $this->assertThat($count, new JobCount(), $message); + } + + /** + * Assert a job was queued with specific data + * + * @param string $jobClass Job class name + * @param array $data Expected data + * @param string $message Optional assertion message + * @return void + */ + public function assertJobQueuedWith( + string $jobClass, + array $data, + string $message = '', + ): void { + $jobs = TestQueueClient::getQueuedJobsByClass($jobClass); + $this->assertNotEmpty($jobs, "Job {$jobClass} was not queued"); + + $found = false; + foreach ($jobs as $job) { + if ($job['data'] === $data) { + $found = true; + break; + } + } + + $this->assertTrue( + $found, + $message ?: "Job {$jobClass} was not queued with expected data", + ); + } + + /** + * Assert a job was queued to a specific queue + * + * @param string $queue Queue name + * @param string $jobClass Job class name + * @param string $message Optional assertion message + * @return void + */ + public function assertJobQueuedToQueue( + string $queue, + string $jobClass, + string $message = '', + ): void { + $jobs = TestQueueClient::getQueuedJobsByQueue($queue); + $found = false; + foreach ($jobs as $job) { + if ($job['jobClass'] === $jobClass) { + $found = true; + break; + } + } + + $this->assertTrue( + $found, + $message ?: "Job {$jobClass} was not queued to queue {$queue}", + ); + } + + /** + * Assert a job was queued with delay + * + * @param string $jobClass Job class name + * @param int $delay Expected delay in seconds + * @param string $message Optional assertion message + * @return void + */ + public function assertJobQueuedWithDelay( + string $jobClass, + int $delay, + string $message = '', + ): void { + $jobs = TestQueueClient::getQueuedJobsByClass($jobClass); + $this->assertNotEmpty($jobs, "Job {$jobClass} was not queued"); + + $found = false; + foreach ($jobs as $job) { + if (($job['options']['delay'] ?? null) === $delay) { + $found = true; + break; + } + } + + $this->assertTrue( + $found, + $message ?: "Job {$jobClass} was not queued with delay {$delay}", + ); + } + + /** + * Assert a job was queued with priority + * + * @param string $jobClass Job class name + * @param string|int $priority Expected priority + * @param string $message Optional assertion message + * @return void + */ + public function assertJobQueuedWithPriority( + string $jobClass, + int|string $priority, + string $message = '', + ): void { + $jobs = TestQueueClient::getQueuedJobsByClass($jobClass); + $this->assertNotEmpty($jobs, "Job {$jobClass} was not queued"); + + $found = false; + foreach ($jobs as $job) { + if (($job['options']['priority'] ?? null) === $priority) { + $found = true; + break; + } + } + + $this->assertTrue( + $found, + $message ?: "Job {$jobClass} was not queued with priority {$priority}", + ); + } + + /** + * Assert a job was queued a specific number of times + * + * @param string $jobClass Job class name + * @param int $times Expected number of times + * @param string $message Optional assertion message + * @return void + */ + public function assertJobQueuedTimes(string $jobClass, int $times, string $message = ''): void + { + $this->assertThat($jobClass, new JobQueuedTimes($times), $message); + } + + /** + * Get all queued jobs + * + * @return array> + */ + public function getQueuedJobs(): array + { + return TestQueueClient::getQueuedJobs(); + } + + /** + * Get queued jobs by class + * + * @param string $jobClass Job class name + * @return array> + */ + public function getQueuedJobsByClass(string $jobClass): array + { + return TestQueueClient::getQueuedJobsByClass($jobClass); + } + + /** + * Get queued jobs by queue name + * + * @param string $queue Queue name + * @return array> + */ + public function getQueuedJobsByQueue(string $queue): array + { + return TestQueueClient::getQueuedJobsByQueue($queue); + } + + /** + * Get queued jobs by config name + * + * @param string $config Config name + * @return array> + */ + public function getQueuedJobsByConfig(string $config): array + { + return TestQueueClient::getQueuedJobsByConfig($config); + } +} diff --git a/src/TestSuite/TestQueueClient.php b/src/TestSuite/TestQueueClient.php new file mode 100644 index 0000000..aa3e554 --- /dev/null +++ b/src/TestSuite/TestQueueClient.php @@ -0,0 +1,251 @@ + 'value']); + * + * // Make assertions + * $jobs = TestQueueClient::getQueuedJobs(); + * ``` + */ +class TestQueueClient +{ + /** + * Captured queued jobs + * + * @var array> + */ + protected static array $queuedJobs = []; + + /** + * Transport registration flag + * + * @var bool + */ + protected static bool $registered = false; + + /** + * Replace all queue clients with test transport + * + * Similar to TestEmailTransport::replaceAllTransports() + * + * @return void + */ + public static function replaceAllClients(): void + { + if (!static::$registered) { + Resources::addConnection( + Transport\TestConnectionFactory::class, + ['test'], + [], + 'cakephp/queue-testsuite', + ); + ClientResources::addDriver( + Transport\TestDriver::class, + ['test'], + [], + ['cakephp/queue-testsuite'], + ); + static::$registered = true; + } + + $configured = QueueManager::configured(); + + foreach ($configured as $configName) { + $config = QueueManager::getConfig($configName); + if ($config === null) { + continue; + } + + $config['url'] = 'test:'; + QueueManager::drop($configName); + QueueManager::setConfig($configName, $config); + } + } + + /** + * Capture message from transport + * + * Called by TestProducer when a message is sent. + * + * @param \Interop\Queue\Destination $destination Destination + * @param \Interop\Queue\Message $message Message + * @param int|null $deliveryDelay Delivery delay from producer (in milliseconds) + * @param int|null $timeToLive Time to live from producer (in milliseconds) + * @param int|null $producerPriority Priority from producer + * @return void + */ + public static function captureMessage( + Destination $destination, + Message $message, + ?int $deliveryDelay = null, + ?int $timeToLive = null, + ?int $producerPriority = null, + ): void { + $body = static::extractMessageBody($message); + $classData = $body['class'] ?? null; + $jobClass = is_array($classData) ? $classData[0] : null; + $method = is_array($classData) && isset($classData[1]) ? $classData[1] : 'execute'; + $data = $body['data'] ?? ($body['args'][0] ?? []); + + $requeueOptions = $body['requeueOptions'] ?? []; + $configName = $requeueOptions['config'] ?? 'default'; + + $queueName = 'default'; + if ($destination instanceof Queue) { + $queueName = $destination->getQueueName(); + } elseif ($destination instanceof Topic) { + $queueName = $destination->getTopicName(); + } + $queueName = $requeueOptions['queue'] ?? $queueName; + + $properties = $message->getProperties(); + $delay = $properties['enqueue.delay'] ?? null; + $expires = $properties['enqueue.expire'] ?? null; + $priority = $properties['enqueue.priority'] ?? + $producerPriority ?? + $requeueOptions['priority'] ?? + null; + + if ($delay !== null) { + $delay = (int)$delay; + } elseif ($deliveryDelay !== null) { + $delay = (int)($deliveryDelay / 1000); + } + + if ($expires !== null) { + $expires = (int)$expires; + } elseif ($timeToLive !== null) { + $expires = (int)($timeToLive / 1000); + } + + static::$queuedJobs[] = [ + 'jobClass' => $jobClass, + 'method' => $method, + 'data' => $data, + 'options' => [ + 'config' => $configName, + 'queue' => $queueName, + 'delay' => $delay, + 'expires' => $expires, + 'priority' => $priority, + ], + 'timestamp' => time(), + ]; + } + + /** + * Extract message body + * + * @param \Interop\Queue\Message $message Message + * @return array + */ + protected static function extractMessageBody(Message $message): array + { + $body = $message->getBody(); + $decoded = json_decode($body, true); + if (is_array($decoded)) { + return $decoded; + } + + return []; + } + + /** + * Get all queued jobs + * + * @return array> + */ + public static function getQueuedJobs(): array + { + return static::$queuedJobs; + } + + /** + * Get queued jobs by job class + * + * @param string $jobClass Job class name + * @return array> + */ + public static function getQueuedJobsByClass(string $jobClass): array + { + $filtered = array_filter(static::$queuedJobs, function ($job) use ($jobClass) { + return $job['jobClass'] === $jobClass; + }); + + return array_values($filtered); + } + + /** + * Get queued jobs by queue name + * + * @param string $queue Queue name + * @return array> + */ + public static function getQueuedJobsByQueue(string $queue): array + { + $filtered = array_filter(static::$queuedJobs, function ($job) use ($queue) { + return ($job['options']['queue'] ?? 'default') === $queue; + }); + + return array_values($filtered); + } + + /** + * Get queued jobs by config name + * + * @param string $config Config name + * @return array> + */ + public static function getQueuedJobsByConfig(string $config): array + { + $filtered = array_filter(static::$queuedJobs, function ($job) use ($config) { + return ($job['options']['config'] ?? 'default') === $config; + }); + + return array_values($filtered); + } + + /** + * Clear all queued jobs + * + * @return void + */ + public static function clearQueuedJobs(): void + { + static::$queuedJobs = []; + } + + /** + * Get count of queued jobs + * + * @return int + */ + public static function getQueuedJobCount(): int + { + return count(static::$queuedJobs); + } +} diff --git a/src/TestSuite/Transport/TestConnectionFactory.php b/src/TestSuite/Transport/TestConnectionFactory.php new file mode 100644 index 0000000..847623f --- /dev/null +++ b/src/TestSuite/Transport/TestConnectionFactory.php @@ -0,0 +1,34 @@ +queue = $queue; + } + + /** + * Get queue + * + * @return \Interop\Queue\Queue + */ + public function getQueue(): Queue + { + return $this->queue; + } + + /** + * Receive message + * + * @param int|null $timeout Timeout in milliseconds + * @return \Interop\Queue\Message|null + */ + public function receive(?int $timeout = null): ?Message + { + return null; + } + + /** + * Receive no wait + * + * @return \Interop\Queue\Message|null + */ + public function receiveNoWait(): ?Message + { + return null; + } + + /** + * Acknowledge message + * + * @param \Interop\Queue\Message $message Message + * @return void + */ + public function acknowledge(Message $message): void + { + } + + /** + * Reject message + * + * @param \Interop\Queue\Message $message Message + * @param bool $requeue Requeue flag + * @return void + */ + public function reject(Message $message, bool $requeue = false): void + { + } +} diff --git a/src/TestSuite/Transport/TestContext.php b/src/TestSuite/Transport/TestContext.php new file mode 100644 index 0000000..d0b1781 --- /dev/null +++ b/src/TestSuite/Transport/TestContext.php @@ -0,0 +1,135 @@ + $properties Message properties + * @param array $headers Message headers + * @return \Interop\Queue\Message + */ + public function createMessage(string $body = '', array $properties = [], array $headers = []): Message + { + return new TestMessage($body, $properties, $headers); + } + + /** + * Create queue + * + * @param string $name Queue name + * @return \Interop\Queue\Queue + */ + public function createQueue(string $name): Queue + { + return new TestDestination($name); + } + + /** + * Create topic + * + * @param string $name Topic name + * @return \Interop\Queue\Topic + */ + public function createTopic(string $name): Topic + { + return new TestDestination($name); + } + + /** + * Create producer + * + * @return \Interop\Queue\Producer + */ + public function createProducer(): Producer + { + if ($this->producer === null) { + $this->producer = new TestProducer(); + } + + return $this->producer; + } + + /** + * Create consumer + * + * @param \Interop\Queue\Destination $destination Destination + * @return \Interop\Queue\Consumer + */ + public function createConsumer(Destination $destination): Consumer + { + if ($destination instanceof Queue) { + return new TestConsumer($destination); + } + + $queueName = $destination instanceof Topic + ? $destination->getTopicName() + : 'default'; + + return new TestConsumer(new TestDestination($queueName)); + } + + /** + * Create subscription consumer + * + * @return \Interop\Queue\SubscriptionConsumer + */ + public function createSubscriptionConsumer(): SubscriptionConsumer + { + return new TestSubscriptionConsumer(); + } + + /** + * Create temporary queue + * + * @return \Interop\Queue\Queue + */ + public function createTemporaryQueue(): Queue + { + return new TestDestination('temp_' . uniqid()); + } + + /** + * Purge queue + * + * @param \Interop\Queue\Queue $queue Queue to purge + * @return void + */ + public function purgeQueue(Queue $queue): void + { + } + + /** + * Close context + * + * @return void + */ + public function close(): void + { + } +} diff --git a/src/TestSuite/Transport/TestDestination.php b/src/TestSuite/Transport/TestDestination.php new file mode 100644 index 0000000..47618de --- /dev/null +++ b/src/TestSuite/Transport/TestDestination.php @@ -0,0 +1,62 @@ +name = $name; + } + + /** + * Get queue name + * + * @return string + */ + public function getQueueName(): string + { + return $this->name; + } + + /** + * Get topic name + * + * @return string + */ + public function getTopicName(): string + { + return $this->name; + } + + /** + * Get destination name (for compatibility) + * + * @return string + */ + public function getDestinationName(): string + { + return $this->name; + } +} diff --git a/src/TestSuite/Transport/TestDriver.php b/src/TestSuite/Transport/TestDriver.php new file mode 100644 index 0000000..b70bb2c --- /dev/null +++ b/src/TestSuite/Transport/TestDriver.php @@ -0,0 +1,30 @@ + + */ + protected array $properties; + + /** + * Message headers + * + * @var array + */ + protected array $headers; + + /** + * Constructor + * + * @param string $body Message body + * @param array $properties Properties + * @param array $headers Headers + */ + public function __construct(string $body = '', array $properties = [], array $headers = []) + { + $this->body = $body; + $this->properties = $properties; + $this->headers = $headers; + } + + /** + * Get body + * + * @return string + */ + public function getBody(): string + { + return $this->body; + } + + /** + * Set body + * + * @param string $body Body + * @return void + */ + public function setBody(string $body): void + { + $this->body = $body; + } + + /** + * Set property + * + * @param string $name Property name + * @param mixed $value Property value + * @return void + */ + public function setProperty(string $name, mixed $value): void + { + $this->properties[$name] = $value; + } + + /** + * Get property + * + * @param string $name Property name + * @param mixed $default Default value + * @return mixed + */ + public function getProperty(string $name, mixed $default = null): mixed + { + return $this->properties[$name] ?? $default; + } + + /** + * Set header + * + * @param string $name Header name + * @param mixed $value Header value + * @return void + */ + public function setHeader(string $name, mixed $value): void + { + $this->headers[$name] = $value; + } + + /** + * Get header + * + * @param string $name Header name + * @param mixed $default Default value + * @return mixed + */ + public function getHeader(string $name, mixed $default = null): mixed + { + return $this->headers[$name] ?? $default; + } + + /** + * Get properties + * + * @return array + */ + public function getProperties(): array + { + return $this->properties; + } + + /** + * Set properties + * + * @param array $properties Properties + * @return void + */ + public function setProperties(array $properties): void + { + $this->properties = $properties; + } + + /** + * Get headers + * + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } + + /** + * Set headers + * + * @param array $headers Headers + * @return void + */ + public function setHeaders(array $headers): void + { + $this->headers = $headers; + } + + /** + * Get redelivered flag + * + * @return bool + */ + public function isRedelivered(): bool + { + return false; + } + + /** + * Set redelivered flag + * + * @param bool $redelivered Redelivered + * @return void + */ + public function setRedelivered(bool $redelivered): void + { + } + + /** + * Get correlation ID + * + * @return string|null + */ + public function getCorrelationId(): ?string + { + return $this->getHeader('correlation_id'); + } + + /** + * Set correlation ID + * + * @param string|null $correlationId Correlation ID + * @return void + */ + public function setCorrelationId(?string $correlationId = null): void + { + $this->setHeader('correlation_id', $correlationId); + } + + /** + * Get message ID + * + * @return string|null + */ + public function getMessageId(): ?string + { + return $this->getHeader('message_id'); + } + + /** + * Set message ID + * + * @param string|null $messageId Message ID + * @return void + */ + public function setMessageId(?string $messageId = null): void + { + $this->setHeader('message_id', $messageId); + } + + /** + * Get timestamp + * + * @return int|null + */ + public function getTimestamp(): ?int + { + return $this->getHeader('timestamp'); + } + + /** + * Set timestamp + * + * @param int|null $timestamp Timestamp + * @return void + */ + public function setTimestamp(?int $timestamp = null): void + { + $this->setHeader('timestamp', $timestamp); + } + + /** + * Get reply to + * + * @return string|null + */ + public function getReplyTo(): ?string + { + return $this->getHeader('reply_to'); + } + + /** + * Set reply to + * + * @param string|null $replyTo Reply to + * @return void + */ + public function setReplyTo(?string $replyTo = null): void + { + $this->setHeader('reply_to', $replyTo); + } +} diff --git a/src/TestSuite/Transport/TestProducer.php b/src/TestSuite/Transport/TestProducer.php new file mode 100644 index 0000000..f019089 --- /dev/null +++ b/src/TestSuite/Transport/TestProducer.php @@ -0,0 +1,125 @@ +deliveryDelay, + $this->timeToLive, + $this->priority, + ); + } + + /** + * Set delivery delay + * + * @param int|null $deliveryDelay Delay in milliseconds + * @return \Interop\Queue\Producer + */ + public function setDeliveryDelay(?int $deliveryDelay = null): Producer + { + $this->deliveryDelay = $deliveryDelay; + + return $this; + } + + /** + * Get delivery delay + * + * @return int|null + */ + public function getDeliveryDelay(): ?int + { + return $this->deliveryDelay; + } + + /** + * Set priority + * + * @param int|null $priority Priority + * @return \Interop\Queue\Producer + */ + public function setPriority(?int $priority = null): Producer + { + $this->priority = $priority; + + return $this; + } + + /** + * Get priority + * + * @return int|null + */ + public function getPriority(): ?int + { + return $this->priority; + } + + /** + * Set time to live + * + * @param int|null $timeToLive Time to live in milliseconds + * @return \Interop\Queue\Producer + */ + public function setTimeToLive(?int $timeToLive = null): Producer + { + $this->timeToLive = $timeToLive; + + return $this; + } + + /** + * Get time to live + * + * @return int|null + */ + public function getTimeToLive(): ?int + { + return $this->timeToLive; + } +} diff --git a/src/TestSuite/Transport/TestSubscriptionConsumer.php b/src/TestSuite/Transport/TestSubscriptionConsumer.php new file mode 100644 index 0000000..075d93e --- /dev/null +++ b/src/TestSuite/Transport/TestSubscriptionConsumer.php @@ -0,0 +1,55 @@ + 'null:', + ]); + } + + TestQueueClient::replaceAllClients(); + + $config = QueueManager::getConfig('default'); + $url = $config['url']; + $transport = is_array($url) ? $url['transport'] : $url; + $this->assertEquals('test:', $transport); + } + + /** + * Test assertJobQueued + * + * @return void + */ + public function testAssertJobQueued(): void + { + QueueManager::push(LogToDebugJob::class, []); + + $this->assertJobQueued(LogToDebugJob::class); + } + + /** + * Test assertJobQueued fails when job not queued + * + * @return void + */ + public function testAssertJobQueuedFailsWhenJobNotQueued(): void + { + $this->expectException(AssertionFailedError::class); + + $this->assertJobQueued(LogToDebugJob::class); + } + + /** + * Test assertJobNotQueued + * + * @return void + */ + public function testAssertJobNotQueued(): void + { + $this->assertJobNotQueued(LogToDebugJob::class); + } + + /** + * Test assertJobNotQueued fails when job is queued + * + * @return void + */ + public function testAssertJobNotQueuedFailsWhenJobQueued(): void + { + QueueManager::push(LogToDebugJob::class, []); + + $this->expectException(AssertionFailedError::class); + + $this->assertJobNotQueued(LogToDebugJob::class); + } + + /** + * Test assertNoJobsQueued + * + * @return void + */ + public function testAssertNoJobsQueued(): void + { + $this->assertNoJobsQueued(); + } + + /** + * Test assertNoJobsQueued fails when jobs queued + * + * @return void + */ + public function testAssertNoJobsQueuedFailsWhenJobsQueued(): void + { + QueueManager::push(LogToDebugJob::class, []); + + $this->expectException(AssertionFailedError::class); + + $this->assertNoJobsQueued(); + } + + /** + * Test assertJobCount + * + * @return void + */ + public function testAssertJobCount(): void + { + QueueManager::push(LogToDebugJob::class, []); + QueueManager::push(LogToDebugJob::class, []); + + $this->assertJobCount(2); + } + + /** + * Test assertJobCount fails when count mismatch + * + * @return void + */ + public function testAssertJobCountFailsWhenCountMismatch(): void + { + QueueManager::push(LogToDebugJob::class, []); + + $this->expectException(AssertionFailedError::class); + + $this->assertJobCount(2); + } + + /** + * Test assertJobQueuedWith + * + * @return void + */ + public function testAssertJobQueuedWith(): void + { + QueueManager::push(LogToDebugJob::class, ['key' => 'value', 'id' => 123]); + + $this->assertJobQueuedWith(LogToDebugJob::class, ['key' => 'value', 'id' => 123]); + } + + /** + * Test assertJobQueuedWith fails when data mismatch + * + * @return void + */ + public function testAssertJobQueuedWithFailsWhenDataMismatch(): void + { + QueueManager::push(LogToDebugJob::class, ['key' => 'value']); + + $this->expectException(AssertionFailedError::class); + + $this->assertJobQueuedWith(LogToDebugJob::class, ['key' => 'different']); + } + + /** + * Test assertJobQueuedToQueue + * + * @return void + */ + public function testAssertJobQueuedToQueue(): void + { + QueueManager::push(LogToDebugJob::class, [], ['queue' => 'high-priority']); + + $this->assertJobQueuedToQueue('high-priority', LogToDebugJob::class); + } + + /** + * Test assertJobQueuedToQueue fails when queue mismatch + * + * @return void + */ + public function testAssertJobQueuedToQueueFailsWhenQueueMismatch(): void + { + QueueManager::push(LogToDebugJob::class, [], ['queue' => 'low-priority']); + + $this->expectException(AssertionFailedError::class); + + $this->assertJobQueuedToQueue('high-priority', LogToDebugJob::class); + } + + /** + * Test assertJobQueuedWithDelay + * + * @return void + */ + public function testAssertJobQueuedWithDelay(): void + { + QueueManager::push(LogToDebugJob::class, [], ['delay' => 60]); + + $this->assertJobQueuedWithDelay(LogToDebugJob::class, 60); + } + + /** + * Test assertJobQueuedWithDelay fails when delay mismatch + * + * @return void + */ + public function testAssertJobQueuedWithDelayFailsWhenDelayMismatch(): void + { + QueueManager::push(LogToDebugJob::class, [], ['delay' => 30]); + + $this->expectException(AssertionFailedError::class); + + $this->assertJobQueuedWithDelay(LogToDebugJob::class, 60); + } + + /** + * Test assertJobQueuedWithPriority + * + * @return void + */ + public function testAssertJobQueuedWithPriority(): void + { + QueueManager::push(LogToDebugJob::class, [], ['priority' => MessagePriority::HIGH]); + + $this->assertJobQueuedWithPriority(LogToDebugJob::class, MessagePriority::HIGH); + } + + /** + * Test assertJobQueuedWithPriority fails when priority mismatch + * + * @return void + */ + public function testAssertJobQueuedWithPriorityFailsWhenPriorityMismatch(): void + { + QueueManager::push(LogToDebugJob::class, [], ['priority' => MessagePriority::LOW]); + + $this->expectException(AssertionFailedError::class); + + $this->assertJobQueuedWithPriority(LogToDebugJob::class, MessagePriority::HIGH); + } + + /** + * Test assertJobQueuedTimes + * + * @return void + */ + public function testAssertJobQueuedTimes(): void + { + QueueManager::push(LogToDebugJob::class, []); + QueueManager::push(LogToDebugJob::class, []); + QueueManager::push(LogToDebugJob::class, []); + + $this->assertJobQueuedTimes(LogToDebugJob::class, 3); + } + + /** + * Test assertJobQueuedTimes fails when count mismatch + * + * @return void + */ + public function testAssertJobQueuedTimesFailsWhenCountMismatch(): void + { + QueueManager::push(LogToDebugJob::class, []); + + $this->expectException(AssertionFailedError::class); + + $this->assertJobQueuedTimes(LogToDebugJob::class, 2); + } + + /** + * Test getQueuedJobs + * + * @return void + */ + public function testGetQueuedJobs(): void + { + QueueManager::push(LogToDebugJob::class, ['data' => 'value']); + + $jobs = $this->getQueuedJobs(); + + $this->assertCount(1, $jobs); + $this->assertEquals(LogToDebugJob::class, $jobs[0]['jobClass']); + $this->assertEquals('execute', $jobs[0]['method']); + $this->assertEquals(['data' => 'value'], $jobs[0]['data']); + $this->assertEquals('default', $jobs[0]['options']['queue']); + $this->assertEquals('default', $jobs[0]['options']['config']); + } + + /** + * Test getQueuedJobsByClass + * + * @return void + */ + public function testGetQueuedJobsByClass(): void + { + QueueManager::push(LogToDebugJob::class, ['job' => 1]); + QueueManager::push(LogToDebugJob::class, ['job' => 2]); + + $this->assertJobQueued(LogToDebugJob::class); + $this->assertJobQueuedTimes(LogToDebugJob::class, 2); + + $jobs = $this->getQueuedJobsByClass(LogToDebugJob::class); + + $this->assertCount(2, $jobs); + } + + /** + * Test getQueuedJobsByQueue + * + * @return void + */ + public function testGetQueuedJobsByQueue(): void + { + QueueManager::push(LogToDebugJob::class, [], ['queue' => 'high-priority']); + QueueManager::push(LogToDebugJob::class, [], ['queue' => 'low-priority']); + QueueManager::push(LogToDebugJob::class, [], ['queue' => 'high-priority']); + + $highPriorityJobs = $this->getQueuedJobsByQueue('high-priority'); + $lowPriorityJobs = $this->getQueuedJobsByQueue('low-priority'); + + $this->assertCount(2, $highPriorityJobs); + $this->assertCount(1, $lowPriorityJobs); + } + + /** + * Test getQueuedJobsByConfig + * + * @return void + */ + public function testGetQueuedJobsByConfig(): void + { + QueueManager::setConfig('test', ['url' => 'null:']); + TestQueueClient::replaceAllClients(); + + QueueManager::push(LogToDebugJob::class, [], ['config' => 'default']); + QueueManager::push(LogToDebugJob::class, [], ['config' => 'test']); + QueueManager::push(LogToDebugJob::class, [], ['config' => 'default']); + + $defaultJobs = $this->getQueuedJobsByConfig('default'); + $testJobs = $this->getQueuedJobsByConfig('test'); + + $this->assertCount(2, $defaultJobs); + $this->assertCount(1, $testJobs); + } + + /** + * Test clearQueuedJobs + * + * @return void + */ + public function testClearQueuedJobs(): void + { + QueueManager::push(LogToDebugJob::class, []); + + $this->assertCount(1, $this->getQueuedJobs()); + + TestQueueClient::clearQueuedJobs(); + + $this->assertCount(0, $this->getQueuedJobs()); + } + + /** + * Test job captured with delay + * + * @return void + */ + public function testJobCapturedWithDelay(): void + { + QueueManager::push(LogToDebugJob::class, [], ['delay' => 60]); + + $jobs = $this->getQueuedJobs(); + + $this->assertCount(1, $jobs); + $this->assertEquals(60, $jobs[0]['options']['delay']); + } + + /** + * Test job captured with priority + * + * @return void + */ + public function testJobCapturedWithPriority(): void + { + QueueManager::push(LogToDebugJob::class, [], ['priority' => MessagePriority::HIGH]); + + $jobs = $this->getQueuedJobs(); + + $this->assertCount(1, $jobs); + $this->assertEquals(MessagePriority::HIGH, $jobs[0]['options']['priority']); + } + + /** + * Test job captured with expires + * + * @return void + */ + public function testJobCapturedWithExpires(): void + { + QueueManager::push(LogToDebugJob::class, [], ['expires' => 3600]); + + $jobs = $this->getQueuedJobs(); + + $this->assertCount(1, $jobs); + $this->assertEquals(3600, $jobs[0]['options']['expires']); + } + + /** + * Test getQueuedJobCount + * + * @return void + */ + public function testGetQueuedJobCount(): void + { + $this->assertEquals(0, TestQueueClient::getQueuedJobCount()); + + QueueManager::push(LogToDebugJob::class, []); + $this->assertEquals(1, TestQueueClient::getQueuedJobCount()); + + QueueManager::push(LogToDebugJob::class, []); + $this->assertEquals(2, TestQueueClient::getQueuedJobCount()); + } + + /** + * Test replaceAllClients with multiple configs + * + * @return void + */ + public function testReplaceAllClientsWithMultipleConfigs(): void + { + QueueManager::setConfig('test1', ['url' => 'null:']); + QueueManager::setConfig('test2', ['url' => 'null:']); + + TestQueueClient::replaceAllClients(); + + $config1 = QueueManager::getConfig('test1'); + $config2 = QueueManager::getConfig('test2'); + + $this->assertNotNull($config1); + $this->assertNotNull($config2); + + $url1 = $config1['url']; + $url2 = $config2['url']; + $transport1 = is_array($url1) ? $url1['transport'] : $url1; + $transport2 = is_array($url2) ? $url2['transport'] : $url2; + + $this->assertEquals('test:', $transport1); + $this->assertEquals('test:', $transport2); + } + + /** + * Test replaceAllClients handles null config gracefully + * + * @return void + */ + public function testReplaceAllClientsHandlesNullConfig(): void + { + QueueManager::setConfig('test-null', ['url' => 'null:']); + QueueManager::drop('test-null'); + + TestQueueClient::replaceAllClients(); + + $this->assertNull(QueueManager::getConfig('test-null')); + } + + /** + * Test job captured with custom method + * + * @return void + */ + public function testJobCapturedWithCustomMethod(): void + { + $body = json_encode([ + 'class' => [LogToDebugJob::class, 'customMethod'], + 'data' => ['test' => 'value'], + ]); + + $context = new TestContext(); + $destination = $context->createQueue('default'); + $message = $context->createMessage($body); + + TestQueueClient::captureMessage($destination, $message); + + $jobs = $this->getQueuedJobs(); + $this->assertCount(1, $jobs); + $this->assertEquals(LogToDebugJob::class, $jobs[0]['jobClass']); + $this->assertEquals('customMethod', $jobs[0]['method']); + } + + /** + * Test job captured with topic destination + * + * @return void + */ + public function testJobCapturedWithTopicDestination(): void + { + $body = json_encode([ + 'class' => [LogToDebugJob::class], + 'data' => [], + ]); + + $context = new TestContext(); + $destination = $context->createTopic('test-topic'); + $message = $context->createMessage($body); + + TestQueueClient::captureMessage($destination, $message); + + $jobs = $this->getQueuedJobs(); + $this->assertCount(1, $jobs); + $this->assertEquals('test-topic', $jobs[0]['options']['queue']); + } + + /** + * Test job captured with requeue options + * + * @return void + */ + public function testJobCapturedWithRequeueOptions(): void + { + $body = json_encode([ + 'class' => [LogToDebugJob::class], + 'data' => [], + 'requeueOptions' => [ + 'config' => 'custom-config', + 'queue' => 'custom-queue', + 'priority' => 'high', + ], + ]); + + $context = new TestContext(); + $destination = $context->createQueue('default'); + $message = $context->createMessage($body); + + TestQueueClient::captureMessage($destination, $message); + + $jobs = $this->getQueuedJobs(); + $this->assertCount(1, $jobs); + $this->assertEquals('custom-config', $jobs[0]['options']['config']); + $this->assertEquals('custom-queue', $jobs[0]['options']['queue']); + $this->assertEquals('high', $jobs[0]['options']['priority']); + } + + /** + * Test job captured with message properties for delay and expires + * + * @return void + */ + public function testJobCapturedWithMessageProperties(): void + { + $body = json_encode([ + 'class' => [LogToDebugJob::class], + 'data' => [], + ]); + + $context = new TestContext(); + $destination = $context->createQueue('default'); + $message = $context->createMessage($body, [ + 'enqueue.delay' => '5', + 'enqueue.expire' => '10', + 'enqueue.priority' => '5', + ]); + + TestQueueClient::captureMessage($destination, $message, 3000, 6000, 3); + + $jobs = $this->getQueuedJobs(); + $this->assertCount(1, $jobs); + $this->assertEquals(5, $jobs[0]['options']['delay']); + $this->assertEquals(10, $jobs[0]['options']['expires']); + $this->assertEquals(5, $jobs[0]['options']['priority']); + } + + /** + * Test job captured with delivery delay and time to live from producer + * + * @return void + */ + public function testJobCapturedWithProducerDelayAndTtl(): void + { + $body = json_encode([ + 'class' => [LogToDebugJob::class], + 'data' => [], + ]); + + $context = new TestContext(); + $destination = $context->createQueue('default'); + $message = $context->createMessage($body); + + TestQueueClient::captureMessage($destination, $message, 5000, 10000, 3); + + $jobs = $this->getQueuedJobs(); + $this->assertCount(1, $jobs); + $this->assertEquals(5, $jobs[0]['options']['delay']); + $this->assertEquals(10, $jobs[0]['options']['expires']); + $this->assertEquals(3, $jobs[0]['options']['priority']); + } + + /** + * Test extractMessageBody with args fallback + * + * @return void + */ + public function testExtractMessageBodyWithArgs(): void + { + $body = json_encode([ + 'class' => [LogToDebugJob::class], + 'args' => [['test' => 'value']], + ]); + + $context = new TestContext(); + $destination = $context->createQueue('default'); + $message = $context->createMessage($body); + + TestQueueClient::captureMessage($destination, $message); + + $jobs = $this->getQueuedJobs(); + $this->assertEquals(['test' => 'value'], $jobs[0]['data']); + } + + /** + * Test extractMessageBody with invalid JSON + * + * @return void + */ + public function testExtractMessageBodyWithInvalidJson(): void + { + $context = new TestContext(); + $destination = $context->createQueue('default'); + $message = $context->createMessage('invalid json'); + + TestQueueClient::captureMessage($destination, $message); + + $jobs = $this->getQueuedJobs(); + $this->assertNull($jobs[0]['jobClass']); + } + + /** + * Test createConsumer with other destination + * + * @return void + */ + public function testCreateConsumerWithOtherDestination(): void + { + $context = new TestContext(); + $destination = new TestDestination('test'); + + $consumer = $context->createConsumer($destination); + + $this->assertInstanceOf(TestConsumer::class, $consumer); + } + + /** + * Test createConsumer with Topic destination (not Queue) + * + * @return void + */ + public function testCreateConsumerWithTopicOnlyDestination(): void + { + $context = new TestContext(); + $topic = $this->createMock(Topic::class); + $topic->method('getTopicName')->willReturn('test-topic'); + + $consumer = $context->createConsumer($topic); + + $this->assertInstanceOf(TestConsumer::class, $consumer); + } + + /** + * Test QueueConstraintBase with at parameter + * + * @return void + */ + public function testQueueConstraintBaseWithAt(): void + { + QueueManager::push(LogToDebugJob::class, []); + QueueManager::push(LogToDebugJob::class, []); + + $constraint = new JobQueued(0); + $this->assertTrue($constraint->matches(LogToDebugJob::class)); + $this->assertEquals('job #0 was queued', $constraint->toString()); + + $constraint = new JobQueued(99); + $this->assertFalse($constraint->matches(LogToDebugJob::class)); + } +} diff --git a/tests/TestCase/TestSuite/Transport/TransportTest.php b/tests/TestCase/TestSuite/Transport/TransportTest.php new file mode 100644 index 0000000..1a750ff --- /dev/null +++ b/tests/TestCase/TestSuite/Transport/TransportTest.php @@ -0,0 +1,147 @@ +createContext(); + $this->assertInstanceOf(TestContext::class, $context); + $factory->close(); + } + + public function testTestContext(): void + { + $context = new TestContext(); + + $message = $context->createMessage('body', ['prop' => 'value'], ['header' => 'value']); + $this->assertInstanceOf(TestMessage::class, $message); + $this->assertEquals('body', $message->getBody()); + + $queue = $context->createQueue('test-queue'); + $this->assertInstanceOf(TestDestination::class, $queue); + $this->assertEquals('test-queue', $queue->getQueueName()); + + $topic = $context->createTopic('test-topic'); + $this->assertInstanceOf(TestDestination::class, $topic); + $this->assertEquals('test-topic', $topic->getTopicName()); + + $producer = $context->createProducer(); + $this->assertInstanceOf(TestProducer::class, $producer); + + $consumer = $context->createConsumer($queue); + $this->assertInstanceOf(TestConsumer::class, $consumer); + + $topicConsumer = $context->createConsumer($topic); + $this->assertInstanceOf(TestConsumer::class, $topicConsumer); + + $subscriptionConsumer = $context->createSubscriptionConsumer(); + $this->assertInstanceOf(TestSubscriptionConsumer::class, $subscriptionConsumer); + + $tempQueue = $context->createTemporaryQueue(); + $this->assertInstanceOf(TestDestination::class, $tempQueue); + $this->assertStringStartsWith('temp_', $tempQueue->getQueueName()); + + $context->purgeQueue($queue); + $context->close(); + } + + public function testTestMessage(): void + { + $message = new TestMessage('body', ['prop' => 'value'], ['header' => 'value']); + + $this->assertEquals('body', $message->getBody()); + $message->setBody('new-body'); + $this->assertEquals('new-body', $message->getBody()); + + $this->assertEquals('value', $message->getProperty('prop')); + $this->assertEquals('default', $message->getProperty('missing', 'default')); + $message->setProperty('new-prop', 'new-value'); + $this->assertEquals('new-value', $message->getProperty('new-prop')); + + $this->assertEquals('value', $message->getHeader('header')); + $this->assertEquals('default', $message->getHeader('missing', 'default')); + $message->setHeader('new-header', 'new-value'); + $this->assertEquals('new-value', $message->getHeader('new-header')); + + $message->setProperties(['a' => 'b']); + $this->assertEquals(['a' => 'b'], $message->getProperties()); + + $message->setHeaders(['x' => 'y']); + $this->assertEquals(['x' => 'y'], $message->getHeaders()); + + $this->assertFalse($message->isRedelivered()); + + $message->setCorrelationId('corr-123'); + $this->assertEquals('corr-123', $message->getCorrelationId()); + + $message->setMessageId('msg-123'); + $this->assertEquals('msg-123', $message->getMessageId()); + + $message->setTimestamp(1234567890); + $this->assertEquals(1234567890, $message->getTimestamp()); + + $message->setReplyTo('reply-to'); + $this->assertEquals('reply-to', $message->getReplyTo()); + } + + public function testTestProducer(): void + { + $producer = new TestProducer(); + $destination = new TestDestination('test-queue'); + $message = new TestMessage('body'); + + $producer->setDeliveryDelay(1000); + $this->assertEquals(1000, $producer->getDeliveryDelay()); + + $producer->setPriority(5); + $this->assertEquals(5, $producer->getPriority()); + + $producer->setTimeToLive(2000); + $this->assertEquals(2000, $producer->getTimeToLive()); + + $producer->send($destination, $message); + } + + public function testTestConsumer(): void + { + $subscriptionConsumer = new TestSubscriptionConsumer(); + $queue = new TestDestination('test-queue'); + $consumer = new TestConsumer($queue); + + $this->assertEquals($queue, $consumer->getQueue()); + $this->assertNull($consumer->receive()); + $this->assertNull($consumer->receiveNoWait()); + + $message = new TestMessage('body'); + $consumer->acknowledge($message); + $consumer->reject($message, true); + + $subscriptionConsumer->consume(1000); + $subscriptionConsumer->subscribe($consumer, function () { + }); + $subscriptionConsumer->unsubscribe($consumer); + $subscriptionConsumer->unsubscribeAll(); + } + + public function testTestDestination(): void + { + $destination = new TestDestination('test-name'); + + $this->assertEquals('test-name', $destination->getQueueName()); + $this->assertEquals('test-name', $destination->getTopicName()); + $this->assertEquals('test-name', $destination->getDestinationName()); + } +} From 4aa7d6397f94a06021967104bf9333bb671d54d2 Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Sat, 4 Apr 2026 20:47:02 +0200 Subject: [PATCH 81/90] convert docs to Vitepress (#184) --- .github/workflows/deploy_docs_2x.yml | 2 + .github/workflows/docs-validation.yml | 27 + Dockerfile | 61 +- docs.Dockerfile | 26 - docs/.gitignore | 4 + docs/.vitepress/config.js | 53 + docs/.vitepress/theme/index.js | 1 + docs/.vitepress/toc_en.json | 29 + docs/config/__init__.py | 0 docs/config/all.py | 46 - docs/en/conf.py | 11 - docs/en/contents.rst | 8 - docs/en/events.md | 13 + docs/en/failed-jobs.md | 48 + docs/en/index.md | 15 + docs/en/index.rst | 596 ----- docs/en/installation.md | 75 + docs/en/jobs.md | 91 + docs/en/mail.md | 64 + docs/en/processors.md | 126 + docs/en/workers.md | 31 + docs/package-lock.json | 2104 +++++++++++++++++ docs/package.json | 19 + docs/public/favicon/apple-touch-icon.png | Bin 0 -> 3756 bytes docs/public/favicon/favicon-96x96.png | Bin 0 -> 2228 bytes docs/public/favicon/favicon.ico | Bin 0 -> 15086 bytes docs/public/favicon/favicon.svg | 3 + docs/public/favicon/site.webmanifest | 21 + .../favicon/web-app-manifest-192x192.png | Bin 0 -> 3834 bytes .../favicon/web-app-manifest-512x512.png | Bin 0 -> 15544 bytes docs/public/fonts/cakedingbats-webfont.eot | Bin 0 -> 14117 bytes docs/public/fonts/cakedingbats-webfont.svg | 56 + docs/public/fonts/cakedingbats-webfont.ttf | Bin 0 -> 21792 bytes docs/public/fonts/cakedingbats-webfont.woff | Bin 0 -> 15356 bytes docs/public/fonts/cakedingbats-webfont.woff2 | Bin 0 -> 13380 bytes docs/public/logo.svg | 27 + 36 files changed, 2845 insertions(+), 712 deletions(-) create mode 100644 .github/workflows/docs-validation.yml delete mode 100644 docs.Dockerfile create mode 100644 docs/.gitignore create mode 100644 docs/.vitepress/config.js create mode 100644 docs/.vitepress/theme/index.js create mode 100644 docs/.vitepress/toc_en.json delete mode 100644 docs/config/__init__.py delete mode 100644 docs/config/all.py delete mode 100644 docs/en/conf.py delete mode 100644 docs/en/contents.rst create mode 100644 docs/en/events.md create mode 100644 docs/en/failed-jobs.md create mode 100644 docs/en/index.md delete mode 100644 docs/en/index.rst create mode 100644 docs/en/installation.md create mode 100644 docs/en/jobs.md create mode 100644 docs/en/mail.md create mode 100644 docs/en/processors.md create mode 100644 docs/en/workers.md create mode 100644 docs/package-lock.json create mode 100644 docs/package.json create mode 100644 docs/public/favicon/apple-touch-icon.png create mode 100644 docs/public/favicon/favicon-96x96.png create mode 100644 docs/public/favicon/favicon.ico create mode 100644 docs/public/favicon/favicon.svg create mode 100644 docs/public/favicon/site.webmanifest create mode 100644 docs/public/favicon/web-app-manifest-192x192.png create mode 100644 docs/public/favicon/web-app-manifest-512x512.png create mode 100644 docs/public/fonts/cakedingbats-webfont.eot create mode 100644 docs/public/fonts/cakedingbats-webfont.svg create mode 100644 docs/public/fonts/cakedingbats-webfont.ttf create mode 100644 docs/public/fonts/cakedingbats-webfont.woff create mode 100644 docs/public/fonts/cakedingbats-webfont.woff2 create mode 100644 docs/public/logo.svg diff --git a/.github/workflows/deploy_docs_2x.yml b/.github/workflows/deploy_docs_2x.yml index 537aee4..66ef203 100644 --- a/.github/workflows/deploy_docs_2x.yml +++ b/.github/workflows/deploy_docs_2x.yml @@ -20,4 +20,6 @@ jobs: uses: dokku/github-action@master with: git_remote_url: 'ssh://dokku@apps.cakephp.org:22/queue-docs-2' + git_push_flags: '-f' ssh_private_key: ${{ secrets.DOKKU_SSH_PRIVATE_KEY }} + branch: '2.x' diff --git a/.github/workflows/docs-validation.yml b/.github/workflows/docs-validation.yml new file mode 100644 index 0000000..2fa4c61 --- /dev/null +++ b/.github/workflows/docs-validation.yml @@ -0,0 +1,27 @@ +name: Documentation Validation + +on: + push: + branches: + - 2.x + paths: + - 'docs/**' + - '.github/**' + pull_request: + paths: + - 'docs/**' + - '.github/**' + +jobs: + validate: + uses: cakephp/.github/.github/workflows/docs-validation.yml@5.x + with: + docs-path: 'docs' + vitepress-path: 'docs/.vitepress' + enable-config-js-check: true + enable-json-lint: true + enable-toc-check: true + enable-spell-check: true + enable-markdown-lint: true + enable-link-check: true + tools-ref: '5.x' diff --git a/Dockerfile b/Dockerfile index 70ceb41..d1f1295 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,25 +1,36 @@ -# Basic docker based environment -# Necessary to trick dokku into building the documentation -# using dockerfile instead of herokuish -FROM ubuntu:22.04 - -# Add basic tools -RUN apt-get update && \ - apt-get install -y build-essential \ - software-properties-common \ - curl \ - git \ - libxml2 \ - libffi-dev \ - libssl-dev && \ - LC_ALL=C.UTF-8 add-apt-repository ppa:ondrej/php && \ - apt-get update && \ - apt-get install -y php8.1-cli php8.1-mbstring php8.1-xml php8.1-zip php8.1-intl php8.1-opcache php8.1-sqlite &&\ - apt-get clean &&\ - rm -rf /var/lib/apt/lists/* - -WORKDIR /code - -VOLUME ["/code"] - -CMD [ '/bin/bash' ] +# ---------------------- +# 1. Build stage +# ---------------------- +FROM node:24-alpine AS builder + +# Git is required because docs/package.json pulls a dependency from GitHub. +RUN apk add --no-cache git openssh-client + +WORKDIR /app/docs + +# Copy dependency manifests first to preserve Docker layer caching. +COPY docs/ ./ +RUN npm ci + +# Increase max-old-space-size to avoid memory issues during build +#ENV NODE_OPTIONS="--max-old-space-size=8192" + +# Build the site. +RUN npm run docs:build + +# ---------------------- +# 2. Runtime stage (angie) +# ---------------------- +FROM docker.angie.software/angie:latest AS runner + +# Copy built files +COPY --from=builder /app/docs/.vitepress/dist /usr/share/angie/html + +# Expose port +EXPOSE 80 + +# Health check (optional) +HEALTHCHECK CMD wget --quiet --tries=1 --spider http://localhost:80/ || exit 1 + +# Start angie +CMD ["angie", "-g", "daemon off;"] diff --git a/docs.Dockerfile b/docs.Dockerfile deleted file mode 100644 index 14a8e7f..0000000 --- a/docs.Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -# Generate the HTML output. -FROM ghcr.io/cakephp/docs-builder as builder - -COPY docs /data/docs - -RUN cd /data/docs-builder && \ - # In the future repeat website for each version - make website LANGS="en" SOURCE=/data/docs DEST=/data/website/ - -# Build a small nginx container with just the static site in it. -FROM ghcr.io/cakephp/docs-builder:runtime as runtime - -# Configure search index script -ENV LANGS="en" -ENV SEARCH_SOURCE="/usr/share/nginx/html" -ENV SEARCH_URL_PREFIX="/queue/2" - -COPY --from=builder /data/docs /data/docs -COPY --from=builder /data/website /data/website -COPY --from=builder /data/docs-builder/nginx.conf /etc/nginx/conf.d/default.conf - -# Move each version into place -RUN cp -R /data/website/html/* /usr/share/nginx/html \ - && rm -rf /data/website/ - -RUN ln -s /usr/share/nginx/html /usr/share/nginx/html/ diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..974d64e --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,4 @@ +node_modules +*/public/ +.vitepress/cache +.vitepress/dist diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js new file mode 100644 index 0000000..396b8a0 --- /dev/null +++ b/docs/.vitepress/config.js @@ -0,0 +1,53 @@ +import baseConfig from '@cakephp/docs-skeleton/config' +import { createRequire } from 'module' + +const require = createRequire(import.meta.url) +const tocEn = require('./toc_en.json') + +const versions = { + text: '2.x', + items: [ + { text: '2.x (current)', link: 'https://book.cakephp.org/queue/2/', target: '_self' }, + { text: '1.x', link: 'https://book.cakephp.org/queue/1/en/', target: '_self' }, + ], +} + +export default { + extends: baseConfig, + srcDir: '.', + title: 'Queue', + description: 'CakePHP Queue Documentation', + base: '/queue/2/', + rewrites: { + 'en/:slug*': ':slug*', + }, + sitemap: { + hostname: 'https://book.cakephp.org/queue/2/', + }, + themeConfig: { + siteTitle: false, + pluginName: 'Queue', + socialLinks: [ + { icon: 'github', link: 'https://github.com/cakephp/queue' }, + ], + editLink: { + pattern: 'https://github.com/cakephp/queue/edit/2.x/docs/:path', + text: 'Edit this page on GitHub', + }, + sidebar: tocEn, + nav: [ + { text: 'CakePHP', link: 'https://cakephp.org' }, + { text: 'API', link: 'https://api.cakephp.org/queue/' }, + { ...versions }, + ], + }, + locales: { + root: { + label: 'English', + lang: 'en', + themeConfig: { + sidebar: tocEn, + }, + }, + }, +} diff --git a/docs/.vitepress/theme/index.js b/docs/.vitepress/theme/index.js new file mode 100644 index 0000000..e33e19e --- /dev/null +++ b/docs/.vitepress/theme/index.js @@ -0,0 +1 @@ +export { default } from '@cakephp/docs-skeleton' diff --git a/docs/.vitepress/toc_en.json b/docs/.vitepress/toc_en.json new file mode 100644 index 0000000..3fc01ab --- /dev/null +++ b/docs/.vitepress/toc_en.json @@ -0,0 +1,29 @@ +{ + "/": [ + { + "text": "Getting Started", + "collapsed": false, + "items": [ + { "text": "Installation and Configuration", "link": "/installation" }, + { "text": "Defining and Queueing Jobs", "link": "/jobs" } + ] + }, + { + "text": "Processing Work", + "collapsed": false, + "items": [ + { "text": "Queueing Mail", "link": "/mail" }, + { "text": "Custom Processors", "link": "/processors" }, + { "text": "Running Workers", "link": "/workers" } + ] + }, + { + "text": "Operations", + "collapsed": false, + "items": [ + { "text": "Failed Jobs", "link": "/failed-jobs" }, + { "text": "Worker Events", "link": "/events" } + ] + } + ] +} diff --git a/docs/config/__init__.py b/docs/config/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/docs/config/all.py b/docs/config/all.py deleted file mode 100644 index 9bf85ee..0000000 --- a/docs/config/all.py +++ /dev/null @@ -1,46 +0,0 @@ -# Global configuration information used across all the -# translations of documentation. -# -# Import the base theme configuration -from cakephpsphinx.config.all import * - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# - -# The full version, including alpha/beta/rc tags. -release = '2.x' - -# The search index version. -search_version = 'queue-2' - -# The marketing display name for the book. -version_name = '' - -# Project name shown in the black header bar -project = 'CakePHP Queue' - -# Other versions that display in the version picker menu. -version_list = [ - {'name': '1.x', 'number': '/queue/1/', 'title': '1.x'}, - {'name': '2.x', 'number': '/queue/2/', 'title': '2.x', 'current': True}, -] - -# Languages available. -languages = ['en'] - -# The GitHub branch name for this version of the docs -# for edit links to point at. -branch = '1.x' - -# Current version being built -version = '2.x' - -show_root_link = True - -repository = 'cakephp/queue' - -source_path = 'docs/' - -hide_page_contents = ('search', '404', 'contents') diff --git a/docs/en/conf.py b/docs/en/conf.py deleted file mode 100644 index 25f4da8..0000000 --- a/docs/en/conf.py +++ /dev/null @@ -1,11 +0,0 @@ -import sys, os - -# Append the top level directory of the docs, so we can import from the config dir. -sys.path.insert(0, os.path.abspath('..')) - -# Pull in all the configuration options defined in the global config file.. -from config.all import * - - -# Language in use for this directory. -language = 'en' diff --git a/docs/en/contents.rst b/docs/en/contents.rst deleted file mode 100644 index 9ba94c3..0000000 --- a/docs/en/contents.rst +++ /dev/null @@ -1,8 +0,0 @@ -Contents -######## - -.. toctree:: - :maxdepth: 2 - :caption: CakePHP Queue - - /index diff --git a/docs/en/events.md b/docs/en/events.md new file mode 100644 index 0000000..677b516 --- /dev/null +++ b/docs/en/events.md @@ -0,0 +1,13 @@ +# Worker Events + +Workers dispatch events during normal message processing. If your queue config defines a `listener`, that class can subscribe to these events and react to worker activity. + +## Available Events + +- `Processor.message.exception`: dispatched when a message throws an exception. Arguments: `message`, `exception`. +- `Processor.message.invalid`: dispatched when a message contains an invalid callable. Arguments: `message`. +- `Processor.message.reject`: dispatched when a message finishes with `Processor::REJECT`. Arguments: `message`. +- `Processor.message.success`: dispatched when a message finishes with `Processor::ACK`. Arguments: `message`. +- `Processor.message.failure`: dispatched when a message finishes with `Processor::REQUEUE`. Arguments: `message`. +- `Processor.message.seen`: dispatched when a message is first observed by the worker. Arguments: `message`. +- `Processor.message.start`: dispatched immediately before processing begins. Arguments: `message`. diff --git a/docs/en/failed-jobs.md b/docs/en/failed-jobs.md new file mode 100644 index 0000000..e0038a2 --- /dev/null +++ b/docs/en/failed-jobs.md @@ -0,0 +1,48 @@ +# Failed Jobs + +By default, jobs that throw exceptions are requeued indefinitely. A job is treated as failed only when it returns `Processor::REQUEUE` or throws an exception and no retry attempts remain. + +Retry limits can come from: + +- The job class property `public static $maxAttempts`. +- The worker command option `--max-attempts`. + +If `storeFailedJobs` is enabled, failed jobs are rejected, written to the `queue_failed_jobs` table, and can later be requeued manually. The original `config`, `queue`, and `priority` options are preserved. + +Your broker may also support dead-letter queues. Those are separate transport-level features. Queue's failed-job storage is transport-agnostic and only tracks jobs that exhaust Queue's retry handling. + +## Requeue Failed Jobs + +Push failed jobs back onto the queue: + +```bash +bin/cake queue requeue +``` + +Filters: + +- Positional `ids` argument: comma-separated failed job IDs. +- `--class`: filter by job class. +- `--queue`: filter by queue name. +- `--config`: filter by queue config. +- `--force` or `-f`: skip the confirmation prompt. + +If no filters are provided, all stored failed jobs are requeued. + +## Purge Failed Jobs + +Delete failed jobs from the database: + +```bash +bin/cake queue purge_failed +``` + +Filters: + +- Positional `ids` argument: comma-separated failed job IDs. +- `--class`: filter by job class. +- `--queue`: filter by queue name. +- `--config`: filter by queue config. +- `--force` or `-f`: skip the confirmation prompt. + +If no filters are provided, all stored failed jobs are deleted. diff --git a/docs/en/index.md b/docs/en/index.md new file mode 100644 index 0000000..0bd8b38 --- /dev/null +++ b/docs/en/index.md @@ -0,0 +1,15 @@ +# Queue + +The Queue plugin provides an easy-to-use interface for the [php-enqueue](https://php-enqueue.github.io) project, which abstracts a wide range of queuing backends for CakePHP applications. Use it to move long-running work such as email delivery, notifications, or report generation out of the request cycle. + +Queue jobs are regular PHP classes. They can receive constructor dependencies from your application's container and are processed by the included `queue worker` command. + +## Documentation Map + +- [Installation and Configuration](/installation) covers plugin setup, transport configuration, and failed-job storage. +- [Defining and Queueing Jobs](/jobs) explains job classes, message handling, unique jobs, and `QueueManager::push()`. +- [Queueing Mail](/mail) shows how to queue mailer actions or send mail through the queue transport. +- [Custom Processors](/processors) explains how to replace the default message processor. +- [Running Workers](/workers) documents the worker command and its options. +- [Failed Jobs](/failed-jobs) covers storing, requeueing, and purging failed jobs. +- [Worker Events](/events) lists the events emitted while jobs are processed. diff --git a/docs/en/index.rst b/docs/en/index.rst deleted file mode 100644 index 9ca35e5..0000000 --- a/docs/en/index.rst +++ /dev/null @@ -1,596 +0,0 @@ -Queue -##### - -The Queue plugin provides an easy-to-use interface for the `php-queue -`_ project, which abstracts dozens of queuing -backends for use within your application. Queues can be used to increase the -performance of your application by deferring long-running processes - such as -email or notification sending - until a later time. - -Installation -============ - -You can install this plugin into your CakePHP application using `composer -`_. - -The recommended way to install composer packages is: - -.. code-block:: bash - - composer require cakephp/queue - -Install the transport you wish to use. For a list of available transports, see -`this page `_. The example below is for -pure-php redis: - -.. code-block:: bash - - composer require enqueue/redis predis/predis:^3 - -Ensure that the plugin is loaded in your ``src/Application.php`` file, within -the ``Application::bootstrap()`` function:: - - $this->addPlugin('Cake/Queue'); - -Configuration -============= - -The following configuration should be present in the config array of your **config/app.php**:: - - // ... - 'Queue' => [ - 'default' => [ - // A DSN for your configured backend. default: null - // Can contain protocol/port/username/password or be null if the backend defaults to localhost - 'url' => 'redis://myusername:mypassword@example.com:1000', - - // The queue that will be used for sending messages. default: default - // This can be overridden when queuing or processing messages - 'queue' => 'default', - - // The name of a configured logger, default: null - 'logger' => 'stdout', - - // The name of an event listener class to associate with the worker - 'listener' => \App\Listener\WorkerListener::class, - - // (optional) The processor class to use for processing messages. - // Must implement Interop\Queue\Processor. Defaults to Cake\Queue\Queue\Processor - 'processor' => \App\Queue\CustomProcessor::class, - - // The amount of time in milliseconds to sleep if no jobs are currently available. default: 10000 - 'receiveTimeout' => 10000, - - // Whether to store failed jobs in the queue_failed_jobs table. default: false - 'storeFailedJobs' => true, - - // (optional) The cache configuration for storing unique job ids. `duration` - // should be greater than the maximum length of time any job can be expected - // to remain on the queue. Otherwise, duplicate jobs may be - // possible. Defaults to +24 hours. Note that `File` engine is only suitable - // for local development. - // See https://book.cakephp.org/4/en/core-libraries/caching.html#configuring-cache-engines. - 'uniqueCache' => [ - 'engine' => 'File', - ], - ] - ], - // ... - -The ``Queue`` config key can contain one or more queue configurations. Each of -these is used for interacting with a different queuing backend. - -If ``storeFailedJobs`` is set to ``true``, make sure to run the plugin migrations to create the ``queue_failed_jobs`` table. - -Install the migrations plugin: - -.. code-block:: bash - - composer require cakephp/migrations:"^3.1" - -Run the migrations: - -.. code-block:: bash - - bin/cake migrations migrate --plugin Cake/Queue - - -Usage -===== - -Defining Jobs -------------- - -Workloads are defined as 'jobs'. Job classes can recieve dependencies from your -application's dependency injection container in their constructor just like -Controllers or Commands. Jobs are responsible for processing queue messages. -A simple job that logs received messages would look like:: - - getArgument('id'); - $data = $message->getArgument('data'); - - $this->log(sprintf('%d %s', $id, $data)); - - return Processor::ACK; - } - } - -The passed ``Message`` object has the following methods: - -- ``getArgument($key = null, $default = null)``: Can return the entire passed - dataset or a value based on a ``Hash::get()`` notation key. -- ``getContext()``: Returns the original context object. -- ``getOriginalMessage()``: Returns the original queue message object. -- ``getParsedBody()``: Returns the parsed queue message body. - -A job *may* return any of the following values: - -- ``Processor::ACK``: Use this constant when the message is processed - successfully. The message will be removed from the queue. -- ``Processor::REJECT``: Use this constant when the message could not be - processed. The message will be removed from the queue. -- ``Processor::REQUEUE``: Use this constant when the message is not valid or - could not be processed right now but we can try again later. The original - message is removed from the queue but a copy is published to the queue again. - -The job **may** also return a null value, which is interpreted as -``Processor::ACK``. Failure to respond with a valid type will result in an -interpreted message failure and requeue of the message. - -Job Properties: - -- ``maxAttempts``: The maximum number of times the job may be requeued as a result - of an exception or by explicitly returning ``Processor::REQUEUE``. If - provided, this value will override the value provided in the worker command - line option ``--max-attempts``. If a value is not provided by the job or by - the command line option, the job may be requeued an infinite number of times. -- ``shouldBeUnique``: If ``true``, only one instance of the job, identified by - it's class, method, and data, will be allowed to be present on the queue at a - time. Subsequent pushes will be silently dropped. This is useful for - idempotent operations where consecutive job executions have no benefit. For - example, refreshing calculated data. If ``true``, the ``uniqueCache`` - configuration must be set. - -Queueing Jobs -------------- - -You can enqueue jobs using ``Cake\Queue\QueueManager``:: - - use App\Job\ExampleJob; - use Cake\Queue\QueueManager; - - $data = ['id' => 7, 'is_premium' => true]; - $options = ['config' => 'default']; - - QueueManager::push(ExampleJob::class, $data, $options); - -Arguments: - -- ``$className``: The class that will have it's execute method invoked when the - job is processed. -- ``$data`` (optional): A json-serializable array of data that will be - passed to your job as a message. It should be key-value pairs. -- ``$options`` (optional): An array of optional data for message queueing. - -The following keys are valid for use within the ``options`` array: - -- ``config``: - - - default: default - - description: A queue config name - - type: string - -- ``delay``: - - - default: ``null`` - - description: Time - in integer seconds - to delay message, after which it will be processed. Not all message brokers accept this. - - type: integer - -- ``expires``: - - - default: ``null`` - - description: Time - in integer seconds - after which the message expires. - The message will be removed from the queue if this time is exceeded and it - has not been consumed. - - type: integer - -- ``priority``: - - - default: ``null`` - - type: constant - - valid values: - - - ``\Enqueue\Client\MessagePriority::VERY_LOW`` - - ``\Enqueue\Client\MessagePriority::LOW`` - - ``\Enqueue\Client\MessagePriority::NORMAL`` - - ``\Enqueue\Client\MessagePriority::HIGH`` - - ``\Enqueue\Client\MessagePriority::VERY_HIGH`` - -- ``queue``: - - - default: from queue ``config`` array or string ``default`` if empty - - description: The name of a queue to use - - type: string - -Queueing Mailer Actions ------------------------ - -Mailer actions can be queued by adding the ``Queue\Mailer\QueueTrait`` to the -mailer class. The following example shows how to setup the trait within a mailer -class:: - - setTo($emailAddress) - ->setSubject(sprintf('Welcome %s', $username)); - } - - // ... other actions here ... - } - -It is now possible to use the ``UserMailer`` to send out user-related emails in -a delayed fashion from anywhere in our application. To queue the mailer action, -use the ``push()`` method on a mailer instance:: - - $this->getMailer('User')->push('welcome', ['example@example.com', 'josegonzalez']); - -This ``QueueTrait::push()`` call will generate an intermediate ``MailerJob`` -that handles processing of the email message. If the MailerJob is unable to -instantiate the Email or Mailer instances, it is interpreted as -a ``Processor::REJECT``. An invalid ``action`` is also interpreted as -a ``Processor::REJECT``, as will the action throwing -a ``BadMethodCallException``. Any non-exception result will be seen as -a ``Processor:ACK``. - -The exposed ``QueueTrait::push()`` method has a similar signature to -``Mailer::send()``, and also supports an ``$options`` array argument. The -options this array holds are the same options as those available for -``QueueManager::push()``. - -Delivering E-mail via Queue Jobs --------------------------------- - -If your application isn't using Mailers but you still want to deliver email via -queue jobs, you can use the ``QueueTransport``. In your application's -``EmailTransport`` configuration add a transport:: - - // in app/config.php - use Cake\Queue\Mailer\Transport\QueueTransport; - - return [ - // ... other configuration - 'EmailTransport' => [ - 'default' => [ - 'className' => MailTransport::class, - // Configuration for MailTransport. - ] - 'queue' => [ - 'className' => QueueTransport::class, - // The transport name to use inside the queue job. - 'transport' => 'default', - ] - ], - 'Email' => [ - 'default' => [ - // Connect the default email profile to deliver - // by queue jobs. - 'transport' => 'queue', - ] - ] - ]; - -With this configuration in place, any time you send an email with the ``default`` -email profile CakePHP will generate a queue message. Once that queue message is -processed the default ``MailTransport`` will be used to deliver the email messages. - -Custom Processors -================ - -You can customize how messages are processed by specifying a custom processor class -in your queue configuration. Custom processors must implement the ``Interop\Queue\Processor`` -interface. - -Example custom processor that extends the main Processor:: - - dispatchEvent('Processor.message.seen', ['queueMessage' => $message]); - - $jobMessage = new Message($message, $context, $this->container); - try { - $jobMessage->getCallable(); - } catch (RuntimeException | Error $e) { - $this->logger->debug('Invalid callable for message. Rejecting message from queue.'); - $this->dispatchEvent('Processor.message.invalid', ['message' => $jobMessage]); - - return InteropProcessor::REJECT; - } - - $startTime = microtime(true) * 1000; - $this->dispatchEvent('Processor.message.start', ['message' => $jobMessage]); - - try { - $response = $this->processMessage($jobMessage); - } catch (Throwable $e) { - $message->setProperty('jobException', $e); - - $this->logger->debug(sprintf('Message encountered exception: %s', $e->getMessage())); - $this->dispatchEvent('Processor.message.exception', [ - 'message' => $jobMessage, - 'exception' => $e, - 'duration' => (int)((microtime(true) * 1000) - $startTime), - ]); - - return Result::requeue('Exception occurred while processing message'); - } - - $duration = (int)((microtime(true) * 1000) - $startTime); - - if ($response === InteropProcessor::ACK) { - $this->logger->debug('Message processed successfully'); - $this->dispatchEvent('Processor.message.success', [ - 'message' => $jobMessage, - 'duration' => $duration, - ]); - - return InteropProcessor::ACK; - } - - if ($response === InteropProcessor::REJECT) { - $this->logger->debug('Message processed with rejection'); - $this->dispatchEvent('Processor.message.reject', [ - 'message' => $jobMessage, - 'duration' => $duration, - ]); - - return InteropProcessor::REJECT; - } - - $this->logger->debug('Message processed with failure, requeuing'); - $this->dispatchEvent('Processor.message.failure', [ - 'message' => $jobMessage, - 'duration' => $duration, - ]); - - return InteropProcessor::REQUEUE; - } - } - -Configuration example:: - - 'Queue' => [ - 'default' => [ - 'url' => 'redis://localhost:6379', - 'queue' => 'default', - // No processor specified - uses default Processor class - ], - 'timed' => [ - 'url' => 'redis://localhost:6379', - 'queue' => 'timed', - 'processor' => \App\Queue\TimedProcessor::class, // Custom processor with timing - ], - ], - -**Note**: If no processor is specified in the configuration, the default -``Cake\Queue\Queue\Processor`` class will be used. Custom processors are useful -for adding custom logging, metrics collection, or specialized message handling. - -**Important**: The `--processor` command line option is different from the `processor` configuration option: - -- **Configuration `processor`**: Specifies the processor class to use for processing messages -- **Command line `--processor`**: Specifies the processor name for Enqueue topic binding (used in `bindTopic()`) - -Example usage:: - - # Use custom processor class from config - bin/cake queue worker --config=timed - - # Use custom processor class AND specify topic binding name - bin/cake queue worker --config=timed --processor=my-topic-processor - -Run the worker -============== - -Once a message is queued, you may run a worker via the included ``queue worker`` shell: - -.. code-block:: bash - - bin/cake queue worker - -This shell can take a few different options: - -- ``--config`` (default: default): Name of a queue config to use -- ``--queue`` (default: default): Name of queue to bind to -- ``--processor`` (default: ``null``): Name of processor to bind to (for Enqueue topic binding, not the processor class) -- ``--logger`` (default: ``stdout``): Name of a configured logger -- ``--max-jobs`` (default: ``null``): Maximum number of jobs to process. Worker will exit after limit is reached. -- ``--max-runtime`` (default: ``null``): Maximum number of seconds to run. Worker will exit after limit is reached. -- ``--max-attempts`` (default: ``null``): Maximum number of times each job will be attempted. Maximum attempts defined on a job will override this value. -- ``--verbose`` or ``-v`` (default: ``null``): Provide verbose output, displaying the current values for: - - - Max Iterations - - Max Runtime - - Runtime: Time since the worker started, the worker will finish when Runtime is over Max Runtime value - -Failed Jobs -=========== - -By default, jobs that throw an exception are requeued indefinitely. However, if -``maxAttempts`` is configured on the job class or via a command line argument, a -job will be considered failed if a ``Processor::REQUEUE`` response is received -after processing (typically due to an exception being thrown) and there are no -remaining attempts. The job will then be rejected and added to the -``queue_failed_jobs`` table and can be requeued manually. - -Your chosen transport may offer a dead-letter queue feature. While Failed Jobs -has a similar purpose, it specifically captures jobs that return a -``Processor::REQUEUE`` response and does not handle other failure cases. It is -agnostic of transport and only supports database persistence. - -The following options passed when originally queueing the job will be preserved: -``config``, ``queue``, and ``priority``. - -Requeue Failed Jobs -------------------- - -Push jobs back onto the queue and remove them from the ``queue_failed_jobs`` -table. If a job fails to requeue it is not guaranteed that the job was not run. - -.. code-block:: bash - - bin/cake queue requeue - -Optional filters: - -- ``--id``: Requeue job by the ID of the ``FailedJob`` -- ``--class``: Requeue jobs by the job class -- ``--queue``: Requeue jobs by the queue the job was received on -- ``--config``: Requeue jobs by the config used to queue the job - -If no filters are provided then all failed jobs will be requeued. - -Purge Failed Jobs ------------------- - -Delete jobs from the ``queue_failed_jobs`` table. - -.. code-block:: bash - - bin/cake queue purge_failed - -Optional filters: - -- ``--id``: Purge job by the ID of the ``FailedJob`` -- ``--class``: Purge jobs by the job class -- ``--queue``: Purge jobs by the queue the job was received on -- ``--config``: Purge jobs by the config used to queue the job - -If no filters are provided then all failed jobs will be purged. - - -Worker Events -============= - -The worker shell may invoke the events during normal execution. These events may -be listened to by the associated ``listener`` in the Queue config. - -- ``Processor.message.exception``: - - - description: Dispatched when a message throws an exception. - - arguments: ``message`` and ``exception`` - -- ``Processor.message.invalid``: - - - description: Dispatched when a message has an invalid callable. - - arguments: ``message`` - -- ``Processor.message.reject``: - - - description: Dispatched when a message completes and is to be rejected. - - arguments: ``message`` - -- ``Processor.message.success``: - - - description: Dispatched when a message completes and is to be acknowledged. - - arguments: ``message`` - -- ``Processor.message.failure``: - - - description: Dispatched when a message completes and is to be requeued. - - arguments: ``message`` - -- ``Processor.message.seen``: - - - description: Dispatched when a message is seen. - - arguments: ``message`` - -- ``Processor.message.start``: - - - description: Dispatched before a message is started. - - arguments: ``message`` diff --git a/docs/en/installation.md b/docs/en/installation.md new file mode 100644 index 0000000..0719c2a --- /dev/null +++ b/docs/en/installation.md @@ -0,0 +1,75 @@ +# Installation and Configuration + +## Installation + +Install the plugin with Composer: + +```bash +composer require cakephp/queue +``` + +Install the transport package for the broker you want to use. For the list of supported transports, see [php-enqueue transport docs](https://php-enqueue.github.io/transport/). For example, a Redis setup can use: + +```bash +composer require enqueue/redis predis/predis:^3 +``` + +Load the plugin in your application's `bootstrap()` method: + +```php +$this->addPlugin('Cake/Queue'); +``` + +## Configuration + +Define one or more queue connections in `config/app.php`: + +```php +'Queue' => [ + 'default' => [ + // DSN or transport config array. Required. + 'url' => 'redis://myusername:mypassword@example.com:1000', + + // Queue name used for publishing and consuming messages. + 'queue' => 'default', + + // Optional logger configured through CakePHP's Log class. + 'logger' => 'stdout', + + // Optional listener class attached to worker processor events. + 'listener' => \App\Listener\WorkerListener::class, + + // Optional processor class. Defaults to Cake\Queue\Queue\Processor. + 'processor' => \App\Queue\CustomProcessor::class, + + // Sleep time in milliseconds when no messages are available. + 'receiveTimeout' => 10000, + + // Persist jobs that exceed their allowed retry count. + 'storeFailedJobs' => true, + + // Cache used to track unique jobs. + 'uniqueCache' => [ + 'engine' => 'File', + ], + ], +], +``` + +The `Queue` config key may contain multiple named connections. Each connection can point to a different backend or queue topology. + +## Failed Job Storage + +When `storeFailedJobs` is enabled, install the migrations plugin and create the `queue_failed_jobs` table: + +```bash +composer require cakephp/migrations:^3.1 +``` + +```bash +bin/cake migrations migrate --plugin Cake/Queue +``` + +## Unique Jobs + +If a job sets `public static $shouldBeUnique = true`, you must configure `uniqueCache`. The cache duration should be longer than the maximum time the job might remain queued, or duplicate jobs may become possible. diff --git a/docs/en/jobs.md b/docs/en/jobs.md new file mode 100644 index 0000000..e61698c --- /dev/null +++ b/docs/en/jobs.md @@ -0,0 +1,91 @@ +# Defining and Queueing Jobs + +## Defining Jobs + +Jobs are classes that implement `Cake\Queue\Job\JobInterface`. They receive a `Cake\Queue\Job\Message` instance and return one of the `Interop\Queue\Processor` status constants. + +```php +getArgument('id'); + $data = $message->getArgument('data'); + + $this->log(sprintf('%d %s', $id, $data)); + + return Processor::ACK; + } +} +``` + +Job classes can use constructor injection in the same way as controllers or commands. + +## Message Access + +The `Message` object exposes: + +- `getArgument($key = null, $default = null)` to fetch the full payload or a nested value using `Hash::get()` notation. +- `getContext()` to access the original queue context. +- `getOriginalMessage()` to access the broker-specific message object. +- `getParsedBody()` to inspect the parsed queue body. + +## Return Values + +A job may return: + +- `Processor::ACK` when processing succeeded and the message should be removed. +- `Processor::REJECT` when processing failed permanently and the message should be removed. +- `Processor::REQUEUE` when the job should be retried later. +- `null`, which is treated as `Processor::ACK`. + +Returning any other value is treated as a failure and results in the message being requeued. + +## Job Properties + +- `maxAttempts` limits how many times a job can be retried after an exception or explicit `Processor::REQUEUE`. If unset, the worker's `--max-attempts` option applies. If neither is set, retries are unlimited. +- `shouldBeUnique` allows only one queued copy of the same job class, method, and payload. Duplicate pushes are ignored. This requires `uniqueCache` in the queue configuration. + +## Queueing Jobs + +Use `Cake\Queue\QueueManager` to publish a job: + +```php +use App\Job\ExampleJob; +use Cake\Queue\QueueManager; + +$data = ['id' => 7, 'is_premium' => true]; +$options = ['config' => 'default']; + +QueueManager::push(ExampleJob::class, $data, $options); +``` + +Arguments: + +- The first argument is the job class name. +- The second argument is an optional JSON-serializable payload array. +- The third argument is an optional array of queueing options. + +Supported options: + +- `config`: queue config name. Defaults to `default`. +- `delay`: delay processing by a number of seconds. Broker support varies. +- `expires`: expire the message after a number of seconds if it has not been consumed. +- `priority`: one of `\Enqueue\Client\MessagePriority::VERY_LOW`, `LOW`, `NORMAL`, `HIGH`, or `VERY_HIGH`. +- `queue`: queue name to use. Defaults to the configured queue, then `default`. diff --git a/docs/en/mail.md b/docs/en/mail.md new file mode 100644 index 0000000..ada848c --- /dev/null +++ b/docs/en/mail.md @@ -0,0 +1,64 @@ +# Queueing Mail + +## Queueing Mailer Actions + +Add `Cake\Queue\Mailer\QueueTrait` to a mailer to make its actions queueable: + +```php +setTo($emailAddress) + ->setSubject(sprintf('Welcome %s', $username)); + } +} +``` + +Queue an action with `push()`: + +```php +$this->getMailer('User')->push('welcome', ['example@example.com', 'josegonzalez']); +``` + +`QueueTrait::push()` creates an intermediate `MailerJob` that executes the mailer action later. Its signature mirrors `Mailer::send()` and also accepts the same queueing `$options` supported by `QueueManager::push()`. + +If the mailer or email instance cannot be created, the action name is invalid, or the action throws `BadMethodCallException`, the message is rejected. + +## Delivering Mail Through Queue Jobs + +If you do not use mailers, configure `Cake\Queue\Mailer\Transport\QueueTransport` as an email transport: + +```php +use Cake\Queue\Mailer\Transport\QueueTransport; + +return [ + 'EmailTransport' => [ + 'default' => [ + 'className' => MailTransport::class, + ], + 'queue' => [ + 'className' => QueueTransport::class, + 'transport' => 'default', + ], + ], + 'Email' => [ + 'default' => [ + 'transport' => 'queue', + ], + ], +]; +``` + +With this configuration, sending mail with the `default` email profile creates a queue message. When that message is processed, the configured underlying transport sends the email. diff --git a/docs/en/processors.md b/docs/en/processors.md new file mode 100644 index 0000000..6e562a1 --- /dev/null +++ b/docs/en/processors.md @@ -0,0 +1,126 @@ +# Custom Processors + +Queue connections can use a custom processor class by setting the `processor` option in the queue configuration. The class must implement `Interop\Queue\Processor`. + +```php +'Queue' => [ + 'default' => [ + 'url' => 'redis://localhost:6379', + 'queue' => 'default', + ], + 'timed' => [ + 'url' => 'redis://localhost:6379', + 'queue' => 'timed', + 'processor' => \App\Queue\TimedProcessor::class, + ], +], +``` + +Example processor: + +```php +dispatchEvent('Processor.message.seen', ['queueMessage' => $message]); + + $jobMessage = new Message($message, $context, $this->container); + try { + $jobMessage->getCallable(); + } catch (RuntimeException | Error $e) { + $this->logger->debug('Invalid callable for message. Rejecting message from queue.'); + $this->dispatchEvent('Processor.message.invalid', ['message' => $jobMessage]); + + return InteropProcessor::REJECT; + } + + $startTime = microtime(true) * 1000; + $this->dispatchEvent('Processor.message.start', ['message' => $jobMessage]); + + try { + $response = $this->processMessage($jobMessage); + } catch (Throwable $e) { + $message->setProperty('jobException', $e); + + $this->logger->debug(sprintf('Message encountered exception: %s', $e->getMessage())); + $this->dispatchEvent('Processor.message.exception', [ + 'message' => $jobMessage, + 'exception' => $e, + 'duration' => (int)((microtime(true) * 1000) - $startTime), + ]); + + return Result::requeue('Exception occurred while processing message'); + } + + $duration = (int)((microtime(true) * 1000) - $startTime); + + if ($response === InteropProcessor::ACK) { + $this->logger->debug('Message processed successfully'); + $this->dispatchEvent('Processor.message.success', [ + 'message' => $jobMessage, + 'duration' => $duration, + ]); + + return InteropProcessor::ACK; + } + + if ($response === InteropProcessor::REJECT) { + $this->logger->debug('Message processed with rejection'); + $this->dispatchEvent('Processor.message.reject', [ + 'message' => $jobMessage, + 'duration' => $duration, + ]); + + return InteropProcessor::REJECT; + } + + $this->logger->debug('Message processed with failure, requeuing'); + $this->dispatchEvent('Processor.message.failure', [ + 'message' => $jobMessage, + 'duration' => $duration, + ]); + + return InteropProcessor::REQUEUE; + } +} +``` + +If no processor is configured, the plugin uses `Cake\Queue\Queue\Processor`. + +The worker command's `--processor` option is separate from the config `processor` key: + +- Config `processor` selects the PHP class used to handle messages. +- `--processor` sets the processor name used for Enqueue topic binding. + +Examples: + +```bash +bin/cake queue worker --config=timed +``` + +```bash +bin/cake queue worker --config=timed --processor=my-topic-processor +``` diff --git a/docs/en/workers.md b/docs/en/workers.md new file mode 100644 index 0000000..f703acd --- /dev/null +++ b/docs/en/workers.md @@ -0,0 +1,31 @@ +# Running Workers + +Once jobs have been queued, start a worker with: + +```bash +bin/cake queue worker +``` + +The plugin also registers `bin/cake worker` as an alias for the same command. + +## Options + +- `--config` or `-c`: queue config name. Defaults to `default`. +- `--queue` or `-Q`: queue name to bind to. Defaults to the queue defined by the selected config. +- `--processor` or `-p`: processor name used for Enqueue topic binding. +- `--logger` or `-l`: CakePHP logger name. Used when running with verbose output. +- `--max-jobs` or `-i`: stop after processing a number of jobs. +- `--max-runtime` or `-r`: stop after running for a number of seconds. +- `--max-attempts` or `-a`: maximum retry count for each job unless the job class overrides it. +- `--verbose` or `-v`: enable verbose output through the selected logger. + +## How Workers Are Built + +When a worker starts it: + +- Loads the named queue config. +- Instantiates the configured processor class or falls back to `Cake\Queue\Queue\Processor`. +- Attaches retry limiting and optional failed-job persistence. +- Applies optional consumption limits such as max jobs and max runtime. +- Attaches the configured listener class, if any, to processor events. +- Binds the processor to the selected queue and consumes messages. diff --git a/docs/package-lock.json b/docs/package-lock.json new file mode 100644 index 0000000..7a760b0 --- /dev/null +++ b/docs/package-lock.json @@ -0,0 +1,2104 @@ +{ + "name": "docs", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "docs", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@cakephp/docs-skeleton": "git+ssh://git@github.com:cakephp/docs-skeleton.git#node-package", + "vitepress": "^2.0.0-alpha.16" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cakephp/docs-skeleton": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/cakephp/docs-skeleton.git#9501c6d47a80645604fceb31328368eeb6a21bc6", + "bin": { + "cakedocs": "bin/cakedocs.js" + }, + "peerDependencies": { + "vitepress": "^2.0.0-alpha.15" + } + }, + "node_modules/@docsearch/css": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.6.2.tgz", + "integrity": "sha512-fH/cn8BjEEdM2nJdjNMHIvOVYupG6AIDtFVDgIZrNzdCSj4KXr9kd+hsehqsNGYjpUjObeKYKvgy/IwCb1jZYQ==", + "license": "MIT" + }, + "node_modules/@docsearch/js": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-4.6.2.tgz", + "integrity": "sha512-qj1yoxl3y4GKoK7+VM6fq/rQqPnvUmg3IKzJ9x0VzN14QVzdB/SG/J6VfV1BWT5RcPUFxIcVwoY1fwHM2fSRRw==", + "license": "MIT" + }, + "node_modules/@docsearch/sidepanel-js": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/@docsearch/sidepanel-js/-/sidepanel-js-4.6.2.tgz", + "integrity": "sha512-Pni85AP/GwRj7fFg8cBJp0U04tzbueBvWSd3gysgnOsVnQVSZwSYncfErUScLE1CAtR+qocPDFjmYR9AMRNJtQ==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.76", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.76.tgz", + "integrity": "sha512-lLRlA8yaf+1L5VCPRvR9lynoSklsddKHEylchmZJKdj/q2xVQ1ZAEJ8SCQlv9cbgtMefnlyM98U+8Si2aoFZPA==", + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", + "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", + "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-3.23.0.tgz", + "integrity": "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz", + "integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==", + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.1.tgz", + "integrity": "sha512-bsDMJ07b3GN1puVwJb/fyFnj/U2imyswK5UQVLZwVl7O05jDrt6BHxeG5XffmOOdasOj/bOmIjxJvGPxU7pcqw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.1.1" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.1.tgz", + "integrity": "sha512-gVBaBv++i+adg4JpH71k9ppl4soyR7Y2McEqO5YNgv0BI1kMZ7BDX5gnwkZ5COYgiCyhejZG+yGNrBAjj6Coqg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.1.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.1.tgz", + "integrity": "sha512-+h4ttmJYl/txpxHKaoZcaKpC+pvckgLzIDiSQlaQ7kKthKh8KuwoLW2D8hPJEnqKzXOvu15UHEoGyngAXCz0EQ==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz", + "integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.2.1", + "@vueuse/shared": "14.2.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/integrations": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-14.2.1.tgz", + "integrity": "sha512-2LIUpBi/67PoXJGqSDQUF0pgQWpNHh7beiA+KG2AbybcNm+pTGWT6oPGlBgUoDWmYwfeQqM/uzOHqcILpKL7nA==", + "license": "MIT", + "dependencies": { + "@vueuse/core": "14.2.1", + "@vueuse/shared": "14.2.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7 || ^8", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7 || ^8", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz", + "integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz", + "integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/focus-trap": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-8.0.1.tgz", + "integrity": "sha512-9ptSG6z51YQOstI/oN4XuVGP/03u2nh0g//qz7L6zX0i6PZiPnkcf3GenXq7N2hZnASXaMxTPpbKwdI+PFvxlw==", + "license": "MIT", + "peer": true, + "dependencies": { + "tabbable": "^6.4.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "license": "MIT" + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oniguruma-parser": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz", + "integrity": "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.1", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", + "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/engine-javascript": "3.23.0", + "@shikijs/engine-oniguruma": "3.23.0", + "@shikijs/langs": "3.23.0", + "@shikijs/themes": "3.23.0", + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitepress": { + "version": "2.0.0-alpha.17", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-2.0.0-alpha.17.tgz", + "integrity": "sha512-Z3VPUpwk/bHYqt1uMVOOK1/4xFiWQov1GNc2FvMdz6kvje4JRXEOngVI9C+bi5jeedMSHiA4dwKkff1NCvbZ9Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@docsearch/css": "^4.5.3", + "@docsearch/js": "^4.5.3", + "@docsearch/sidepanel-js": "^4.5.3", + "@iconify-json/simple-icons": "^1.2.69", + "@shikijs/core": "^3.22.0", + "@shikijs/transformers": "^3.22.0", + "@shikijs/types": "^3.22.0", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^6.0.4", + "@vue/devtools-api": "^8.0.5", + "@vue/shared": "^3.5.27", + "@vueuse/core": "^14.2.0", + "@vueuse/integrations": "^14.2.0", + "focus-trap": "^8.0.0", + "mark.js": "8.11.1", + "minisearch": "^7.2.0", + "shiki": "^3.22.0", + "vite": "^7.3.1", + "vue": "^3.5.27" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "oxc-minify": "*", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "oxc-minify": { + "optional": true + }, + "postcss": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..f140773 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,19 @@ +{ + "name": "docs", + "version": "1.0.0", + "description": "", + "main": "config.js", + "scripts": { + "docs:dev": "vitepress dev", + "docs:build": "vitepress build", + "docs:preview": "vitepress preview" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "module", + "dependencies": { + "@cakephp/docs-skeleton": "git+ssh://git@github.com:cakephp/docs-skeleton.git#node-package", + "vitepress": "^2.0.0-alpha.16" + } +} diff --git a/docs/public/favicon/apple-touch-icon.png b/docs/public/favicon/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c6d073d7b90df354bf3f850d31823b3f71425aa9 GIT binary patch literal 3756 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 Yei$bomA;yTHxw|rdc&~ditFQl0flquUH||9 literal 0 HcmV?d00001 diff --git a/docs/public/favicon/favicon-96x96.png b/docs/public/favicon/favicon-96x96.png new file mode 100644 index 0000000000000000000000000000000000000000..6642e0cdac521bc20349a06ef18076a2ee71f5a4 GIT binary patch literal 2228 zcmai$XE@sn8^-@pl*DeW9MbUGL}Qn#hDMEQtX66}QEEi2;7}z|G)7fx)TmLT;}uHc zh`ncN&C9H+9W_r=9BQ2QdcVEzhxfyCKi73%_w(ubeYg{>FeYFq7ytlXGgGw9&+YbW zxHy06LRQ!%0N{!=LmS@u!)4RS4{v*0ENZX`59f+OFP>$Wls2_LP_G;osV`40JlXkI zj#rUkarf1eokwyqMG0z9cU*FUT=(DC7O_R0S(FRZxDZDjts>e7KX5M=kX8C7b!I$& zVpT-Dp`w(*Xxb+R5NJg2!SI6M{ME))R+C9;RJuAmy~sCUgiV3K?f@W2IL`AOzWhUM z3Z5vn#=ZYGUc)M{3?Z?pZv>4744dvPzKg%(WZXGQ>C{w#T-=Z4f7L<`XU{S*-PxA$ z7KMEF23OhgKpR$~XBs=wf`Cq8(B8y^YtUHMogQ?E3>qk4frw4m3?&HPC?>sO!VSY^ znAeP^maBx)lhch!vq|l?mKY&rTbXvRSrD^+;BmVWqi^hEj8LaK!LCo+^+tb!P5+W9 zX?A`mHqA^!B|(~Ll=HSq-S}5T&<{pxyeCp_2A9_Eb2Wm)V zAz1))K`xEy=X!rbxgi9h(3)b!Oa$2jg&ZQQY|^4y<_jAoBdsILtvB0R7vbh)~Y0HhBE_3__hk_I)9t79^|9}4v z06z^>0uORtA{RSv;cl0ZnSQs2Y*E55-@!^Rd=9FvxsqcrvBghqmg@_W{aAxteA57P zoxR1$BIGjZm`)eey8X8HGv&R&SJ+&Ncpcu~wQ9wD)Kpt7>9znn%qILX58~d#N+W*D zzP~P8w<=boF42ai_!Zbk8C35G8kpRPHf%gcvQ%rag6Tx}s4Wo;JjY(mGL*#h+Cik^NM zDLr=zjkXtP)GUOM;eXSAAWzBqgNNs;bm;UYeoM80WkFidE{q2Z3pYnC}_BcgOp zNY9;W`g9o11D+2XsJU)1exEJA@VRJp&*$Er8*QSR)Z{B|y=R`SnSFl(X_TuK$X5ad zWt1sbM`XBNf8Lc8dwv_iq!3hYMLDwZh_99VIz3bGau(CuqEfhRHOg?;AYx69u;Wvph@XL0n(+;52fAQNm(dpgys4b-qTI)wOuf*1$%GK_`D+ zMsLK$nDQd-ju0yzN9_>@NA|^~THowsu+%U5hb+X5)VX|%@MMyq75nBZJ(jSCu#KQ75QNf=6NiYN%;$gqp?8auu z=NKc_iHNuO^W(PHnzfx5uz%4p_&8f>DtON#t4`dd^12841#U*zZETY4Y&L*$*g3Yl z1}$$+St)pt`EoLH?QwI7I3v{Q^lJLOL)?O5y+1WP#ugL=ZXfHGsfo#|nMSkryM%n@ zo3#4i`8yn*_e1Os1+4|gSDyBDOS;r>UXC+x%?aX0LLUZ-rcxk%<+Yiwc_(;?nGWL> zho*?^@AfOpw{<&gDoGJIL+L}GwXt#bQu83Tr9g4FVE<(*InP)$MQgjOUGQ?xYBU&}nk|Vt zB5ipWXKNblee-OKynSg-uh6PXd3nExy;o=vTRSJ0w#Filn#>SB*Z2*sn>&xO-Q~5G zAG+xmx2~ljpWE~cs&D(e(Z&4eFxjZ~Y~~Yb#UCFG>-@KBGOzY;%4TSCUV5AR3oNmk zGZ-9*CK|r~^2hWOQ}Xdn&aqt514XDl@-b#RZ)!@{D%lT#Qo~P~LabA8CM>|N3m5%#0He%2Ad%ou~NFmj9e2dBOq=Kufz literal 0 HcmV?d00001 diff --git a/docs/public/favicon/favicon.ico b/docs/public/favicon/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..405aa94ce7a1aab55f757e5c6ee36308fa0facc0 GIT binary patch literal 15086 zcmeI3eT-aH6~G^DV*`G)VrmouyW(PC`RKy5JMU}v&HGs4ABj|h-?2gvMFDAPP$2FY z5Gg8BiHRUo0tk&QRY2=km2D^}((cT>H`5KZ#iiJ$sB0ThD6-x0ci!xFk{Rp06m+kv1PJ`JbDkC^u8! zG|Es#Sqeyc`)Ny{ErI_}38?C>V+XY4JFRG4^^nNW^>}dW6T`hC5tNY6wJ$a+8 zrS5R`)N0@%TTiWbjo|0N(2O+oSl5Lqb5$Yjm#M_HDNft>266YZqrg8pW`l0 zz7M{ax_aw~{tsB= z*R#eyVw--XZ&7ekcX#?tg>$6%y;fdYRc?~!QTY9XW27E%wd9pvZ%Xaz+CI0Kw_3hi z!52I+?jpzFUuD}f{8uQG5uP0*d5NPwdtxp8RV!1LUWGkS_yk^H&ZXgLGx%;tOI?K? zy>~KgntrG3Ue@0lbgf;DpL+9a;XlW(zZ*S}A^QBzmN5JXSMRp z5%S0#!tcXgSH;5aH9TzyLj6>>Zez_MnC&t>741Qx<2w&&q$vvc1g@kMq^rV9_#4~ zo}RuDUHW}&=JjHuAlJln@DtL<>8%}}ueSZ1J0mnB?Y8|~`#GzWo|Lpo@^9PEt+AiW z$zIZrCi}S(pXKt<{YT7xF2_?ly?MRRmSJ0l?KyyLZUo~qRSxLpan3J$W-9;qzMg~s zRrF)pd3YxD2cBQz%<(ncKiFU@P#T+yfNzUHr{C`)t<6Rh+rkGq3(gy&b?9 z@2|bl{ejMFGMB-B0`RZ;+(!kUul1~6+?RpgWb0_bM`O`sR8<@I1vT5RcKx;l+7dYY63B*+qEy9kDWWLjVNpdH1y67z69=@ zZ(JIppQD25KP)Rs2Yd?VC%a$z1P-6-SmLxgff&$^(?SR{?6Iu$TU?Xa2{m=D+)c3` zFJb?^j6138*?Z%8Xl1v=_GEw8uw^d62Kg57Bj`K=aE~9_A!5_KZoh~f(+c`xAJDf8 zp?3>5#4mwec^j!3u0~((q2(S(?jUVqJg~LCj2*osZ^%T}`^c}v(=*Pp@ve#m_ zzF+PRrzli@t45QyUj@XDmo>Cn#)p2b6Pi*NJW5R0m2x(!OsRBkF@2FCZOVPDtRrIE z{I6h>e=^h|%ypTco|-y~{w@Qa7hAn_%eAF4Cy1X4QjFJQ!1qLk%<+pPu0~?6%C%xy zkI!+m^tI*gU5oV}ZKEIj&&nA&KlcQ4_PClpERnbq)7-wG9KBNMTG1V9TmRMXJ1&;% z|BC)AURUM5TdDub(P*dFKQCvD%vum{*l1tb8}^{DiPQ8C`&|Jv@qMMfCb8q}`NT6e zW`8x(cW8;va%FyhDzrb@XMVvx^L!)iR;mNdKcg!)h(DS*_zS$qmP6vXM0du_OcdTO zW)CGEHMASW{@zF$F+jw!Rq*{v9%aQpEM+&cH~$oTUuY8&gYBs4-t%jWca#_vY@zoO z%YP1gZeK30U-sYNI%vFp{wRExJnXegr7Sj%NsQ$=(uVY_c%Nv09~~0N`4e)#<9HWF zfw+>z6M@4a|KN|9%}K8V7|bf;Hz@NaN|wu)FM;E@Ax80R;ODUlX^Nj>KF|rg@qUUQ z&o<5B1$J++(9tt}%-vWXAYbuuRo_E5Y(zImEGa(y={hrC*;<&GA2WT6wSEO4=a_Y} z?!dAp4~mZ9eH?fe+6i>uGn8A4PFTTzP&jWBR~ynG&Ng>vaDaTyR>639#v&FxrX|l4 zdsku_<=haiQ@L-z_Rs4e^tzloo(9Xk#6fhyZMHGY8A@#O?5sS$c^uZ$!@CQKU0-f% zNk`5J;r|Hq^xgTmV~Oj8pHGBm$AY+YZ21rKzm!GraEfXAZ-)kRDEC;=kxqA!e+stx z9h^m;fR~-n;4Vh)JaW+0wSifdd*t3u#)p{vFS1A6Aa*)5H=q;czZw(yJ{Dq$;Y1EA zX#ZZyY^L1+cmY~_?eNZ`^8Fum+gUvgH3*60aM^@P~6!WXbozFirP2Grsqr%dfT=)4Z=lMn}fh)-oOa%niBQ waiddfNZut)=8Tk)wJ \ No newline at end of file diff --git a/docs/public/favicon/site.webmanifest b/docs/public/favicon/site.webmanifest new file mode 100644 index 0000000..4f23fb3 --- /dev/null +++ b/docs/public/favicon/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "CakePHP", + "short_name": "CakePHP", + "icons": [ + { + "src": "/favicon/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/favicon/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} \ No newline at end of file diff --git a/docs/public/favicon/web-app-manifest-192x192.png b/docs/public/favicon/web-app-manifest-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..b5df2990b8e405b96289c93536cb92bf71c3c49f GIT binary patch literal 3834 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 OwLn8v2T`tU8~h&>G1^1` literal 0 HcmV?d00001 diff --git a/docs/public/favicon/web-app-manifest-512x512.png b/docs/public/favicon/web-app-manifest-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..6a522de3520f9a4740a379cca62afe75ff91a97f GIT binary patch literal 15544 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 literal 0 HcmV?d00001 diff --git a/docs/public/fonts/cakedingbats-webfont.eot b/docs/public/fonts/cakedingbats-webfont.eot new file mode 100644 index 0000000000000000000000000000000000000000..0800d1e7def65757de80ea51112af9929664e7b0 GIT binary patch literal 14117 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�M7B)ZD0hK8i9R2c@+ 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/public/fonts/cakedingbats-webfont.ttf b/docs/public/fonts/cakedingbats-webfont.ttf new file mode 100644 index 0000000000000000000000000000000000000000..78ad6c8840a2e758532b358027c16f52c4e00bf6 GIT binary patch literal 21792 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 literal 0 HcmV?d00001 diff --git a/docs/public/fonts/cakedingbats-webfont.woff b/docs/public/fonts/cakedingbats-webfont.woff new file mode 100644 index 0000000000000000000000000000000000000000..a95e1b38b405a4f576421e0c69f575b526e07e89 GIT binary patch literal 15356 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 aR26z@9E{Q#m>c6I;}A^)n6$T#zXJdOPrH@? literal 0 HcmV?d00001 diff --git a/docs/public/logo.svg b/docs/public/logo.svg new file mode 100644 index 0000000..829c8e9 --- /dev/null +++ b/docs/public/logo.svg @@ -0,0 +1,27 @@ + + + + From ad5529c6df5afcb8b1264c26f4b28b3157da5072 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:53:36 +0200 Subject: [PATCH 82/90] Bump vite from 7.3.1 to 7.3.2 in /docs (#185) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.1 to 7.3.2. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 7.3.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package-lock.json | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 7a760b0..8236c3b 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@cakephp/docs-skeleton": "git+ssh://git@github.com:cakephp/docs-skeleton.git#node-package", + "@cakephp/docs-skeleton": "https://github.com/cakephp/docs-skeleton.git#node-package", "vitepress": "^2.0.0-alpha.16" } }, @@ -1401,7 +1401,6 @@ "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-8.0.1.tgz", "integrity": "sha512-9ptSG6z51YQOstI/oN4XuVGP/03u2nh0g//qz7L6zX0i6PZiPnkcf3GenXq7N2hZnASXaMxTPpbKwdI+PFvxlw==", "license": "MIT", - "peer": true, "dependencies": { "tabbable": "^6.4.0" } @@ -1655,7 +1654,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1947,11 +1945,10 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2026,7 +2023,6 @@ "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-2.0.0-alpha.17.tgz", "integrity": "sha512-Z3VPUpwk/bHYqt1uMVOOK1/4xFiWQov1GNc2FvMdz6kvje4JRXEOngVI9C+bi5jeedMSHiA4dwKkff1NCvbZ9Q==", "license": "MIT", - "peer": true, "dependencies": { "@docsearch/css": "^4.5.3", "@docsearch/js": "^4.5.3", @@ -2073,7 +2069,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/compiler-sfc": "3.5.32", From 4b9c7a743381370fec5ca7bedc50d7f6a72aad44 Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Sat, 23 May 2026 13:27:49 +0200 Subject: [PATCH 83/90] update stan (#186) * update stan * fix rector style issues --- .phive/phars.xml | 2 +- .../Constraint/Queue/JobQueuedTimes.php | 2 -- .../Constraint/Queue/QueueConstraintBase.php | 2 -- src/TestSuite/QueueTrait.php | 16 ++++++++-------- src/TestSuite/TestQueueClient.php | 3 +-- src/TestSuite/Transport/TestConsumer.php | 2 -- src/TestSuite/Transport/TestContext.php | 4 +--- src/TestSuite/Transport/TestDestination.php | 2 -- src/TestSuite/Transport/TestDriver.php | 14 -------------- src/TestSuite/Transport/TestMessage.php | 2 -- src/TestSuite/Transport/TestProducer.php | 6 ------ 11 files changed, 11 insertions(+), 44 deletions(-) diff --git a/.phive/phars.xml b/.phive/phars.xml index a95d33f..848f478 100644 --- a/.phive/phars.xml +++ b/.phive/phars.xml @@ -1,4 +1,4 @@ - + diff --git a/src/TestSuite/Constraint/Queue/JobQueuedTimes.php b/src/TestSuite/Constraint/Queue/JobQueuedTimes.php index 9937308..69bdda8 100644 --- a/src/TestSuite/Constraint/Queue/JobQueuedTimes.php +++ b/src/TestSuite/Constraint/Queue/JobQueuedTimes.php @@ -16,8 +16,6 @@ class JobQueuedTimes extends QueueConstraintBase { /** * Expected number of times - * - * @var int */ protected int $times; diff --git a/src/TestSuite/Constraint/Queue/QueueConstraintBase.php b/src/TestSuite/Constraint/Queue/QueueConstraintBase.php index 7ff4099..bffcd72 100644 --- a/src/TestSuite/Constraint/Queue/QueueConstraintBase.php +++ b/src/TestSuite/Constraint/Queue/QueueConstraintBase.php @@ -15,8 +15,6 @@ abstract class QueueConstraintBase extends Constraint { /** * Job index to check - * - * @var int|null */ protected ?int $at = null; diff --git a/src/TestSuite/QueueTrait.php b/src/TestSuite/QueueTrait.php index 8d8686c..240771b 100644 --- a/src/TestSuite/QueueTrait.php +++ b/src/TestSuite/QueueTrait.php @@ -96,7 +96,7 @@ public function assertJobNotQueued(string $jobClass, string $message = ''): void $jobs = TestQueueClient::getQueuedJobsByClass($jobClass); $this->assertEmpty( $jobs, - $message ?: "Job {$jobClass} was queued unexpectedly", + $message ?: sprintf('Job %s was queued unexpectedly', $jobClass), ); } @@ -137,7 +137,7 @@ public function assertJobQueuedWith( string $message = '', ): void { $jobs = TestQueueClient::getQueuedJobsByClass($jobClass); - $this->assertNotEmpty($jobs, "Job {$jobClass} was not queued"); + $this->assertNotEmpty($jobs, sprintf('Job %s was not queued', $jobClass)); $found = false; foreach ($jobs as $job) { @@ -149,7 +149,7 @@ public function assertJobQueuedWith( $this->assertTrue( $found, - $message ?: "Job {$jobClass} was not queued with expected data", + $message ?: sprintf('Job %s was not queued with expected data', $jobClass), ); } @@ -177,7 +177,7 @@ public function assertJobQueuedToQueue( $this->assertTrue( $found, - $message ?: "Job {$jobClass} was not queued to queue {$queue}", + $message ?: sprintf('Job %s was not queued to queue %s', $jobClass, $queue), ); } @@ -195,7 +195,7 @@ public function assertJobQueuedWithDelay( string $message = '', ): void { $jobs = TestQueueClient::getQueuedJobsByClass($jobClass); - $this->assertNotEmpty($jobs, "Job {$jobClass} was not queued"); + $this->assertNotEmpty($jobs, sprintf('Job %s was not queued', $jobClass)); $found = false; foreach ($jobs as $job) { @@ -207,7 +207,7 @@ public function assertJobQueuedWithDelay( $this->assertTrue( $found, - $message ?: "Job {$jobClass} was not queued with delay {$delay}", + $message ?: sprintf('Job %s was not queued with delay %d', $jobClass, $delay), ); } @@ -225,7 +225,7 @@ public function assertJobQueuedWithPriority( string $message = '', ): void { $jobs = TestQueueClient::getQueuedJobsByClass($jobClass); - $this->assertNotEmpty($jobs, "Job {$jobClass} was not queued"); + $this->assertNotEmpty($jobs, sprintf('Job %s was not queued', $jobClass)); $found = false; foreach ($jobs as $job) { @@ -237,7 +237,7 @@ public function assertJobQueuedWithPriority( $this->assertTrue( $found, - $message ?: "Job {$jobClass} was not queued with priority {$priority}", + $message ?: sprintf('Job %s was not queued with priority %s', $jobClass, $priority), ); } diff --git a/src/TestSuite/TestQueueClient.php b/src/TestSuite/TestQueueClient.php index aa3e554..584a9dc 100644 --- a/src/TestSuite/TestQueueClient.php +++ b/src/TestSuite/TestQueueClient.php @@ -42,8 +42,6 @@ class TestQueueClient /** * Transport registration flag - * - * @var bool */ protected static bool $registered = false; @@ -120,6 +118,7 @@ public static function captureMessage( } elseif ($destination instanceof Topic) { $queueName = $destination->getTopicName(); } + $queueName = $requeueOptions['queue'] ?? $queueName; $properties = $message->getProperties(); diff --git a/src/TestSuite/Transport/TestConsumer.php b/src/TestSuite/Transport/TestConsumer.php index 783ed88..ca2bf70 100644 --- a/src/TestSuite/Transport/TestConsumer.php +++ b/src/TestSuite/Transport/TestConsumer.php @@ -16,8 +16,6 @@ class TestConsumer implements Consumer { /** * Queue - * - * @var \Interop\Queue\Queue */ protected Queue $queue; diff --git a/src/TestSuite/Transport/TestContext.php b/src/TestSuite/Transport/TestContext.php index d0b1781..e29b9d9 100644 --- a/src/TestSuite/Transport/TestContext.php +++ b/src/TestSuite/Transport/TestContext.php @@ -21,8 +21,6 @@ class TestContext implements Context { /** * Cached producer instance - * - * @var \Interop\Queue\Producer|null */ protected ?Producer $producer = null; @@ -68,7 +66,7 @@ public function createTopic(string $name): Topic */ public function createProducer(): Producer { - if ($this->producer === null) { + if (!$this->producer instanceof Producer) { $this->producer = new TestProducer(); } diff --git a/src/TestSuite/Transport/TestDestination.php b/src/TestSuite/Transport/TestDestination.php index 47618de..0389618 100644 --- a/src/TestSuite/Transport/TestDestination.php +++ b/src/TestSuite/Transport/TestDestination.php @@ -15,8 +15,6 @@ class TestDestination implements Queue, Topic { /** * Destination name - * - * @var string */ protected string $name; diff --git a/src/TestSuite/Transport/TestDriver.php b/src/TestSuite/Transport/TestDriver.php index b70bb2c..f6ced19 100644 --- a/src/TestSuite/Transport/TestDriver.php +++ b/src/TestSuite/Transport/TestDriver.php @@ -3,11 +3,8 @@ namespace Cake\Queue\TestSuite\Transport; -use Enqueue\Client\Config; use Enqueue\Client\Driver\GenericDriver; use Enqueue\Client\DriverInterface; -use Enqueue\Client\RouteCollection; -use Interop\Queue\Context; /** * Test Driver @@ -16,15 +13,4 @@ */ class TestDriver extends GenericDriver implements DriverInterface { - /** - * Constructor - * - * @param \Interop\Queue\Context $context Context - * @param \Enqueue\Client\Config $config Client config - * @param \Enqueue\Client\RouteCollection $routes Route collection - */ - public function __construct(Context $context, Config $config, RouteCollection $routes) - { - parent::__construct($context, $config, $routes); - } } diff --git a/src/TestSuite/Transport/TestMessage.php b/src/TestSuite/Transport/TestMessage.php index 0fce319..2eaa341 100644 --- a/src/TestSuite/Transport/TestMessage.php +++ b/src/TestSuite/Transport/TestMessage.php @@ -14,8 +14,6 @@ class TestMessage implements Message { /** * Message body - * - * @var string */ protected string $body; diff --git a/src/TestSuite/Transport/TestProducer.php b/src/TestSuite/Transport/TestProducer.php index f019089..75b433f 100644 --- a/src/TestSuite/Transport/TestProducer.php +++ b/src/TestSuite/Transport/TestProducer.php @@ -17,22 +17,16 @@ class TestProducer implements Producer { /** * Delivery delay - * - * @var int|null */ protected ?int $deliveryDelay = null; /** * Time to live - * - * @var int|null */ protected ?int $timeToLive = null; /** * Priority - * - * @var int|null */ protected ?int $priority = null; From 740258fad7549200c62e0c45a37961ea561c8e61 Mon Sep 17 00:00:00 2001 From: Jamison Bryant Date: Thu, 28 May 2026 21:40:14 -0400 Subject: [PATCH 84/90] Merge pull request #187 from cakephp/add-security-policy Add security policy --- .github/SECURITY.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/SECURITY.md diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..652863c --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,24 @@ +# Security Policy + +## Supported Versions + +Security fixes are applied to all active versions listed in the +[version map](https://github.com/cakephp/queue/wiki#cakephp-version-map). +Versions marked as EOL no longer receive fixes. + +## Reporting a Vulnerability + +If you've found a security issue in the CakePHP Queue plugin, please use the following procedure +instead of the normal bug reporting system. Instead of using the bug tracker, +or one of the support forums please send an email to security [at] cakephp.org. Emails +sent to this address go to the CakePHP core team on a private mailing list. + +For each report, we try to first confirm the vulnerability. Once confirmed, +the CakePHP team will take the following actions: + +* Acknowledge to the reporter that we've received the issue, and are + working on a fix. We ask that the reporter keep the issue confidential until we announce it. +* Get a fix/patch prepared. +* Prepare a post describing the vulnerability, and the possible exploits. +* Release new versions of all affected versions. +* Prominently feature the problem in the release announcement From 13890591e248acc8824becb24ba1939fa061bd34 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Sun, 7 Jun 2026 11:10:31 -0400 Subject: [PATCH 85/90] Fix getUniqueId with array data The QueueManager::getUniqueId() should not drop keys when sorting to create canonical data shapes for idempotency keys. Dropping keys allows data with different key semantics to be the 'same'. Instead we should recursively sort data by key. Thanks to Volker Dusch and the PHP Ecosystem security team for reporting this. --- src/QueueManager.php | 20 +++++++++++++++- tests/TestCase/QueueManagerTest.php | 36 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/QueueManager.php b/src/QueueManager.php index 976fd6c..65e5096 100644 --- a/src/QueueManager.php +++ b/src/QueueManager.php @@ -304,7 +304,7 @@ public static function push(string|array $className, array $data = [], array $op */ public static function getUniqueId(string $class, string $method, array $data): string { - sort($data); + $data = static::sortUniqueValues($data); $hashInput = implode('', [ $class, @@ -314,4 +314,22 @@ public static function getUniqueId(string $class, string $method, array $data): return hash('md5', $hashInput); } + + /** + * Recursively sort an array by key + * + * @param array $data The data to sort + * @return array The sorted array + */ + protected static function sortUniqueValues(array $data): array + { + foreach ($data as $key => $value) { + if (is_array($value)) { + $data[$key] = static::sortUniqueValues($value); + } + } + ksort($data); + + return $data; + } } diff --git a/tests/TestCase/QueueManagerTest.php b/tests/TestCase/QueueManagerTest.php index 85c8474..c584751 100644 --- a/tests/TestCase/QueueManagerTest.php +++ b/tests/TestCase/QueueManagerTest.php @@ -59,6 +59,42 @@ public function tearDown(): void array_map('unlink', glob($this->fsQueuePath . DS . '*')); } + public function testGetUniqueId() + { + $first = QueueManager::getUniqueId('Example', 'hello', [1, 2, 3]); + $second = QueueManager::getUniqueId('Example', 'hello', [3, 2, 1]); + $this->assertNotEquals($first, $second, 'values are sorted by key'); + + $second = QueueManager::getUniqueId('Human', 'hello', [3, 2, 1]); + $this->assertNotEquals($first, $second, 'class changes hash'); + + $second = QueueManager::getUniqueId('Example', 'bye', [3, 2, 1]); + $this->assertNotEquals($first, $second, 'method changes hash'); + + $first = QueueManager::getUniqueId('Example', 'hello', ['user_id' => 'admin', 'role' => 'guest']); + $second = QueueManager::getUniqueId('Example', 'hello', ['user_id' => 'admin', 'role' => 'guest']); + $this->assertSame($first, $second, 'same values and keys are the same'); + + $second = QueueManager::getUniqueId('Example', 'hello', ['role' => 'guest', 'user_id' => 'admin']); + $this->assertSame($first, $second, 'reordered keys are the same'); + + $first = QueueManager::getUniqueId('Example', 'hello', ['user_id' => 'admin', 'role' => 'guest']); + $second = QueueManager::getUniqueId('Example', 'hello', ['user_id' => 'admin', 'role' => 'admin']); + $this->assertNotEquals($first, $second, 'different values are distinct'); + + $first = QueueManager::getUniqueId('Example', 'hello', ['foo' => 'admin', 'bar' => 'guest']); + $second = QueueManager::getUniqueId('Example', 'hello', ['user_id' => 'admin', 'role' => 'guest']); + $this->assertNotEquals($first, $second, 'different keys, same values are distinct'); + + $first = QueueManager::getUniqueId('Example', 'hello', ['foo' => 'foo', 'bar' => 'foo']); + $second = QueueManager::getUniqueId('Example', 'hello', ['user_id' => 'admin', 'role' => 'guest']); + $this->assertNotEquals($first, $second, 'different keys and values, are distinct'); + + $first = QueueManager::getUniqueId('Example', 'hello', ['arr' => ['a' => 1, 'b' => 2]]); + $second = QueueManager::getUniqueId('Example', 'hello', ['arr' => ['b' => 2, 'a' => 1]]); + $this->assertEquals($first, $second, 'nested arrays are sorted too'); + } + public function testSetConfig() { QueueManager::setConfig('test', [ From ddb57b4758f8429f6d3eb65c8f39a024e7a69a68 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Mon, 8 Jun 2026 00:20:58 -0400 Subject: [PATCH 86/90] Improve types --- src/QueueManager.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/QueueManager.php b/src/QueueManager.php index 65e5096..9a8bd89 100644 --- a/src/QueueManager.php +++ b/src/QueueManager.php @@ -318,8 +318,8 @@ public static function getUniqueId(string $class, string $method, array $data): /** * Recursively sort an array by key * - * @param array $data The data to sort - * @return array The sorted array + * @param array $data The data to sort + * @return array The sorted array */ protected static function sortUniqueValues(array $data): array { @@ -328,6 +328,7 @@ protected static function sortUniqueValues(array $data): array $data[$key] = static::sortUniqueValues($value); } } + ksort($data); return $data; From a0fb09692e56af7097ee6627567b1faba93e9f52 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:23:24 +0200 Subject: [PATCH 87/90] Bump actions/checkout from 6 to 7 (#189) Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/deploy_docs_1x.yml | 2 +- .github/workflows/deploy_docs_2x.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy_docs_1x.yml b/.github/workflows/deploy_docs_1x.yml index 420424a..b6f78a4 100644 --- a/.github/workflows/deploy_docs_1x.yml +++ b/.github/workflows/deploy_docs_1x.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Cloning repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/deploy_docs_2x.yml b/.github/workflows/deploy_docs_2x.yml index 66ef203..48be028 100644 --- a/.github/workflows/deploy_docs_2x.yml +++ b/.github/workflows/deploy_docs_2x.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Cloning repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 From a2047074eb2028f694f1c7abf78e1e57912dee92 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:40:37 +0200 Subject: [PATCH 88/90] Bump postcss from 8.5.8 to 8.5.15 in /docs (#190) Bumps [postcss](https://github.com/postcss/postcss) from 8.5.8 to 8.5.15. - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.15) --- updated-dependencies: - dependency-name: postcss dependency-version: 8.5.15 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 8236c3b..7a08ceb 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -1603,9 +1603,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "funding": [ { "type": "github", @@ -1662,9 +1662,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -1681,7 +1681,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, From 1df9d885e4ffa4c7956aac4cf2b906a69826024c Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Wed, 1 Jul 2026 07:53:05 +0200 Subject: [PATCH 89/90] npm update for docs --- docs/package-lock.json | 765 ++++++++++++++++++++++------------------- 1 file changed, 402 insertions(+), 363 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 7a08ceb..8bceb60 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -9,35 +9,35 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@cakephp/docs-skeleton": "https://github.com/cakephp/docs-skeleton.git#node-package", + "@cakephp/docs-skeleton": "git+ssh://git@github.com:cakephp/docs-skeleton.git#node-package", "vitepress": "^2.0.0-alpha.16" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -47,13 +47,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -70,27 +70,27 @@ } }, "node_modules/@docsearch/css": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.6.2.tgz", - "integrity": "sha512-fH/cn8BjEEdM2nJdjNMHIvOVYupG6AIDtFVDgIZrNzdCSj4KXr9kd+hsehqsNGYjpUjObeKYKvgy/IwCb1jZYQ==", + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.6.3.tgz", + "integrity": "sha512-nlOwcXcsNAptQl4vlL4MA78qNJKO0Qlds5GuBjCoePgkebTXLSf8Qt1oyZ3YBshYupKXG9VRGEsk1zr23d+bzQ==", "license": "MIT" }, "node_modules/@docsearch/js": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-4.6.2.tgz", - "integrity": "sha512-qj1yoxl3y4GKoK7+VM6fq/rQqPnvUmg3IKzJ9x0VzN14QVzdB/SG/J6VfV1BWT5RcPUFxIcVwoY1fwHM2fSRRw==", + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-4.6.3.tgz", + "integrity": "sha512-qUIX2b4Apew3tv4F0qhmgShsl/Lfw4m6mqv/5/5dWNxwTcDdLMp2s3YwZ+NMGh3IKCg0pBaXm7Q5VdyU5Rj+cQ==", "license": "MIT" }, "node_modules/@docsearch/sidepanel-js": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/@docsearch/sidepanel-js/-/sidepanel-js-4.6.2.tgz", - "integrity": "sha512-Pni85AP/GwRj7fFg8cBJp0U04tzbueBvWSd3gysgnOsVnQVSZwSYncfErUScLE1CAtR+qocPDFjmYR9AMRNJtQ==", + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/@docsearch/sidepanel-js/-/sidepanel-js-4.6.3.tgz", + "integrity": "sha512-grGSmvXzG0if+mrzdIKykvpIAuEQ9u0sEJ2eLRRCaQfJvsWqh2C2/aY04bIzWvDh7myi5rvl8D+tUNsVrjYQ3A==", "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -104,9 +104,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -120,9 +120,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -136,9 +136,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -152,9 +152,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -168,9 +168,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -184,9 +184,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -200,9 +200,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -216,9 +216,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -232,9 +232,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -248,9 +248,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -264,9 +264,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -280,9 +280,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -296,9 +296,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -312,9 +312,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -328,9 +328,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -344,9 +344,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -360,9 +360,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -376,9 +376,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -392,9 +392,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -408,9 +408,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -424,9 +424,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -440,9 +440,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -456,9 +456,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -472,9 +472,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -488,9 +488,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -504,9 +504,9 @@ } }, "node_modules/@iconify-json/simple-icons": { - "version": "1.2.76", - "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.76.tgz", - "integrity": "sha512-lLRlA8yaf+1L5VCPRvR9lynoSklsddKHEylchmZJKdj/q2xVQ1ZAEJ8SCQlv9cbgtMefnlyM98U+8Si2aoFZPA==", + "version": "1.2.88", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.88.tgz", + "integrity": "sha512-+cvi1qCuvReL29ehi6t62L4fb7GDXe+UlGHFcsJcV7I2l9wtqn9XE2IBKcDr3CI5iGUGS5ISnXv699pSGpyx1Q==", "license": "CC0-1.0", "dependencies": { "@iconify/types": "*" @@ -525,15 +525,15 @@ "license": "MIT" }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.2", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", - "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", "cpu": [ "arm" ], @@ -544,9 +544,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", "cpu": [ "arm64" ], @@ -557,9 +557,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", "cpu": [ "arm64" ], @@ -570,9 +570,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", "cpu": [ "x64" ], @@ -583,9 +583,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", "cpu": [ "arm64" ], @@ -596,9 +596,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", "cpu": [ "x64" ], @@ -609,12 +609,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -622,12 +625,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", "cpu": [ "arm" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -635,12 +641,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -648,12 +657,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -661,12 +673,15 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", "cpu": [ "loong64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -674,12 +689,15 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", "cpu": [ "loong64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -687,12 +705,15 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -700,12 +721,15 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", "cpu": [ "ppc64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -713,12 +737,15 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -726,12 +753,15 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", "cpu": [ "riscv64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -739,12 +769,15 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -752,12 +785,15 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -765,12 +801,15 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -778,9 +817,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", "cpu": [ "x64" ], @@ -791,9 +830,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", "cpu": [ "arm64" ], @@ -804,9 +843,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", "cpu": [ "arm64" ], @@ -817,9 +856,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", "cpu": [ "ia32" ], @@ -830,9 +869,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", "cpu": [ "x64" ], @@ -843,9 +882,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", "cpu": [ "x64" ], @@ -933,9 +972,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, "node_modules/@types/hast": { @@ -991,18 +1030,18 @@ "license": "MIT" }, "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", + "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", "license": "ISC" }, "node_modules/@vitejs/plugin-vue": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz", - "integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==", + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", + "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==", "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.2" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -1013,141 +1052,141 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", - "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.2", - "@vue/shared": "3.5.32", + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", - "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.32", - "@vue/shared": "3.5.32" + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", - "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.2", - "@vue/compiler-core": "3.5.32", - "@vue/compiler-dom": "3.5.32", - "@vue/compiler-ssr": "3.5.32", - "@vue/shared": "3.5.32", + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", - "postcss": "^8.5.8", + "postcss": "^8.5.15", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", - "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.32", - "@vue/shared": "3.5.32" + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" } }, "node_modules/@vue/devtools-api": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.1.tgz", - "integrity": "sha512-bsDMJ07b3GN1puVwJb/fyFnj/U2imyswK5UQVLZwVl7O05jDrt6BHxeG5XffmOOdasOj/bOmIjxJvGPxU7pcqw==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.5.tgz", + "integrity": "sha512-YJipMVAKe5wT5CWf5kTYCaNV7NMNjFVxJkIkJaJ4W/nCxEBzlZzrOsYKeCymdCrFZmBS/+wTWFoUs3Jf/Q6XSQ==", "license": "MIT", "dependencies": { - "@vue/devtools-kit": "^8.1.1" + "@vue/devtools-kit": "^8.1.5" } }, "node_modules/@vue/devtools-kit": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.1.tgz", - "integrity": "sha512-gVBaBv++i+adg4JpH71k9ppl4soyR7Y2McEqO5YNgv0BI1kMZ7BDX5gnwkZ5COYgiCyhejZG+yGNrBAjj6Coqg==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.5.tgz", + "integrity": "sha512-FcSAxsi4eWuXLCB7Rv9lj0aIVHHPNVQ2BazGf4RJTc2JCqb4BQg0hk87ZFhminCfl+mD5OUI0rX2cgyu4kJOGA==", "license": "MIT", "dependencies": { - "@vue/devtools-shared": "^8.1.1", + "@vue/devtools-shared": "^8.1.5", "birpc": "^2.6.1", "hookable": "^5.5.3", "perfect-debounce": "^2.0.0" } }, "node_modules/@vue/devtools-shared": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.1.tgz", - "integrity": "sha512-+h4ttmJYl/txpxHKaoZcaKpC+pvckgLzIDiSQlaQ7kKthKh8KuwoLW2D8hPJEnqKzXOvu15UHEoGyngAXCz0EQ==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.5.tgz", + "integrity": "sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ==", "license": "MIT" }, "node_modules/@vue/reactivity": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", - "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", "license": "MIT", "dependencies": { - "@vue/shared": "3.5.32" + "@vue/shared": "3.5.39" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", - "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.32", - "@vue/shared": "3.5.32" + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", - "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.32", - "@vue/runtime-core": "3.5.32", - "@vue/shared": "3.5.32", + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", - "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.32", - "@vue/shared": "3.5.32" + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" }, "peerDependencies": { - "vue": "3.5.32" + "vue": "3.5.39" } }, "node_modules/@vue/shared": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", - "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", "license": "MIT" }, "node_modules/@vueuse/core": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz", - "integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==", + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", "license": "MIT", "dependencies": { "@types/web-bluetooth": "^0.0.21", - "@vueuse/metadata": "14.2.1", - "@vueuse/shared": "14.2.1" + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" }, "funding": { "url": "https://github.com/sponsors/antfu" @@ -1157,13 +1196,13 @@ } }, "node_modules/@vueuse/integrations": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-14.2.1.tgz", - "integrity": "sha512-2LIUpBi/67PoXJGqSDQUF0pgQWpNHh7beiA+KG2AbybcNm+pTGWT6oPGlBgUoDWmYwfeQqM/uzOHqcILpKL7nA==", + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-14.3.0.tgz", + "integrity": "sha512-76I5FT2ESvCmCaSwapI+a/u/CFtNXmzl9f9lNp1hRtx8vKB8hfiokJr8IvQqcQG5ckGXElyXK516b54ozV3MvA==", "license": "MIT", "dependencies": { - "@vueuse/core": "14.2.1", - "@vueuse/shared": "14.2.1" + "@vueuse/core": "14.3.0", + "@vueuse/shared": "14.3.0" }, "funding": { "url": "https://github.com/sponsors/antfu" @@ -1223,18 +1262,18 @@ } }, "node_modules/@vueuse/metadata": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz", - "integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==", + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" } }, "node_modules/@vueuse/shared": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz", - "integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==", + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" @@ -1333,9 +1372,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -1345,32 +1384,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/estree-walker": { @@ -1397,12 +1436,12 @@ } }, "node_modules/focus-trap": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-8.0.1.tgz", - "integrity": "sha512-9ptSG6z51YQOstI/oN4XuVGP/03u2nh0g//qz7L6zX0i6PZiPnkcf3GenXq7N2hZnASXaMxTPpbKwdI+PFvxlw==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-8.2.2.tgz", + "integrity": "sha512-qV0g8hRYBqgACcFOH3f9wXc4zPKhr/0z9RI2a6ZijZ72EeBi4g8oBy8zAWuUR1TsMpOzwpUMFvjdasrC41Joug==", "license": "MIT", "dependencies": { - "tabbable": "^6.4.0" + "tabbable": "^6.5.0" } }, "node_modules/fsevents": { @@ -1621,18 +1660,18 @@ } }, "node_modules/oniguruma-parser": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", - "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", "license": "MIT" }, "node_modules/oniguruma-to-es": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz", - "integrity": "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", "license": "MIT", "dependencies": { - "oniguruma-parser": "^0.12.1", + "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } @@ -1662,9 +1701,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "funding": [ { "type": "opencollective", @@ -1690,9 +1729,9 @@ } }, "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", "license": "MIT", "funding": { "type": "github", @@ -1724,12 +1763,12 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -1739,31 +1778,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.1", - "@rollup/rollup-android-arm64": "4.60.1", - "@rollup/rollup-darwin-arm64": "4.60.1", - "@rollup/rollup-darwin-x64": "4.60.1", - "@rollup/rollup-freebsd-arm64": "4.60.1", - "@rollup/rollup-freebsd-x64": "4.60.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", - "@rollup/rollup-linux-arm-musleabihf": "4.60.1", - "@rollup/rollup-linux-arm64-gnu": "4.60.1", - "@rollup/rollup-linux-arm64-musl": "4.60.1", - "@rollup/rollup-linux-loong64-gnu": "4.60.1", - "@rollup/rollup-linux-loong64-musl": "4.60.1", - "@rollup/rollup-linux-ppc64-gnu": "4.60.1", - "@rollup/rollup-linux-ppc64-musl": "4.60.1", - "@rollup/rollup-linux-riscv64-gnu": "4.60.1", - "@rollup/rollup-linux-riscv64-musl": "4.60.1", - "@rollup/rollup-linux-s390x-gnu": "4.60.1", - "@rollup/rollup-linux-x64-gnu": "4.60.1", - "@rollup/rollup-linux-x64-musl": "4.60.1", - "@rollup/rollup-openbsd-x64": "4.60.1", - "@rollup/rollup-openharmony-arm64": "4.60.1", - "@rollup/rollup-win32-arm64-msvc": "4.60.1", - "@rollup/rollup-win32-ia32-msvc": "4.60.1", - "@rollup/rollup-win32-x64-gnu": "4.60.1", - "@rollup/rollup-win32-x64-msvc": "4.60.1", + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" } }, @@ -1817,19 +1856,19 @@ } }, "node_modules/tabbable": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", - "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -1945,12 +1984,12 @@ } }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -2065,16 +2104,16 @@ } }, "node_modules/vue": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", - "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.32", - "@vue/compiler-sfc": "3.5.32", - "@vue/runtime-dom": "3.5.32", - "@vue/server-renderer": "3.5.32", - "@vue/shared": "3.5.32" + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" }, "peerDependencies": { "typescript": "*" From 6e7122e8bd2653ba2d882adc159bade7bf76bd49 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:58:06 +0200 Subject: [PATCH 90/90] Bump postcss from 8.5.16 to 8.5.25 in /docs (#191) Bumps [postcss](https://github.com/postcss/postcss) from 8.5.16 to 8.5.25. - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.16...8.5.25) --- updated-dependencies: - dependency-name: postcss dependency-version: 8.5.25 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package-lock.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 8bceb60..699d31e 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@cakephp/docs-skeleton": "git+ssh://git@github.com:cakephp/docs-skeleton.git#node-package", + "@cakephp/docs-skeleton": "https://github.com/cakephp/docs-skeleton.git#node-package", "vitepress": "^2.0.0-alpha.16" } }, @@ -1642,9 +1642,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -1701,9 +1701,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "funding": [ { "type": "opencollective", @@ -1720,7 +1720,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" },