diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 404a31c..4309d06 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -67,17 +67,8 @@ jobs: - { queue: 'github-actions-laravel10-php83', laravel: '10.*', php: '8.3', 'testbench': '8.*'} - { queue: 'github-actions-laravel10-php82', laravel: '10.*', php: '8.2', 'testbench': '8.*'} - { queue: 'github-actions-laravel10-php81', laravel: '10.*', php: '8.1', 'testbench': '8.*'} - - { queue: 'github-actions-laravel9-php83', laravel: '9.*', php: '8.3', 'testbench': '7.*'} - - { queue: 'github-actions-laravel9-php82', laravel: '9.*', php: '8.2', 'testbench': '7.*'} - - { queue: 'github-actions-laravel9-php81', laravel: '9.*', php: '8.1', 'testbench': '7.*'} - - { queue: 'github-actions-laravel9-php80', laravel: '9.*', php: '8.0', 'testbench': '7.*'} - - { queue: 'github-actions-laravel8-php81', laravel: '8.*', php: '8.1', 'testbench': '6.*'} - - { queue: 'github-actions-laravel8-php80', laravel: '8.*', php: '8.0', 'testbench': '6.*'} - - { queue: 'github-actions-laravel8-php74', laravel: '8.*', php: '7.4', 'testbench': '6.*'} - - { queue: 'github-actions-laravel7-php80', laravel: '7.*', php: '8.0', 'testbench': '5.*' } - - { queue: 'github-actions-laravel7-php74', laravel: '7.*', php: '7.4', 'testbench': '5.*' } - - { queue: 'github-actions-laravel6-php80', laravel: '6.*', php: '8.0', 'testbench': '4.*' } - - { queue: 'github-actions-laravel6-php74', laravel: '6.*', php: '7.4', 'testbench': '4.*' } + - { queue: 'github-actions-laravel11-php82', laravel: '11.*', php: '8.2', 'testbench': '9.*' } + - { queue: 'github-actions-laravel12-php83', laravel: '11.*', php: '8.3', 'testbench': '9.*' } name: PHP ${{ matrix.payload.php }} - Laravel ${{ matrix.payload.laravel }} - DB ${{ matrix.db }} diff --git a/README.md b/README.md index fb60d8f..4b42847 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- +

Build Status @@ -12,161 +12,147 @@ This package allows Google Cloud Tasks to be used as the queue driver.

- +

-
- - Requirements - +### Requirements -
- This package requires Laravel 6 or higher and supports MySQL 8 and PostgreSQL 14. Might support older database versions too, but package hasn't been tested for it. +This package requires Laravel 10 or 11. -Please check the [Laravel support policy](https://laravel.com/docs/master/releases#support-policy) table for supported Laravel and PHP versions. -
-
- Installation -
- - Require the package using Composer - - ```console - composer require stackkit/laravel-google-cloud-tasks-queue - ``` - - Add a new queue connection to `config/queue.php` - - ```php - 'cloudtasks' => [ - 'driver' => 'cloudtasks', - 'project' => env('STACKKIT_CLOUD_TASKS_PROJECT', ''), - 'location' => env('STACKKIT_CLOUD_TASKS_LOCATION', ''), - 'queue' => env('STACKKIT_CLOUD_TASKS_QUEUE', 'default'), - - // Required when using AppEngine - 'app_engine' => env('STACKKIT_APP_ENGINE_TASK', false), - 'app_engine_service' => env('STACKKIT_APP_ENGINE_SERVICE', ''), - - // Required when not using AppEngine - 'handler' => env('STACKKIT_CLOUD_TASKS_HANDLER', ''), - 'service_account_email' => env('STACKKIT_CLOUD_TASKS_SERVICE_EMAIL', ''), - 'signed_audience' => env('STACKKIT_CLOUD_TASKS_SIGNED_AUDIENCE', true), - - // Optional: The deadline in seconds for requests sent to the worker. If the worker - // does not respond by this deadline then the request is cancelled and the attempt - // is marked as a DEADLINE_EXCEEDED failure. - 'dispatch_deadline' => null, - 'backoff' => 0, - ], - ``` - -Update the `QUEUE_CONNECTION` environment variable - - ```dotenv - QUEUE_CONNECTION=cloudtasks - ``` +### Installation + +Require the package using Composer + +```console +composer require stackkit/laravel-google-cloud-tasks-queue +``` + +Publish the service provider: + +```console +php artisan vendor:publish --provider=cloud-tasks +``` + +Add a new queue connection to `config/queue.php` + +```php +'cloudtasks' => [ + 'driver' => 'cloudtasks', + 'project' => env('CLOUD_TASKS_PROJECT', ''), + 'location' => env('CLOUD_TASKS_LOCATION', ''), + 'queue' => env('CLOUD_TASKS_QUEUE', 'default'), + + // Required when using AppEngine + 'app_engine' => env('APP_ENGINE_TASK', false), + 'app_engine_service' => env('APP_ENGINE_SERVICE', ''), + + // Required when not using AppEngine + 'handler' => env('CLOUD_TASKS_HANDLER', ''), + 'service_account_email' => env('CLOUD_TASKS_SERVICE_EMAIL', ''), + + 'backoff' => 0, +], +``` + +If you are using separate services for dispatching and handling tasks, and your application only dispatches jobs and should not be able to handle jobs, you may disable the task handler from `config/cloud-tasks.php`: + +```php +'disable_task_handler' => env('CLOUD_TASKS_DISABLE_TASK_HANDLER', false), +``` + +Finally, change the `QUEUE_CONNECTION` to the newly defined connection. + +```dotenv +QUEUE_CONNECTION=cloudtasks +``` Now that the package is installed, the final step is to set the correct environment variables. Please check the table below on what the values mean and what their value should be. -| Environment variable | Description |Example ----------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|--- -| `STACKKIT_CLOUD_TASKS_PROJECT` | The project your queue belongs to. |`my-project` -| `STACKKIT_CLOUD_TASKS_LOCATION` | The region where the project is hosted. |`europe-west6` -| `STACKKIT_CLOUD_TASKS_QUEUE` | The default queue a job will be added to. |`emails` -| **App Engine** -| `STACKKIT_APP_ENGINE_TASK` (optional) | Set to true to use App Engine task (else a Http task will be used). Defaults to false. |`true` -| `STACKKIT_APP_ENGINE_SERVICE` (optional) | The App Engine service to handle the task (only if using App Engine task). |`api` -| **Non- App Engine apps** -| `STACKKIT_CLOUD_TASKS_SERVICE_EMAIL` (optional) | The email address of the service account. Important, it should have the correct roles. See the section below which roles. |`my-service-account@appspot.gserviceaccount.com` -| `STACKKIT_CLOUD_TASKS_HANDLER` (optional) | The URL that Cloud Tasks will call to process a job. This should be the URL to your Laravel app. By default we will use the URL that dispatched the job. |`https://.com` -| `STACKKIT_CLOUD_TASKS_SIGNED_AUDIENCE` (optional) | True or false depending if you want extra security by signing the audience of your tasks. May misbehave in certain Cloud Run setups. Defaults to true. | `true` +| Environment variable | Description | Example +---------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------- +| `CLOUD_TASKS_PROJECT` | The project your queue belongs to. | `my-project` +| `CLOUD_TASKS_LOCATION` | The region where the project is hosted. | `europe-west6` +| `CLOUD_TASKS_QUEUE` | The default queue a job will be added to. | `emails` +| **App Engine** +| `APP_ENGINE_TASK` (optional) | Set to true to use App Engine task (else a Http task will be used). Defaults to false. | `true` +| `APP_ENGINE_SERVICE` (optional) | The App Engine service to handle the task (only if using App Engine task). | `api` +| **Non- App Engine apps** +| `CLOUD_TASKS_SERVICE_EMAIL` (optional) | The email address of the service account. Important, it should have the correct roles. See the section below which roles. | `my-service-account@appspot.gserviceaccount.com` +| `CLOUD_TASKS_HANDLER` (optional) | The URL that Cloud Tasks will call to process a job. This should be the URL to your Laravel app. By default we will use the URL that dispatched the job. | `https://.com` +
-
- - How it works & Differences - -
- Using Cloud Tasks as a Laravel queue driver is fundamentally different than other Laravel queue drivers, like Redis. -Typically a Laravel queue has a worker that listens to incoming jobs using the `queue:work` / `queue:listen` command. -With Cloud Tasks, this is not the case. Instead, Cloud Tasks will schedule the job for you and make an HTTP request to your application with the job payload. There is no need to run a `queue:work/listen` command. +### How to -#### Good to know +#### Passing headers to a task -- The "Min backoff" and "Max backoff" options in Cloud Tasks are ignored. This is intentional: Laravel has its own backoff feature (which is more powerful than what Cloud Tasks offers) and therefore I have chosen that over the Cloud Tasks one. -- Similarly to the backoff feature, I have also chosen to let the package do job retries the 'Laravel way'. In Cloud Tasks, when a task throws an exception, Cloud Tasks will decide for itself when to retry the task (based on the backoff values). It will also manage its own state and knows how many times a task has been retried. This is different from Laravel. In typical Laravel queues, when a job throws an exception, the job is deleted and released back onto the queue. In order to support Laravel's backoff feature, this package must behave the same way about job retries. +You can pass headers to a task by using the `setTaskHeadersUsing` method on the `CloudTasksQueue` class. -
-
- Dashboard (beta) -
- The package comes with a beautiful dashboard that can be used to monitor all queued jobs. +```php +use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksQueue; +CloudTasksQueue::setTaskHeadersUsing(static fn() => [ + 'X-My-Header' => 'My-Value', +]); +``` - +If necessary, the current payload being dispatched is also available: - --- +```php +use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksQueue; -_Experimental_ +CloudTasksQueue::setTaskHeadersUsing(static fn(array $payload) => [ + 'X-My-Header' => $payload['displayName'], +]); +``` -The dashboard works by storing all outgoing tasks in a database table. When Cloud Tasks calls the application and this -package handles the task, we will automatically update the tasks' status, attempts -and possible errors. +#### Configure task handler url -There is probably a (small) performance penalty because each task dispatch and handling does extra database read and writes. -Also, the dashboard has not been tested with high throughput queues. +You can set the handler url for a task by using the `configureHandlerUrlUsing` method on the `CloudTasksQueue` class. - --- +```php +use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksQueue; +CloudTasksQueue::configureHandlerUrlUsing(static fn() => 'https://example.com/my-url'); +``` -To make use of it, enable it through the `.env` file: +If necessary, the current job being dispatched is also available: - ```dotenv - STACKKIT_CLOUD_TASKS_DASHBOARD_ENABLED=true - STACKKIT_CLOUD_TASKS_DASHBOARD_PASSWORD=MySecretLoginPasswordPleaseChangeThis - ``` +```php +use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksQueue; -Then publish its assets and migrations: +CloudTasksQueue::configureHandlerUrlUsing(static fn(MyJob $job) => 'https://example.com/my-url/' . $job->something()); +``` - ```console - php artisan vendor:publish --tag=cloud-tasks - php artisan migrate - ``` +### How it works and differences -The dashboard is accessible at the URI: /cloud-tasks +Using Cloud Tasks as a Laravel queue driver is fundamentally different than other Laravel queue drivers, like Redis. -
-
- Authentication -
+Typically a Laravel queue has a worker that listens to incoming jobs using the `queue:work` / `queue:listen` command. +With Cloud Tasks, this is not the case. Instead, Cloud Tasks will schedule the job for you and make an HTTP request to +your application with the job payload. There is no need to run a `queue:work/listen` command. + +#### Good to know + +Cloud Tasks has it's own retry configuration options: maximum number of attempts, retry duration, min/max backoff and max doublings. All of these options are ignored by this package. Instead, you may configure max attempts, retry duration and backoff strategy right from Laravel. + +### Authentication Set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable with a path to the credentials file. More info: https://cloud.google.com/docs/authentication/production -If you're not using your master service account (which has all abilities), you must add the following roles to make it works: +If you're not using your master service account (which has all abilities), you must add the following roles to make it +works: + 1. App Engine Viewer 2. Cloud Tasks Enqueuer 3. Cloud Tasks Viewer 4. Cloud Tasks Task Deleter 5. Service Account User -
-
- Security -
- The job handler requires each request to have an OpenID token. In the installation step we set the service account email, and with that service account, Cloud Tasks will generate an OpenID token and send it along with the job payload to the handler. -This package verifies that the token is digitally signed by Google. Only Google Tasks will be able to call your handler. +### Upgrading -More information about OpenID Connect: - -https://developers.google.com/identity/protocols/oauth2/openid-connect -
-
- Upgrading -
- Read [UPGRADING.MD](UPGRADING.md) on how to update versions. -
+Read [UPGRADING.MD](UPGRADING.md) on how to update versions. diff --git a/UPGRADING.md b/UPGRADING.md index 93d0a71..f68380c 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,3 +1,41 @@ +# From 3.x to 4.x + +## Renamed environment names (Impact: high) + +The following environment variables have been shortened: +- `STACKKIT_CLOUD_TASKS_PROJECT` → `CLOUD_TASKS_PROJECT` +- `STACKKIT_CLOUD_TASKS_LOCATION` → `CLOUD_TASKS_LOCATION` +- `STACKKIT_CLOUD_TASKS_QUEUE` → `CLOUD_TASKS_QUEUE` +- `STACKKIT_CLOUD_TASKS_HANDLER` → `CLOUD_TASKS_HANDLER` +- `STACKKIT_CLOUD_TASKS_SERVICE_EMAIL` → `CLOUD_TASKS_SERVICE_EMAIL` + +The following environment variables have been renamed to be more consistent: + +- `STACKKIT_APP_ENGINE_TASK` → `CLOUD_TASKS_APP_ENGINE_TASK` +- `STACKKIT_APP_ENGINE_SERVICE` → `CLOUD_TASKS_APP_ENGINE_SERVICE` + +The following environment variable has been removed: +- `STACKKIT_CLOUD_TASKS_SIGNED_AUDIENCE` + +## Removed dashboard (Impact: high) + +The dashboard has been removed to keep the package minimal. A separate composer package might be created with an updated version of the dashboard. + +## New configuration file (Impact: medium) + +The configuration file has been updated to reflect the removed dashboard and to add new configurable options. + +Please publish the new configuration file: + +```shell +php artisan vendor:publish --tag=cloud-tasks --force +``` + +## Dispatch deadline (Impact: medium) + +The `dispatch_deadline` has been removed from the task configuration. You may now use Laravel's timeout configuration to control the maximum execution time of a task. + + # From 2.x to 3.x PHP 7.2 and 7.3, and Laravel 5.x are no longer supported. diff --git a/assets/dashboard.png b/assets/dashboard.png deleted file mode 100644 index 0051f17..0000000 Binary files a/assets/dashboard.png and /dev/null differ diff --git a/composer.json b/composer.json index cf4f0ba..1b5ea29 100644 --- a/composer.json +++ b/composer.json @@ -8,17 +8,18 @@ } ], "require": { + "php": "^8.1", "ext-json": "*", "phpseclib/phpseclib": "^3.0", - "google/auth": "^v1.29.1", "google/cloud-tasks": "^1.10", "thecodingmachine/safe": "^1.0|^2.0" }, "require-dev": { - "orchestra/testbench": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0", - "nunomaduro/larastan": "^1.0 || ^2.0", + "orchestra/testbench": "^8.0", "thecodingmachine/phpstan-safe-rule": "^1.2", - "laravel/legacy-factories": "^1.3" + "laravel/legacy-factories": "^1.3", + "laravel/pint": "^1.13", + "larastan/larastan": "^2.9" }, "autoload": { "psr-4": { @@ -45,21 +46,11 @@ "composer require laravel/framework:10.* orchestra/testbench:8.* --no-interaction --no-update", "composer update --prefer-stable --prefer-dist --no-interaction" ], - "l9": [ - "composer require laravel/framework:9.* orchestra/testbench:7.* --no-interaction --no-update", - "composer update --prefer-stable --prefer-dist --no-interaction" - ], - "l8": [ - "composer require laravel/framework:8.* orchestra/testbench:6.* --no-interaction --no-update", - "composer update --prefer-stable --prefer-dist --no-interaction" + "pint": [ + "pint" ], - "l7": [ - "composer require laravel/framework:7.* orchestra/testbench:5.* --no-interaction --no-update", - "composer update --prefer-stable --prefer-dist --no-interaction" - ], - "l6": [ - "composer require laravel/framework:6.* orchestra/testbench:4.* --no-interaction --no-update", - "composer update --prefer-stable --prefer-dist --no-interaction" + "larastan": [ + "@php -d memory_limit=-1 vendor/bin/phpstan" ] } } diff --git a/config/cloud-tasks.php b/config/cloud-tasks.php index c8cbdca..4a77375 100644 --- a/config/cloud-tasks.php +++ b/config/cloud-tasks.php @@ -3,8 +3,9 @@ declare(strict_types=1); return [ - 'dashboard' => [ - 'enabled' => env('STACKKIT_CLOUD_TASKS_DASHBOARD_ENABLED', false), - 'password' => env('STACKKIT_CLOUD_TASKS_DASHBOARD_PASSWORD', 'MyPassword1!') - ], + // The URI of the endpoint that will handle the task + 'uri' => env('CLOUD_TASKS_URI', 'handle-task'), + + // If the application only dispatches jobs + 'disable_task_handler' => env('CLOUD_TASKS_DISABLE_TASK_HANDLER', false), ]; diff --git a/dashboard/.env.production b/dashboard/.env.production deleted file mode 100644 index 292a14c..0000000 --- a/dashboard/.env.production +++ /dev/null @@ -1 +0,0 @@ -VITE_API_URL= diff --git a/dashboard/.gitignore b/dashboard/.gitignore deleted file mode 100644 index a84704d..0000000 --- a/dashboard/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules -.DS_Store -dist-ssr -*.local \ No newline at end of file diff --git a/dashboard/.prettierignore b/dashboard/.prettierignore deleted file mode 100644 index 85dd8c4..0000000 --- a/dashboard/.prettierignore +++ /dev/null @@ -1,6 +0,0 @@ -# Ignore artifacts: -build -coverage -.vscode -node_modules -.idea diff --git a/dashboard/.prettierrc.js b/dashboard/.prettierrc.js deleted file mode 100644 index 0614ee7..0000000 --- a/dashboard/.prettierrc.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - trailingComma: 'es5', - tabWidth: 2, - semi: false, - singleQuote: true, -} diff --git a/dashboard/.prettierrc.json b/dashboard/.prettierrc.json deleted file mode 100644 index b2095be..0000000 --- a/dashboard/.prettierrc.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "semi": false, - "singleQuote": true -} diff --git a/dashboard/README.md b/dashboard/README.md deleted file mode 100644 index c0793a8..0000000 --- a/dashboard/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Vue 3 + Vite - -This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` - - - - -
- - - diff --git a/dashboard/dist/manifest.json b/dashboard/dist/manifest.json deleted file mode 100644 index 53f0594..0000000 --- a/dashboard/dist/manifest.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "index.html": { - "file": "assets/index.ea68d73f.js", - "src": "index.html", - "isEntry": true, - "imports": [ - "_vendor.433de25e.js" - ], - "css": [ - "assets/index.d8eef428.css" - ] - }, - "_vendor.433de25e.js": { - "file": "assets/vendor.433de25e.js" - } -} \ No newline at end of file diff --git a/dashboard/index.html b/dashboard/index.html deleted file mode 100644 index 4333263..0000000 --- a/dashboard/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Vite App - - -
- - - diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json deleted file mode 100644 index 61de1ad..0000000 --- a/dashboard/package-lock.json +++ /dev/null @@ -1,2829 +0,0 @@ -{ - "name": "cloud-tasks-dashboard", - "version": "0.0.0", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "cloud-tasks-dashboard", - "version": "0.0.0", - "dependencies": { - "vue": "^3.2.25", - "vue-router": "^4.0.12", - "vue3-popper": "^1.4.1" - }, - "devDependencies": { - "@vitejs/plugin-vue": "^2.0.0", - "autoprefixer": "^10.4.2", - "postcss": "^8.4.5", - "prettier": "2.5.1", - "tailwindcss": "^3.0.18", - "vite": "^2.7.2" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", - "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", - "dev": true, - "dependencies": { - "@babel/highlight": "^7.16.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", - "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.16.10", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.16.10.tgz", - "integrity": "sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.16.7", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", - "dev": true - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/parser": { - "version": "7.16.12", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.16.12.tgz", - "integrity": "sha512-VfaV15po8RiZssrkPweyvbGVSe4x2y+aciFCgn0n0/SJMR22cwofRV1mtnJQYcSB1wUTaA/X1LnA3es66MCO5A==", - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@popperjs/core": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.2.tgz", - "integrity": "sha512-92FRmppjjqz29VMJ2dn+xdyXZBrMlE42AV6Kq6BwjWV7CNUW1hs2FtxSNLQE+gJhaZ6AAmYuO9y8dshhcBl7vA==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, - "node_modules/@types/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", - "dev": true - }, - "node_modules/@vitejs/plugin-vue": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-2.1.0.tgz", - "integrity": "sha512-AZ78WxvFMYd8JmM/GBV6a6SGGTU0GgN/0/4T+FnMMsLzFEzTeAUwuraapy50ifHZsC+G5SvWs86bvaCPTneFlA==", - "dev": true, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "vite": "^2.5.10", - "vue": "^3.2.25" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.29.tgz", - "integrity": "sha512-RePZ/J4Ub3sb7atQw6V6Rez+/5LCRHGFlSetT3N4VMrejqJnNPXKUt5AVm/9F5MJriy2w/VudEIvgscCfCWqxw==", - "dependencies": { - "@babel/parser": "^7.16.4", - "@vue/shared": "3.2.29", - "estree-walker": "^2.0.2", - "source-map": "^0.6.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.29.tgz", - "integrity": "sha512-y26vK5khdNS9L3ckvkqJk/78qXwWb75Ci8iYLb67AkJuIgyKhIOcR1E8RIt4mswlVCIeI9gQ+fmtdhaiTAtrBQ==", - "dependencies": { - "@vue/compiler-core": "3.2.29", - "@vue/shared": "3.2.29" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.29.tgz", - "integrity": "sha512-X9+0dwsag2u6hSOP/XsMYqFti/edvYvxamgBgCcbSYuXx1xLZN+dS/GvQKM4AgGS4djqo0jQvWfIXdfZ2ET68g==", - "dependencies": { - "@babel/parser": "^7.16.4", - "@vue/compiler-core": "3.2.29", - "@vue/compiler-dom": "3.2.29", - "@vue/compiler-ssr": "3.2.29", - "@vue/reactivity-transform": "3.2.29", - "@vue/shared": "3.2.29", - "estree-walker": "^2.0.2", - "magic-string": "^0.25.7", - "postcss": "^8.1.10", - "source-map": "^0.6.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.29.tgz", - "integrity": "sha512-LrvQwXlx66uWsB9/VydaaqEpae9xtmlUkeSKF6aPDbzx8M1h7ukxaPjNCAXuFd3fUHblcri8k42lfimHfzMICA==", - "dependencies": { - "@vue/compiler-dom": "3.2.29", - "@vue/shared": "3.2.29" - } - }, - "node_modules/@vue/devtools-api": { - "version": "6.0.0-beta.21.1", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.0.0-beta.21.1.tgz", - "integrity": "sha512-FqC4s3pm35qGVeXRGOjTsRzlkJjrBLriDS9YXbflHLsfA9FrcKzIyWnLXoNm+/7930E8rRakXuAc2QkC50swAw==" - }, - "node_modules/@vue/reactivity": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.2.29.tgz", - "integrity": "sha512-Ryhb6Gy62YolKXH1gv42pEqwx7zs3n8gacRVZICSgjQz8Qr8QeCcFygBKYfJm3o1SccR7U+bVBQDWZGOyG1k4g==", - "dependencies": { - "@vue/shared": "3.2.29" - } - }, - "node_modules/@vue/reactivity-transform": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.29.tgz", - "integrity": "sha512-YF6HdOuhdOw6KyRm59+3rML8USb9o8mYM1q+SH0G41K3/q/G7uhPnHGKvspzceD7h9J3VR1waOQ93CUZj7J7OA==", - "dependencies": { - "@babel/parser": "^7.16.4", - "@vue/compiler-core": "3.2.29", - "@vue/shared": "3.2.29", - "estree-walker": "^2.0.2", - "magic-string": "^0.25.7" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.2.29.tgz", - "integrity": "sha512-VMvQuLdzoTGmCwIKTKVwKmIL0qcODIqe74JtK1pVr5lnaE0l25hopodmPag3RcnIcIXe+Ye3B2olRCn7fTCgig==", - "dependencies": { - "@vue/reactivity": "3.2.29", - "@vue/shared": "3.2.29" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.2.29.tgz", - "integrity": "sha512-YJgLQLwr+SQyORzTsBQLL5TT/5UiV83tEotqjL7F9aFDIQdFBTCwpkCFvX9jqwHoyi9sJqM9XtTrMcc8z/OjPA==", - "dependencies": { - "@vue/runtime-core": "3.2.29", - "@vue/shared": "3.2.29", - "csstype": "^2.6.8" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.2.29.tgz", - "integrity": "sha512-lpiYx7ciV7rWfJ0tPkoSOlLmwqBZ9FTmQm33S+T4g0j1fO/LmhJ9b9Ctl1o5xvIFVDk9QkSUWANZn7H2pXuxVw==", - "dependencies": { - "@vue/compiler-ssr": "3.2.29", - "@vue/shared": "3.2.29" - }, - "peerDependencies": { - "vue": "3.2.29" - } - }, - "node_modules/@vue/shared": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.29.tgz", - "integrity": "sha512-BjNpU8OK6Z0LVzGUppEk0CMYm/hKDnZfYdjSmPOs0N+TR1cLKJAkDwW8ASZUvaaSLEi6d3hVM7jnWnX+6yWnHw==" - }, - "node_modules/acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-node": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", - "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", - "dev": true, - "dependencies": { - "acorn": "^7.0.0", - "acorn-walk": "^7.0.0", - "xtend": "^4.0.2" - } - }, - "node_modules/acorn-walk": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", - "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", - "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.1.tgz", - "integrity": "sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==", - "dev": true - }, - "node_modules/autoprefixer": { - "version": "10.4.2", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.2.tgz", - "integrity": "sha512-9fOPpHKuDW1w/0EKfRmVnxTDt8166MAnLI3mgZ1JCnhNtYWxcJ6Ud5CO/AVOZi/AvFa8DY9RTy3h3+tFBlrrdQ==", - "dev": true, - "dependencies": { - "browserslist": "^4.19.1", - "caniuse-lite": "^1.0.30001297", - "fraction.js": "^4.1.2", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "dependencies": { - "fill-range": "^7.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.19.1.tgz", - "integrity": "sha512-u2tbbG5PdKRTUoctO3NBD8FQ5HdPh1ZXPHzp1rwaa5jTc+RV9/+RlWiAIKmjRPQF+xbGM9Kklj5bZQFa2s/38A==", - "dev": true, - "dependencies": { - "caniuse-lite": "^1.0.30001286", - "electron-to-chromium": "^1.4.17", - "escalade": "^3.1.1", - "node-releases": "^2.0.1", - "picocolors": "^1.0.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001304", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001304.tgz", - "integrity": "sha512-bdsfZd6K6ap87AGqSHJP/s1V+U6Z5lyrcbBu3ovbCCf8cSYpwTtGrCBObMpJqwxfTbLW6YTIdbb1jEeTelcpYQ==", - "dev": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/cosmiconfig": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", - "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", - "dev": true, - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "2.6.19", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.19.tgz", - "integrity": "sha512-ZVxXaNy28/k3kJg0Fou5MiYpp88j7H9hLZp8PDC3jV0WFjfH5E9xHb56L0W59cPbKbcHXeP4qyT8PrHp8t6LcQ==" - }, - "node_modules/debounce": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", - "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==" - }, - "node_modules/defined": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", - "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=", - "dev": true - }, - "node_modules/detective": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz", - "integrity": "sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==", - "dev": true, - "dependencies": { - "acorn-node": "^1.6.1", - "defined": "^1.0.0", - "minimist": "^1.1.1" - }, - "bin": { - "detective": "bin/detective.js" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true - }, - "node_modules/electron-to-chromium": { - "version": "1.4.57", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.57.tgz", - "integrity": "sha512-FNC+P5K1n6pF+M0zIK+gFCoXcJhhzDViL3DRIGy2Fv5PohuSES1JHR7T+GlwxSxlzx4yYbsuzCZvHxcBSRCIOw==", - "dev": true - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/esbuild": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.13.15.tgz", - "integrity": "sha512-raCxt02HBKv8RJxE8vkTSCXGIyKHdEdGfUmiYb8wnabnaEmHzyW7DCHb5tEN0xU8ryqg5xw54mcwnYkC4x3AIw==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "optionalDependencies": { - "esbuild-android-arm64": "0.13.15", - "esbuild-darwin-64": "0.13.15", - "esbuild-darwin-arm64": "0.13.15", - "esbuild-freebsd-64": "0.13.15", - "esbuild-freebsd-arm64": "0.13.15", - "esbuild-linux-32": "0.13.15", - "esbuild-linux-64": "0.13.15", - "esbuild-linux-arm": "0.13.15", - "esbuild-linux-arm64": "0.13.15", - "esbuild-linux-mips64le": "0.13.15", - "esbuild-linux-ppc64le": "0.13.15", - "esbuild-netbsd-64": "0.13.15", - "esbuild-openbsd-64": "0.13.15", - "esbuild-sunos-64": "0.13.15", - "esbuild-windows-32": "0.13.15", - "esbuild-windows-64": "0.13.15", - "esbuild-windows-arm64": "0.13.15" - } - }, - "node_modules/esbuild-android-arm64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.13.15.tgz", - "integrity": "sha512-m602nft/XXeO8YQPUDVoHfjyRVPdPgjyyXOxZ44MK/agewFFkPa8tUo6lAzSWh5Ui5PB4KR9UIFTSBKh/RrCmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/esbuild-darwin-64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.13.15.tgz", - "integrity": "sha512-ihOQRGs2yyp7t5bArCwnvn2Atr6X4axqPpEdCFPVp7iUj4cVSdisgvEKdNR7yH3JDjW6aQDw40iQFoTqejqxvQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/esbuild-darwin-arm64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.13.15.tgz", - "integrity": "sha512-i1FZssTVxUqNlJ6cBTj5YQj4imWy3m49RZRnHhLpefFIh0To05ow9DTrXROTE1urGTQCloFUXTX8QfGJy1P8dQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/esbuild-freebsd-64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.13.15.tgz", - "integrity": "sha512-G3dLBXUI6lC6Z09/x+WtXBXbOYQZ0E8TDBqvn7aMaOCzryJs8LyVXKY4CPnHFXZAbSwkCbqiPuSQ1+HhrNk7EA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/esbuild-freebsd-arm64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.13.15.tgz", - "integrity": "sha512-KJx0fzEDf1uhNOZQStV4ujg30WlnwqUASaGSFPhznLM/bbheu9HhqZ6mJJZM32lkyfGJikw0jg7v3S0oAvtvQQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/esbuild-linux-32": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.13.15.tgz", - "integrity": "sha512-ZvTBPk0YWCLMCXiFmD5EUtB30zIPvC5Itxz0mdTu/xZBbbHJftQgLWY49wEPSn2T/TxahYCRDWun5smRa0Tu+g==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/esbuild-linux-64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.13.15.tgz", - "integrity": "sha512-eCKzkNSLywNeQTRBxJRQ0jxRCl2YWdMB3+PkWFo2BBQYC5mISLIVIjThNtn6HUNqua1pnvgP5xX0nHbZbPj5oA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/esbuild-linux-arm": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.13.15.tgz", - "integrity": "sha512-wUHttDi/ol0tD8ZgUMDH8Ef7IbDX+/UsWJOXaAyTdkT7Yy9ZBqPg8bgB/Dn3CZ9SBpNieozrPRHm0BGww7W/jA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/esbuild-linux-arm64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.13.15.tgz", - "integrity": "sha512-bYpuUlN6qYU9slzr/ltyLTR9YTBS7qUDymO8SV7kjeNext61OdmqFAzuVZom+OLW1HPHseBfJ/JfdSlx8oTUoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/esbuild-linux-mips64le": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.13.15.tgz", - "integrity": "sha512-KlVjIG828uFPyJkO/8gKwy9RbXhCEUeFsCGOJBepUlpa7G8/SeZgncUEz/tOOUJTcWMTmFMtdd3GElGyAtbSWg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/esbuild-linux-ppc64le": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.13.15.tgz", - "integrity": "sha512-h6gYF+OsaqEuBjeesTBtUPw0bmiDu7eAeuc2OEH9S6mV9/jPhPdhOWzdeshb0BskRZxPhxPOjqZ+/OqLcxQwEQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/esbuild-netbsd-64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.13.15.tgz", - "integrity": "sha512-3+yE9emwoevLMyvu+iR3rsa+Xwhie7ZEHMGDQ6dkqP/ndFzRHkobHUKTe+NCApSqG5ce2z4rFu+NX/UHnxlh3w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ] - }, - "node_modules/esbuild-openbsd-64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.13.15.tgz", - "integrity": "sha512-wTfvtwYJYAFL1fSs8yHIdf5GEE4NkbtbXtjLWjM3Cw8mmQKqsg8kTiqJ9NJQe5NX/5Qlo7Xd9r1yKMMkHllp5g==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/esbuild-sunos-64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.13.15.tgz", - "integrity": "sha512-lbivT9Bx3t1iWWrSnGyBP9ODriEvWDRiweAs69vI+miJoeKwHWOComSRukttbuzjZ8r1q0mQJ8Z7yUsDJ3hKdw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ] - }, - "node_modules/esbuild-windows-32": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.13.15.tgz", - "integrity": "sha512-fDMEf2g3SsJ599MBr50cY5ve5lP1wyVwTe6aLJsM01KtxyKkB4UT+fc5MXQFn3RLrAIAZOG+tHC+yXObpSn7Nw==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/esbuild-windows-64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.13.15.tgz", - "integrity": "sha512-9aMsPRGDWCd3bGjUIKG/ZOJPKsiztlxl/Q3C1XDswO6eNX/Jtwu4M+jb6YDH9hRSUflQWX0XKAfWzgy5Wk54JQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/esbuild-windows-arm64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.13.15.tgz", - "integrity": "sha512-zzvyCVVpbwQQATaf3IG8mu1IwGEiDxKkYUdA4FpoCHi1KtPa13jeScYDjlW0Qh+ebWzpKfR2ZwvqAQkSWNcKjA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "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==" - }, - "node_modules/fast-glob": { - "version": "3.2.11", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", - "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fastq": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", - "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", - "dev": true, - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fraction.js": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.1.2.tgz", - "integrity": "sha512-o2RiJQ6DZaR/5+Si0qJUIy637QMRudSi9kU/FFzx9EZazrIdnBgpU+3sEWCxAVhH2RtxW2Oz+T4p2o8uOPVcgA==", - "dev": true, - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://www.patreon.com/infusion" - } - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", - "dev": true - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz", - "integrity": "sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==", - "dev": true, - "dependencies": { - "has": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - }, - "node_modules/lilconfig": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.4.tgz", - "integrity": "sha512-bfTIN7lEsiooCocSISTWXkiWJkRqtL9wYtYy+8EK3Y41qh3mpwPU0ycTOgjdY9ErwXCc8QyrQp82bdL0Xkm9yA==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true - }, - "node_modules/magic-string": { - "version": "0.25.7", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", - "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", - "dependencies": { - "sourcemap-codec": "^1.4.4" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", - "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", - "dev": true, - "dependencies": { - "braces": "^3.0.1", - "picomatch": "^2.2.3" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", - "dev": true - }, - "node_modules/nanoid": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.2.0.tgz", - "integrity": "sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA==", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-releases": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1.tgz", - "integrity": "sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA==", - "dev": true - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", - "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.4.5", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.5.tgz", - "integrity": "sha512-jBDboWM8qpaqwkMwItqTQTiFikhs/67OYVvblFFTM7MrZjt6yMKd6r2kgXizEbTTljacm4NldIlZnhbjr84QYg==", - "dependencies": { - "nanoid": "^3.1.30", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - } - }, - "node_modules/postcss-js": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.0.tgz", - "integrity": "sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==", - "dev": true, - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.3.3" - } - }, - "node_modules/postcss-load-config": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.1.tgz", - "integrity": "sha512-c/9XYboIbSEUZpiD1UQD0IKiUe8n9WHYV7YFe7X7J+ZwCsEKkUJSFWjS9hBU1RR9THR7jMXst8sxiqP0jjo2mg==", - "dev": true, - "dependencies": { - "lilconfig": "^2.0.4", - "yaml": "^1.10.2" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "ts-node": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-5.0.6.tgz", - "integrity": "sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==", - "dev": true, - "dependencies": { - "postcss-selector-parser": "^6.0.6" - }, - "engines": { - "node": ">=12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.0.9", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.9.tgz", - "integrity": "sha512-UO3SgnZOVTwu4kyLR22UQ1xZh086RyNZppb7lLAKBFK8a32ttG5i87Y/P3+2bRSjZNyJ1B7hfFNo273tKe9YxQ==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true - }, - "node_modules/prettier": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.5.1.tgz", - "integrity": "sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==", - "dev": true, - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", - "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", - "dev": true, - "dependencies": { - "is-core-module": "^2.8.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "2.66.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.66.1.tgz", - "integrity": "sha512-crSgLhSkLMnKr4s9iZ/1qJCplgAgrRY+igWv8KhG/AjKOJ0YX/WpmANyn8oxrw+zenF3BXWDLa7Xl/QZISH+7w==", - "dev": true, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=10.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==" - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tailwindcss": { - "version": "3.0.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.0.18.tgz", - "integrity": "sha512-ihPTpEyA5ANgZbwKlgrbfnzOp9R5vDHFWmqxB1PT8NwOGCOFVVMl+Ps1cQQ369acaqqf1BEF77roCwK0lvNmTw==", - "dev": true, - "dependencies": { - "arg": "^5.0.1", - "chalk": "^4.1.2", - "chokidar": "^3.5.3", - "color-name": "^1.1.4", - "cosmiconfig": "^7.0.1", - "detective": "^5.2.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.2.11", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "normalize-path": "^3.0.0", - "object-hash": "^2.2.0", - "postcss-js": "^4.0.0", - "postcss-load-config": "^3.1.0", - "postcss-nested": "5.0.6", - "postcss-selector-parser": "^6.0.9", - "postcss-value-parser": "^4.2.0", - "quick-lru": "^5.1.1", - "resolve": "^1.21.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=12.13.0" - }, - "peerDependencies": { - "autoprefixer": "^10.0.2", - "postcss": "^8.0.9" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true - }, - "node_modules/vite": { - "version": "2.7.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-2.7.13.tgz", - "integrity": "sha512-Mq8et7f3aK0SgSxjDNfOAimZGW9XryfHRa/uV0jseQSilg+KhYDSoNb9h1rknOy6SuMkvNDLKCYAYYUMCE+IgQ==", - "dev": true, - "dependencies": { - "esbuild": "^0.13.12", - "postcss": "^8.4.5", - "resolve": "^1.20.0", - "rollup": "^2.59.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": ">=12.2.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - }, - "peerDependencies": { - "less": "*", - "sass": "*", - "stylus": "*" - }, - "peerDependenciesMeta": { - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - } - } - }, - "node_modules/vue": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.2.29.tgz", - "integrity": "sha512-cFIwr7LkbtCRanjNvh6r7wp2yUxfxeM2yPpDQpAfaaLIGZSrUmLbNiSze9nhBJt5MrZ68Iqt0O5scwAMEVxF+Q==", - "dependencies": { - "@vue/compiler-dom": "3.2.29", - "@vue/compiler-sfc": "3.2.29", - "@vue/runtime-dom": "3.2.29", - "@vue/server-renderer": "3.2.29", - "@vue/shared": "3.2.29" - } - }, - "node_modules/vue-router": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.12.tgz", - "integrity": "sha512-CPXvfqe+mZLB1kBWssssTiWg4EQERyqJZes7USiqfW9B5N2x+nHlnsM1D3b5CaJ6qgCvMmYJnz+G0iWjNCvXrg==", - "dependencies": { - "@vue/devtools-api": "^6.0.0-beta.18" - }, - "peerDependencies": { - "vue": "^3.0.0" - } - }, - "node_modules/vue3-popper": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/vue3-popper/-/vue3-popper-1.4.1.tgz", - "integrity": "sha512-pmct5vumtvbK8MmUs4oFY+3Al1glU34QXWcIPK4WJhRo/Kp85kxD0j70cNofNBqHYwhY5D7xJ6Yhkwf/5x9w7Q==", - "dependencies": { - "@popperjs/core": "^2.9.2", - "debounce": "^1.2.1" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "vue": "^3.2.20" - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, - "engines": { - "node": ">=0.4" - } - }, - "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "dev": true, - "engines": { - "node": ">= 6" - } - } - }, - "dependencies": { - "@babel/code-frame": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", - "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", - "dev": true, - "requires": { - "@babel/highlight": "^7.16.7" - } - }, - "@babel/helper-validator-identifier": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", - "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", - "dev": true - }, - "@babel/highlight": { - "version": "7.16.10", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.16.10.tgz", - "integrity": "sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==", - "dev": true, - "requires": { - "@babel/helper-validator-identifier": "^7.16.7", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", - "dev": true - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, - "@babel/parser": { - "version": "7.16.12", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.16.12.tgz", - "integrity": "sha512-VfaV15po8RiZssrkPweyvbGVSe4x2y+aciFCgn0n0/SJMR22cwofRV1mtnJQYcSB1wUTaA/X1LnA3es66MCO5A==" - }, - "@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "requires": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - } - }, - "@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true - }, - "@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "requires": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - } - }, - "@popperjs/core": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.2.tgz", - "integrity": "sha512-92FRmppjjqz29VMJ2dn+xdyXZBrMlE42AV6Kq6BwjWV7CNUW1hs2FtxSNLQE+gJhaZ6AAmYuO9y8dshhcBl7vA==" - }, - "@types/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", - "dev": true - }, - "@vitejs/plugin-vue": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-2.1.0.tgz", - "integrity": "sha512-AZ78WxvFMYd8JmM/GBV6a6SGGTU0GgN/0/4T+FnMMsLzFEzTeAUwuraapy50ifHZsC+G5SvWs86bvaCPTneFlA==", - "dev": true, - "requires": {} - }, - "@vue/compiler-core": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.29.tgz", - "integrity": "sha512-RePZ/J4Ub3sb7atQw6V6Rez+/5LCRHGFlSetT3N4VMrejqJnNPXKUt5AVm/9F5MJriy2w/VudEIvgscCfCWqxw==", - "requires": { - "@babel/parser": "^7.16.4", - "@vue/shared": "3.2.29", - "estree-walker": "^2.0.2", - "source-map": "^0.6.1" - } - }, - "@vue/compiler-dom": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.29.tgz", - "integrity": "sha512-y26vK5khdNS9L3ckvkqJk/78qXwWb75Ci8iYLb67AkJuIgyKhIOcR1E8RIt4mswlVCIeI9gQ+fmtdhaiTAtrBQ==", - "requires": { - "@vue/compiler-core": "3.2.29", - "@vue/shared": "3.2.29" - } - }, - "@vue/compiler-sfc": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.29.tgz", - "integrity": "sha512-X9+0dwsag2u6hSOP/XsMYqFti/edvYvxamgBgCcbSYuXx1xLZN+dS/GvQKM4AgGS4djqo0jQvWfIXdfZ2ET68g==", - "requires": { - "@babel/parser": "^7.16.4", - "@vue/compiler-core": "3.2.29", - "@vue/compiler-dom": "3.2.29", - "@vue/compiler-ssr": "3.2.29", - "@vue/reactivity-transform": "3.2.29", - "@vue/shared": "3.2.29", - "estree-walker": "^2.0.2", - "magic-string": "^0.25.7", - "postcss": "^8.1.10", - "source-map": "^0.6.1" - } - }, - "@vue/compiler-ssr": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.29.tgz", - "integrity": "sha512-LrvQwXlx66uWsB9/VydaaqEpae9xtmlUkeSKF6aPDbzx8M1h7ukxaPjNCAXuFd3fUHblcri8k42lfimHfzMICA==", - "requires": { - "@vue/compiler-dom": "3.2.29", - "@vue/shared": "3.2.29" - } - }, - "@vue/devtools-api": { - "version": "6.0.0-beta.21.1", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.0.0-beta.21.1.tgz", - "integrity": "sha512-FqC4s3pm35qGVeXRGOjTsRzlkJjrBLriDS9YXbflHLsfA9FrcKzIyWnLXoNm+/7930E8rRakXuAc2QkC50swAw==" - }, - "@vue/reactivity": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.2.29.tgz", - "integrity": "sha512-Ryhb6Gy62YolKXH1gv42pEqwx7zs3n8gacRVZICSgjQz8Qr8QeCcFygBKYfJm3o1SccR7U+bVBQDWZGOyG1k4g==", - "requires": { - "@vue/shared": "3.2.29" - } - }, - "@vue/reactivity-transform": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.29.tgz", - "integrity": "sha512-YF6HdOuhdOw6KyRm59+3rML8USb9o8mYM1q+SH0G41K3/q/G7uhPnHGKvspzceD7h9J3VR1waOQ93CUZj7J7OA==", - "requires": { - "@babel/parser": "^7.16.4", - "@vue/compiler-core": "3.2.29", - "@vue/shared": "3.2.29", - "estree-walker": "^2.0.2", - "magic-string": "^0.25.7" - } - }, - "@vue/runtime-core": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.2.29.tgz", - "integrity": "sha512-VMvQuLdzoTGmCwIKTKVwKmIL0qcODIqe74JtK1pVr5lnaE0l25hopodmPag3RcnIcIXe+Ye3B2olRCn7fTCgig==", - "requires": { - "@vue/reactivity": "3.2.29", - "@vue/shared": "3.2.29" - } - }, - "@vue/runtime-dom": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.2.29.tgz", - "integrity": "sha512-YJgLQLwr+SQyORzTsBQLL5TT/5UiV83tEotqjL7F9aFDIQdFBTCwpkCFvX9jqwHoyi9sJqM9XtTrMcc8z/OjPA==", - "requires": { - "@vue/runtime-core": "3.2.29", - "@vue/shared": "3.2.29", - "csstype": "^2.6.8" - } - }, - "@vue/server-renderer": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.2.29.tgz", - "integrity": "sha512-lpiYx7ciV7rWfJ0tPkoSOlLmwqBZ9FTmQm33S+T4g0j1fO/LmhJ9b9Ctl1o5xvIFVDk9QkSUWANZn7H2pXuxVw==", - "requires": { - "@vue/compiler-ssr": "3.2.29", - "@vue/shared": "3.2.29" - } - }, - "@vue/shared": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.29.tgz", - "integrity": "sha512-BjNpU8OK6Z0LVzGUppEk0CMYm/hKDnZfYdjSmPOs0N+TR1cLKJAkDwW8ASZUvaaSLEi6d3hVM7jnWnX+6yWnHw==" - }, - "acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "dev": true - }, - "acorn-node": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", - "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", - "dev": true, - "requires": { - "acorn": "^7.0.0", - "acorn-walk": "^7.0.0", - "xtend": "^4.0.2" - } - }, - "acorn-walk": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", - "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", - "dev": true - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "anymatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", - "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", - "dev": true, - "requires": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - } - }, - "arg": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.1.tgz", - "integrity": "sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==", - "dev": true - }, - "autoprefixer": { - "version": "10.4.2", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.2.tgz", - "integrity": "sha512-9fOPpHKuDW1w/0EKfRmVnxTDt8166MAnLI3mgZ1JCnhNtYWxcJ6Ud5CO/AVOZi/AvFa8DY9RTy3h3+tFBlrrdQ==", - "dev": true, - "requires": { - "browserslist": "^4.19.1", - "caniuse-lite": "^1.0.30001297", - "fraction.js": "^4.1.2", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - } - }, - "binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true - }, - "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "requires": { - "fill-range": "^7.0.1" - } - }, - "browserslist": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.19.1.tgz", - "integrity": "sha512-u2tbbG5PdKRTUoctO3NBD8FQ5HdPh1ZXPHzp1rwaa5jTc+RV9/+RlWiAIKmjRPQF+xbGM9Kklj5bZQFa2s/38A==", - "dev": true, - "requires": { - "caniuse-lite": "^1.0.30001286", - "electron-to-chromium": "^1.4.17", - "escalade": "^3.1.1", - "node-releases": "^2.0.1", - "picocolors": "^1.0.0" - } - }, - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true - }, - "camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true - }, - "caniuse-lite": { - "version": "1.0.30001304", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001304.tgz", - "integrity": "sha512-bdsfZd6K6ap87AGqSHJP/s1V+U6Z5lyrcbBu3ovbCCf8cSYpwTtGrCBObMpJqwxfTbLW6YTIdbb1jEeTelcpYQ==", - "dev": true - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "dev": true, - "requires": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "fsevents": "~2.3.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "dependencies": { - "glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "requires": { - "is-glob": "^4.0.1" - } - } - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "cosmiconfig": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", - "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", - "dev": true, - "requires": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - } - }, - "cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true - }, - "csstype": { - "version": "2.6.19", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.19.tgz", - "integrity": "sha512-ZVxXaNy28/k3kJg0Fou5MiYpp88j7H9hLZp8PDC3jV0WFjfH5E9xHb56L0W59cPbKbcHXeP4qyT8PrHp8t6LcQ==" - }, - "debounce": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", - "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==" - }, - "defined": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", - "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=", - "dev": true - }, - "detective": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz", - "integrity": "sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==", - "dev": true, - "requires": { - "acorn-node": "^1.6.1", - "defined": "^1.0.0", - "minimist": "^1.1.1" - } - }, - "didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true - }, - "dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true - }, - "electron-to-chromium": { - "version": "1.4.57", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.57.tgz", - "integrity": "sha512-FNC+P5K1n6pF+M0zIK+gFCoXcJhhzDViL3DRIGy2Fv5PohuSES1JHR7T+GlwxSxlzx4yYbsuzCZvHxcBSRCIOw==", - "dev": true - }, - "error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "requires": { - "is-arrayish": "^0.2.1" - } - }, - "esbuild": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.13.15.tgz", - "integrity": "sha512-raCxt02HBKv8RJxE8vkTSCXGIyKHdEdGfUmiYb8wnabnaEmHzyW7DCHb5tEN0xU8ryqg5xw54mcwnYkC4x3AIw==", - "dev": true, - "requires": { - "esbuild-android-arm64": "0.13.15", - "esbuild-darwin-64": "0.13.15", - "esbuild-darwin-arm64": "0.13.15", - "esbuild-freebsd-64": "0.13.15", - "esbuild-freebsd-arm64": "0.13.15", - "esbuild-linux-32": "0.13.15", - "esbuild-linux-64": "0.13.15", - "esbuild-linux-arm": "0.13.15", - "esbuild-linux-arm64": "0.13.15", - "esbuild-linux-mips64le": "0.13.15", - "esbuild-linux-ppc64le": "0.13.15", - "esbuild-netbsd-64": "0.13.15", - "esbuild-openbsd-64": "0.13.15", - "esbuild-sunos-64": "0.13.15", - "esbuild-windows-32": "0.13.15", - "esbuild-windows-64": "0.13.15", - "esbuild-windows-arm64": "0.13.15" - } - }, - "esbuild-android-arm64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.13.15.tgz", - "integrity": "sha512-m602nft/XXeO8YQPUDVoHfjyRVPdPgjyyXOxZ44MK/agewFFkPa8tUo6lAzSWh5Ui5PB4KR9UIFTSBKh/RrCmg==", - "dev": true, - "optional": true - }, - "esbuild-darwin-64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.13.15.tgz", - "integrity": "sha512-ihOQRGs2yyp7t5bArCwnvn2Atr6X4axqPpEdCFPVp7iUj4cVSdisgvEKdNR7yH3JDjW6aQDw40iQFoTqejqxvQ==", - "dev": true, - "optional": true - }, - "esbuild-darwin-arm64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.13.15.tgz", - "integrity": "sha512-i1FZssTVxUqNlJ6cBTj5YQj4imWy3m49RZRnHhLpefFIh0To05ow9DTrXROTE1urGTQCloFUXTX8QfGJy1P8dQ==", - "dev": true, - "optional": true - }, - "esbuild-freebsd-64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.13.15.tgz", - "integrity": "sha512-G3dLBXUI6lC6Z09/x+WtXBXbOYQZ0E8TDBqvn7aMaOCzryJs8LyVXKY4CPnHFXZAbSwkCbqiPuSQ1+HhrNk7EA==", - "dev": true, - "optional": true - }, - "esbuild-freebsd-arm64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.13.15.tgz", - "integrity": "sha512-KJx0fzEDf1uhNOZQStV4ujg30WlnwqUASaGSFPhznLM/bbheu9HhqZ6mJJZM32lkyfGJikw0jg7v3S0oAvtvQQ==", - "dev": true, - "optional": true - }, - "esbuild-linux-32": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.13.15.tgz", - "integrity": "sha512-ZvTBPk0YWCLMCXiFmD5EUtB30zIPvC5Itxz0mdTu/xZBbbHJftQgLWY49wEPSn2T/TxahYCRDWun5smRa0Tu+g==", - "dev": true, - "optional": true - }, - "esbuild-linux-64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.13.15.tgz", - "integrity": "sha512-eCKzkNSLywNeQTRBxJRQ0jxRCl2YWdMB3+PkWFo2BBQYC5mISLIVIjThNtn6HUNqua1pnvgP5xX0nHbZbPj5oA==", - "dev": true, - "optional": true - }, - "esbuild-linux-arm": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.13.15.tgz", - "integrity": "sha512-wUHttDi/ol0tD8ZgUMDH8Ef7IbDX+/UsWJOXaAyTdkT7Yy9ZBqPg8bgB/Dn3CZ9SBpNieozrPRHm0BGww7W/jA==", - "dev": true, - "optional": true - }, - "esbuild-linux-arm64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.13.15.tgz", - "integrity": "sha512-bYpuUlN6qYU9slzr/ltyLTR9YTBS7qUDymO8SV7kjeNext61OdmqFAzuVZom+OLW1HPHseBfJ/JfdSlx8oTUoA==", - "dev": true, - "optional": true - }, - "esbuild-linux-mips64le": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.13.15.tgz", - "integrity": "sha512-KlVjIG828uFPyJkO/8gKwy9RbXhCEUeFsCGOJBepUlpa7G8/SeZgncUEz/tOOUJTcWMTmFMtdd3GElGyAtbSWg==", - "dev": true, - "optional": true - }, - "esbuild-linux-ppc64le": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.13.15.tgz", - "integrity": "sha512-h6gYF+OsaqEuBjeesTBtUPw0bmiDu7eAeuc2OEH9S6mV9/jPhPdhOWzdeshb0BskRZxPhxPOjqZ+/OqLcxQwEQ==", - "dev": true, - "optional": true - }, - "esbuild-netbsd-64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.13.15.tgz", - "integrity": "sha512-3+yE9emwoevLMyvu+iR3rsa+Xwhie7ZEHMGDQ6dkqP/ndFzRHkobHUKTe+NCApSqG5ce2z4rFu+NX/UHnxlh3w==", - "dev": true, - "optional": true - }, - "esbuild-openbsd-64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.13.15.tgz", - "integrity": "sha512-wTfvtwYJYAFL1fSs8yHIdf5GEE4NkbtbXtjLWjM3Cw8mmQKqsg8kTiqJ9NJQe5NX/5Qlo7Xd9r1yKMMkHllp5g==", - "dev": true, - "optional": true - }, - "esbuild-sunos-64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.13.15.tgz", - "integrity": "sha512-lbivT9Bx3t1iWWrSnGyBP9ODriEvWDRiweAs69vI+miJoeKwHWOComSRukttbuzjZ8r1q0mQJ8Z7yUsDJ3hKdw==", - "dev": true, - "optional": true - }, - "esbuild-windows-32": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.13.15.tgz", - "integrity": "sha512-fDMEf2g3SsJ599MBr50cY5ve5lP1wyVwTe6aLJsM01KtxyKkB4UT+fc5MXQFn3RLrAIAZOG+tHC+yXObpSn7Nw==", - "dev": true, - "optional": true - }, - "esbuild-windows-64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.13.15.tgz", - "integrity": "sha512-9aMsPRGDWCd3bGjUIKG/ZOJPKsiztlxl/Q3C1XDswO6eNX/Jtwu4M+jb6YDH9hRSUflQWX0XKAfWzgy5Wk54JQ==", - "dev": true, - "optional": true - }, - "esbuild-windows-arm64": { - "version": "0.13.15", - "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.13.15.tgz", - "integrity": "sha512-zzvyCVVpbwQQATaf3IG8mu1IwGEiDxKkYUdA4FpoCHi1KtPa13jeScYDjlW0Qh+ebWzpKfR2ZwvqAQkSWNcKjA==", - "dev": true, - "optional": true - }, - "escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true - }, - "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==" - }, - "fast-glob": { - "version": "3.2.11", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", - "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", - "dev": true, - "requires": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "dependencies": { - "glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "requires": { - "is-glob": "^4.0.1" - } - } - } - }, - "fastq": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", - "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", - "dev": true, - "requires": { - "reusify": "^1.0.4" - } - }, - "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "requires": { - "to-regex-range": "^5.0.1" - } - }, - "fraction.js": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.1.2.tgz", - "integrity": "sha512-o2RiJQ6DZaR/5+Si0qJUIy637QMRudSi9kU/FFzx9EZazrIdnBgpU+3sEWCxAVhH2RtxW2Oz+T4p2o8uOPVcgA==", - "dev": true - }, - "fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "optional": true - }, - "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true - }, - "glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "requires": { - "is-glob": "^4.0.3" - } - }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "requires": { - "function-bind": "^1.1.1" - } - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "requires": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - } - }, - "is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", - "dev": true - }, - "is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "requires": { - "binary-extensions": "^2.0.0" - } - }, - "is-core-module": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz", - "integrity": "sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==", - "dev": true, - "requires": { - "has": "^1.0.3" - } - }, - "is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "dev": true - }, - "is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "requires": { - "is-extglob": "^2.1.1" - } - }, - "is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - }, - "lilconfig": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.4.tgz", - "integrity": "sha512-bfTIN7lEsiooCocSISTWXkiWJkRqtL9wYtYy+8EK3Y41qh3mpwPU0ycTOgjdY9ErwXCc8QyrQp82bdL0Xkm9yA==", - "dev": true - }, - "lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true - }, - "magic-string": { - "version": "0.25.7", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", - "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", - "requires": { - "sourcemap-codec": "^1.4.4" - } - }, - "merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true - }, - "micromatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", - "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", - "dev": true, - "requires": { - "braces": "^3.0.1", - "picomatch": "^2.2.3" - } - }, - "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", - "dev": true - }, - "nanoid": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.2.0.tgz", - "integrity": "sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA==" - }, - "node-releases": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1.tgz", - "integrity": "sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA==", - "dev": true - }, - "normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true - }, - "normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=", - "dev": true - }, - "object-hash": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", - "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", - "dev": true - }, - "parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "requires": { - "callsites": "^3.0.0" - } - }, - "parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - } - }, - "path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true - }, - "picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" - }, - "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true - }, - "postcss": { - "version": "8.4.5", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.5.tgz", - "integrity": "sha512-jBDboWM8qpaqwkMwItqTQTiFikhs/67OYVvblFFTM7MrZjt6yMKd6r2kgXizEbTTljacm4NldIlZnhbjr84QYg==", - "requires": { - "nanoid": "^3.1.30", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.1" - } - }, - "postcss-js": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.0.tgz", - "integrity": "sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==", - "dev": true, - "requires": { - "camelcase-css": "^2.0.1" - } - }, - "postcss-load-config": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.1.tgz", - "integrity": "sha512-c/9XYboIbSEUZpiD1UQD0IKiUe8n9WHYV7YFe7X7J+ZwCsEKkUJSFWjS9hBU1RR9THR7jMXst8sxiqP0jjo2mg==", - "dev": true, - "requires": { - "lilconfig": "^2.0.4", - "yaml": "^1.10.2" - } - }, - "postcss-nested": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-5.0.6.tgz", - "integrity": "sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==", - "dev": true, - "requires": { - "postcss-selector-parser": "^6.0.6" - } - }, - "postcss-selector-parser": { - "version": "6.0.9", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.9.tgz", - "integrity": "sha512-UO3SgnZOVTwu4kyLR22UQ1xZh086RyNZppb7lLAKBFK8a32ttG5i87Y/P3+2bRSjZNyJ1B7hfFNo273tKe9YxQ==", - "dev": true, - "requires": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - } - }, - "postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true - }, - "prettier": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.5.1.tgz", - "integrity": "sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==", - "dev": true - }, - "queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true - }, - "quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true - }, - "readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "requires": { - "picomatch": "^2.2.1" - } - }, - "resolve": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", - "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", - "dev": true, - "requires": { - "is-core-module": "^2.8.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, - "resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true - }, - "reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true - }, - "rollup": { - "version": "2.66.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.66.1.tgz", - "integrity": "sha512-crSgLhSkLMnKr4s9iZ/1qJCplgAgrRY+igWv8KhG/AjKOJ0YX/WpmANyn8oxrw+zenF3BXWDLa7Xl/QZISH+7w==", - "dev": true, - "requires": { - "fsevents": "~2.3.2" - } - }, - "run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "requires": { - "queue-microtask": "^1.2.2" - } - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" - }, - "source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" - }, - "sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==" - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true - }, - "tailwindcss": { - "version": "3.0.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.0.18.tgz", - "integrity": "sha512-ihPTpEyA5ANgZbwKlgrbfnzOp9R5vDHFWmqxB1PT8NwOGCOFVVMl+Ps1cQQ369acaqqf1BEF77roCwK0lvNmTw==", - "dev": true, - "requires": { - "arg": "^5.0.1", - "chalk": "^4.1.2", - "chokidar": "^3.5.3", - "color-name": "^1.1.4", - "cosmiconfig": "^7.0.1", - "detective": "^5.2.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.2.11", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "normalize-path": "^3.0.0", - "object-hash": "^2.2.0", - "postcss-js": "^4.0.0", - "postcss-load-config": "^3.1.0", - "postcss-nested": "5.0.6", - "postcss-selector-parser": "^6.0.9", - "postcss-value-parser": "^4.2.0", - "quick-lru": "^5.1.1", - "resolve": "^1.21.0" - } - }, - "to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "requires": { - "is-number": "^7.0.0" - } - }, - "util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true - }, - "vite": { - "version": "2.7.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-2.7.13.tgz", - "integrity": "sha512-Mq8et7f3aK0SgSxjDNfOAimZGW9XryfHRa/uV0jseQSilg+KhYDSoNb9h1rknOy6SuMkvNDLKCYAYYUMCE+IgQ==", - "dev": true, - "requires": { - "esbuild": "^0.13.12", - "fsevents": "~2.3.2", - "postcss": "^8.4.5", - "resolve": "^1.20.0", - "rollup": "^2.59.0" - } - }, - "vue": { - "version": "3.2.29", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.2.29.tgz", - "integrity": "sha512-cFIwr7LkbtCRanjNvh6r7wp2yUxfxeM2yPpDQpAfaaLIGZSrUmLbNiSze9nhBJt5MrZ68Iqt0O5scwAMEVxF+Q==", - "requires": { - "@vue/compiler-dom": "3.2.29", - "@vue/compiler-sfc": "3.2.29", - "@vue/runtime-dom": "3.2.29", - "@vue/server-renderer": "3.2.29", - "@vue/shared": "3.2.29" - } - }, - "vue-router": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.12.tgz", - "integrity": "sha512-CPXvfqe+mZLB1kBWssssTiWg4EQERyqJZes7USiqfW9B5N2x+nHlnsM1D3b5CaJ6qgCvMmYJnz+G0iWjNCvXrg==", - "requires": { - "@vue/devtools-api": "^6.0.0-beta.18" - } - }, - "vue3-popper": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/vue3-popper/-/vue3-popper-1.4.1.tgz", - "integrity": "sha512-pmct5vumtvbK8MmUs4oFY+3Al1glU34QXWcIPK4WJhRo/Kp85kxD0j70cNofNBqHYwhY5D7xJ6Yhkwf/5x9w7Q==", - "requires": { - "@popperjs/core": "^2.9.2", - "debounce": "^1.2.1" - } - }, - "xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true - }, - "yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "dev": true - } - } -} diff --git a/dashboard/package.json b/dashboard/package.json deleted file mode 100644 index 412ac7e..0000000 --- a/dashboard/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "cloud-tasks-dashboard", - "version": "0.0.0", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" - }, - "dependencies": { - "vue": "^3.2.25", - "vue-router": "^4.0.12", - "vue3-popper": "^1.4.1" - }, - "devDependencies": { - "@vitejs/plugin-vue": "^2.0.0", - "autoprefixer": "^10.4.2", - "postcss": "^8.4.5", - "prettier": "2.5.1", - "tailwindcss": "^3.0.18", - "vite": "^2.7.2" - } -} diff --git a/dashboard/postcss.config.js b/dashboard/postcss.config.js deleted file mode 100644 index 33ad091..0000000 --- a/dashboard/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/dashboard/public/favicon.ico b/dashboard/public/favicon.ico deleted file mode 100644 index df36fcf..0000000 Binary files a/dashboard/public/favicon.ico and /dev/null differ diff --git a/dashboard/src/App.vue b/dashboard/src/App.vue deleted file mode 100644 index eea59d2..0000000 --- a/dashboard/src/App.vue +++ /dev/null @@ -1,29 +0,0 @@ - - - - diff --git a/dashboard/src/api.js b/dashboard/src/api.js deleted file mode 100644 index 82df227..0000000 --- a/dashboard/src/api.js +++ /dev/null @@ -1,84 +0,0 @@ -import { onUnmounted, watch } from 'vue' -import { onBeforeRouteUpdate } from 'vue-router' - -export async function callApi({ - endpoint, - router, - body = null, - method = 'GET', - login = false, -} = {}) { - const response = await fetch( - `${import.meta.env.VITE_API_URL || ''}/cloud-tasks-api/${endpoint}`, - { - method, - ...(body ? { body } : {}), - headers: { - ...(!login - ? { - Authorization: `Bearer ${localStorage.getItem( - 'cloud-tasks-token' - )}`, - } - : {}), - }, - } - ) - - if (response.status === 403 && !login) { - localStorage.removeItem('cloud-tasks-token') - router.push({ name: 'login' }) - } - - return login ? await response.text() : await response.json() -} - -export async function fetchTasks(into, query = {}, router) { - let paused = false - - const f = async function (into) { - if (paused) { - return - } - - const url = new URL(window.location.href) - const queryParams = new URLSearchParams(url.search) - - for (const [name, value] of Object.entries(query)) { - queryParams.append(name, value) - } - - paused = true - into.value = await callApi({ - endpoint: `tasks?${queryParams.toString()}`, - router, - }) - paused = false - } - - f(into) - let interval = setInterval(() => f(into), 3000) - let visibilityChangeListener = null - - // immediately re-fetch results if results have been filtered. - onBeforeRouteUpdate(function () { - setTimeout(() => f(into)) - }) - - const onVisibilityChange = function () { - if (document.visibilityState === 'visible') { - f(into) - clearInterval(interval) - interval = setInterval(() => f(into), 3000) - } else if (document.visibilityState === 'hidden') { - clearInterval(interval) - } - } - document.addEventListener('visibilitychange', onVisibilityChange) - - onUnmounted(() => { - clearInterval(interval) - document.removeEventListener('visibilitychange', onVisibilityChange) - paused = false - }) -} diff --git a/dashboard/src/assets/logo.png b/dashboard/src/assets/logo.png deleted file mode 100644 index f3d2503..0000000 Binary files a/dashboard/src/assets/logo.png and /dev/null differ diff --git a/dashboard/src/components/Dashboard.vue b/dashboard/src/components/Dashboard.vue deleted file mode 100644 index 7d4fffa..0000000 --- a/dashboard/src/components/Dashboard.vue +++ /dev/null @@ -1,107 +0,0 @@ - - - diff --git a/dashboard/src/components/Failed.vue b/dashboard/src/components/Failed.vue deleted file mode 100644 index 5009a2c..0000000 --- a/dashboard/src/components/Failed.vue +++ /dev/null @@ -1,29 +0,0 @@ - - - diff --git a/dashboard/src/components/FilterCard.vue b/dashboard/src/components/FilterCard.vue deleted file mode 100644 index a24526b..0000000 --- a/dashboard/src/components/FilterCard.vue +++ /dev/null @@ -1,80 +0,0 @@ - - - diff --git a/dashboard/src/components/Icon.vue b/dashboard/src/components/Icon.vue deleted file mode 100644 index a29e556..0000000 --- a/dashboard/src/components/Icon.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - - - diff --git a/dashboard/src/components/Login.vue b/dashboard/src/components/Login.vue deleted file mode 100644 index 17f04f8..0000000 --- a/dashboard/src/components/Login.vue +++ /dev/null @@ -1,101 +0,0 @@ - - - - - diff --git a/dashboard/src/components/Menu.vue b/dashboard/src/components/Menu.vue deleted file mode 100644 index 3458e78..0000000 --- a/dashboard/src/components/Menu.vue +++ /dev/null @@ -1,31 +0,0 @@ - diff --git a/dashboard/src/components/Overview.vue b/dashboard/src/components/Overview.vue deleted file mode 100644 index 8679fd0..0000000 --- a/dashboard/src/components/Overview.vue +++ /dev/null @@ -1,207 +0,0 @@ - - - - - diff --git a/dashboard/src/components/Queued.vue b/dashboard/src/components/Queued.vue deleted file mode 100644 index 03335e7..0000000 --- a/dashboard/src/components/Queued.vue +++ /dev/null @@ -1,29 +0,0 @@ - - - diff --git a/dashboard/src/components/Recent.vue b/dashboard/src/components/Recent.vue deleted file mode 100644 index 58c4855..0000000 --- a/dashboard/src/components/Recent.vue +++ /dev/null @@ -1,29 +0,0 @@ - - - diff --git a/dashboard/src/components/Spinner.vue b/dashboard/src/components/Spinner.vue deleted file mode 100644 index c2a0d55..0000000 --- a/dashboard/src/components/Spinner.vue +++ /dev/null @@ -1,22 +0,0 @@ - diff --git a/dashboard/src/components/Status.vue b/dashboard/src/components/Status.vue deleted file mode 100644 index b1beb73..0000000 --- a/dashboard/src/components/Status.vue +++ /dev/null @@ -1,37 +0,0 @@ - - - - - diff --git a/dashboard/src/components/Task.vue b/dashboard/src/components/Task.vue deleted file mode 100644 index 031dd73..0000000 --- a/dashboard/src/components/Task.vue +++ /dev/null @@ -1,125 +0,0 @@ - - - - - diff --git a/dashboard/src/components/TaskRowSpinner.vue b/dashboard/src/components/TaskRowSpinner.vue deleted file mode 100644 index a877678..0000000 --- a/dashboard/src/components/TaskRowSpinner.vue +++ /dev/null @@ -1,28 +0,0 @@ - diff --git a/dashboard/src/index.css b/dashboard/src/index.css deleted file mode 100644 index b5c61c9..0000000 --- a/dashboard/src/index.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/dashboard/src/main.js b/dashboard/src/main.js deleted file mode 100644 index 4b958dd..0000000 --- a/dashboard/src/main.js +++ /dev/null @@ -1,102 +0,0 @@ -import { createApp } from 'vue/dist/vue.esm-bundler' -import App from './App.vue' -import './index.css' -import { createRouter, createWebHistory } from 'vue-router' -import Popper from 'vue3-popper' - -// 1. Define route components. -// These can be imported from other files -import Login from './components/Login.vue' -import Dashboard from './components/Dashboard.vue' -import Recent from './components/Recent.vue' -import Queued from './components/Queued.vue' -import Failed from './components/Failed.vue' -import Task from './components/Task.vue' - -// 2. Define some routes -// Each route should map to a component. -// We'll talk about nested routes later. -const routes = [ - { - name: 'home', - path: '/', - component: Dashboard, - }, - { - name: 'login', - path: '/login', - component: Login, - }, - { - name: 'recent', - path: '/recent', - component: Recent, - meta: { - route: 'recent', - }, - }, - { - name: 'recent-task', - path: '/recent/:uuid', - component: Task, - meta: { - route: 'recent', - }, - }, - { - name: 'queued', - path: '/queued', - component: Queued, - meta: { - route: 'queued', - }, - }, - { - name: 'queued-task', - path: '/queued/:uuid', - component: Task, - meta: { - route: 'queued', - }, - }, - { - name: 'failed', - path: '/failed', - component: Failed, - meta: { - route: 'failed', - }, - }, - { - name: 'failed-task', - path: '/failed/:uuid', - component: Task, - meta: { - route: 'failed', - }, - }, -] - -// 3. Create the router instance and pass the `routes` option -// You can pass in additional options here, but let's -// keep it simple for now. -let routerBasePath = null -if ('CloudTasks' in window) { - routerBasePath = `/${window.CloudTasks.path}` -} - -const router = createRouter({ - // 4. Provide the history implementation to use. We are using the hash history for simplicity here. - history: createWebHistory(routerBasePath), - routes, // short for `routes: routes`, -}) - -router.beforeEach((to, from, next) => { - const authenticated = localStorage.hasOwnProperty('cloud-tasks-token') - if (!authenticated && to.name !== 'login') { - return next({ name: 'login' }) - } - return next() -}) - -createApp(App).use(router).component('Popper', Popper).mount('#app') diff --git a/dashboard/tailwind.config.js b/dashboard/tailwind.config.js deleted file mode 100644 index c3d7982..0000000 --- a/dashboard/tailwind.config.js +++ /dev/null @@ -1,10 +0,0 @@ -module.exports = { - content: [ - "./index.html", - "./src/**/*.{vue,js,ts,jsx,tsx}", - ], - theme: { - extend: {}, - }, - plugins: [], -} diff --git a/dashboard/vite.config.js b/dashboard/vite.config.js deleted file mode 100644 index 3cbd7b6..0000000 --- a/dashboard/vite.config.js +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from 'vite' -import vue from '@vitejs/plugin-vue' - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [vue()], - build: { - manifest: true, - target: 'es2015', - }, -}) diff --git a/factories/StackkitCloudTaskFactory.php b/factories/StackkitCloudTaskFactory.php deleted file mode 100644 index ac0991e..0000000 --- a/factories/StackkitCloudTaskFactory.php +++ /dev/null @@ -1,22 +0,0 @@ -define(StackkitCloudTask::class, function (Faker $faker) { - return [ - 'status' => 'queued', - 'queue' => 'barbequeue', - 'task_uuid' => (string) Str::uuid(), - 'name' => 'SimpleJob', - 'metadata' => '{}', - 'payload' => '{}', - ]; -}); diff --git a/migrations/2021_10_16_171140_create_stackkit_cloud_tasks_table.php b/migrations/2021_10_16_171140_create_stackkit_cloud_tasks_table.php deleted file mode 100644 index 2455a5a..0000000 --- a/migrations/2021_10_16_171140_create_stackkit_cloud_tasks_table.php +++ /dev/null @@ -1,41 +0,0 @@ -increments('id'); - $table->string('queue'); - $table->string('task_uuid'); - $table->string('name'); - $table->string('status'); - $table->text('metadata'); - $table->text('payload'); - $table->timestamps(); - - $table->index('task_uuid'); - $table->index('queue'); - $table->index('status'); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('stackkit_cloud_tasks'); - } -} diff --git a/phpstan.neon b/phpstan.neon index b2e12de..5af5ae1 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,5 @@ includes: - - ./vendor/nunomaduro/larastan/extension.neon + - ./vendor/larastan/larastan/extension.neon - ./vendor/thecodingmachine/phpstan-safe-rule/phpstan-safe-rule.neon parameters: paths: diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..94d78a4 --- /dev/null +++ b/pint.json @@ -0,0 +1,7 @@ +{ + "preset": "laravel", + "rules": { + "fully_qualified_strict_types": true, + "declare_strict_types": true + } +} diff --git a/src/Authenticate.php b/src/Authenticate.php deleted file mode 100644 index aef3c13..0000000 --- a/src/Authenticate.php +++ /dev/null @@ -1,18 +0,0 @@ -json('', 403); - } -} diff --git a/src/CloudTasks.php b/src/CloudTasks.php deleted file mode 100644 index b9ca554..0000000 --- a/src/CloudTasks.php +++ /dev/null @@ -1,53 +0,0 @@ -bearerToken(); - - if (!$token) { - return false; - } - - try { - $expireTimestamp = decrypt($token); - - return $expireTimestamp > Carbon::now()->timestamp; - } catch (Throwable $e) { - return false; - } - } - - /** - * Determine if the dashboard is enabled. - * - * @return bool - */ - public static function dashboardEnabled(): bool - { - return config('cloud-tasks.dashboard.enabled') === true; - } - - /** - * Determine if the dashboard is disabled. - * - * @return bool - */ - public static function dashboardDisabled(): bool - { - return self::dashboardEnabled() === false; - } -} diff --git a/src/CloudTasksApi.php b/src/CloudTasksApi.php index c113bf4..51639fa 100644 --- a/src/CloudTasksApi.php +++ b/src/CloudTasksApi.php @@ -4,20 +4,18 @@ namespace Stackkit\LaravelGoogleCloudTasksQueue; -use Google\Cloud\Tasks\V2\RetryConfig; use Google\Cloud\Tasks\V2\Task; use Illuminate\Support\Facades\Facade; /** - * @method static RetryConfig getRetryConfig(string $queueName) * @method static Task createTask(string $queueName, Task $task) * @method static void deleteTask(string $taskName) * @method static Task getTask(string $taskName) - * @method static int|null getRetryUntilTimestamp(Task $task) + * @method static bool exists(string $taskName) */ class CloudTasksApi extends Facade { - protected static function getFacadeAccessor() + protected static function getFacadeAccessor(): string { return 'cloud-tasks-api'; } diff --git a/src/CloudTasksApiConcrete.php b/src/CloudTasksApiConcrete.php index d63b8ed..b0b8b92 100644 --- a/src/CloudTasksApiConcrete.php +++ b/src/CloudTasksApiConcrete.php @@ -4,75 +4,65 @@ namespace Stackkit\LaravelGoogleCloudTasksQueue; -use Exception; -use Google\Cloud\Tasks\V2\Attempt; -use Google\Cloud\Tasks\V2\CloudTasksClient; -use Google\Cloud\Tasks\V2\RetryConfig; +use Google\ApiCore\ApiException; +use Google\Cloud\Tasks\V2\Client\CloudTasksClient; +use Google\Cloud\Tasks\V2\CreateTaskRequest; +use Google\Cloud\Tasks\V2\DeleteTaskRequest; +use Google\Cloud\Tasks\V2\GetTaskRequest; use Google\Cloud\Tasks\V2\Task; -use Google\Protobuf\Duration; -use Google\Protobuf\Timestamp; class CloudTasksApiConcrete implements CloudTasksApiContract { - /** - * @var CloudTasksClient $client - */ - private $client; - - public function __construct(CloudTasksClient $client) + public function __construct(private readonly CloudTasksClient $client) { - $this->client = $client; - } - - public function getRetryConfig(string $queueName): RetryConfig - { - $retryConfig = $this->client->getQueue($queueName)->getRetryConfig(); - - if (! $retryConfig instanceof RetryConfig) { - throw new Exception('Queue does not have a retry config.'); - } - - return $retryConfig; + // } + /** + * @throws ApiException + */ public function createTask(string $queueName, Task $task): Task { - return $this->client->createTask($queueName, $task); + return $this->client->createTask(new CreateTaskRequest([ + 'parent' => $queueName, + 'task' => $task, + ])); } + /** + * @throws ApiException + */ public function deleteTask(string $taskName): void { - $this->client->deleteTask($taskName); + $this->client->deleteTask(new DeleteTaskRequest([ + 'name' => $taskName, + ])); } + /** + * @throws ApiException + */ public function getTask(string $taskName): Task { - return $this->client->getTask($taskName); + return $this->client->getTask(new GetTaskRequest([ + 'name' => $taskName, + ])); } - public function getRetryUntilTimestamp(Task $task): ?int + public function exists(string $taskName): bool { - $attempt = $task->getFirstAttempt(); - - if (!$attempt instanceof Attempt) { - return null; - } - - $queueName = implode('/', array_slice(explode('/', $task->getName()), 0, 6)); - - $retryConfig = $this->getRetryConfig($queueName); + try { + $this->getTask($taskName); - $maxRetryDuration = $retryConfig->getMaxRetryDuration(); - $dispatchTime = $attempt->getDispatchTime(); + return true; + } catch (ApiException $e) { + if ($e->getStatus() === 'NOT_FOUND') { + return false; + } - if (! $maxRetryDuration instanceof Duration || ! $dispatchTime instanceof Timestamp) { - return null; + report($e); } - $maxDurationInSeconds = (int) $maxRetryDuration->getSeconds(); - - $firstAttemptTimestamp = $dispatchTime->toDateTime()->getTimestamp(); - - return $firstAttemptTimestamp + $maxDurationInSeconds; + return false; } } diff --git a/src/CloudTasksApiContract.php b/src/CloudTasksApiContract.php index aa0880b..5f0af35 100644 --- a/src/CloudTasksApiContract.php +++ b/src/CloudTasksApiContract.php @@ -4,14 +4,15 @@ namespace Stackkit\LaravelGoogleCloudTasksQueue; -use Google\Cloud\Tasks\V2\RetryConfig; use Google\Cloud\Tasks\V2\Task; interface CloudTasksApiContract { - public function getRetryConfig(string $queueName): RetryConfig; public function createTask(string $queueName, Task $task): Task; + public function deleteTask(string $taskName): void; + public function getTask(string $taskName): Task; - public function getRetryUntilTimestamp(Task $task): ?int; + + public function exists(string $taskName): bool; } diff --git a/src/CloudTasksApiController.php b/src/CloudTasksApiController.php deleted file mode 100644 index 1d3a771..0000000 --- a/src/CloudTasksApiController.php +++ /dev/null @@ -1,186 +0,0 @@ -getTimestamp() + 900); - } - - public function dashboard(): array - { - $dbDriver = config('database.connections.' . config('database.default') . '.driver'); - - if (!in_array($dbDriver, ['mysql', 'pgsql'])) { - throw new Exception('Unsupported database driver for Cloud Tasks dashboard.'); - } - - $groupBy = [ - 'mysql' => [ - 'this_minute' => 'DATE_FORMAT(created_at, \'%H:%i\')', - 'this_hour' => 'DATE_FORMAT(created_at, \'%H\')', - ], - 'pgsql' => [ - 'this_minute' => 'TO_CHAR(created_at :: TIME, \'HH24:MI\')', - 'this_hour' => 'TO_CHAR(created_at :: TIME, \'HH24\')', - ], - ][$dbDriver]; - - /** - * @var array $stats - */ - $stats = DB::table((new StackkitCloudTask())->getTable()) - ->where('created_at', '>=', now()->utc()->startOfDay()) - ->select( - [ - DB::raw('COUNT(id) as count'), - DB::raw('CASE WHEN status = \'failed\' THEN 1 ELSE 0 END AS failed'), - DB::raw(' - CASE - WHEN ' . $groupBy['this_minute'] . ' = \'' . now()->utc()->format('H:i') . '\' THEN \'this_minute\' - WHEN ' . $groupBy['this_hour'] . ' = \'' . now()->utc()->format('H') . '\' THEN \'this_hour\' - - ELSE \'today\' - END AS time_preset - ') - ] - ) - ->groupBy( - [ - 'failed', - 'time_preset', - ] - ) - ->get() - ->map(fn($row) => StatRow::createFromObject($row)) - ->toArray(); - - $response = [ - 'recent' => [ - 'this_minute' => 0, - 'this_hour' => 0, - 'this_day' => 0, - ], - 'failed' => [ - 'this_minute' => 0, - 'this_hour' => 0, - 'this_day' => 0, - ], - ]; - - foreach ($stats as $row) { - $response['recent']['this_day'] += $row->count; - - if ($row->time_preset === 'this_minute') { - $response['recent']['this_minute'] += $row->count; - $response['recent']['this_hour'] += $row->count; - } - - if ($row->time_preset === 'this_hour') { - $response['recent']['this_hour'] += $row->count; - } - - if ($row->failed === 0) { - continue; - } - - $response['failed']['this_day'] += $row->count; - - if ($row->time_preset === 'this_minute') { - $response['failed']['this_minute'] += $row->count; - $response['failed']['this_hour'] += $row->count; - } - - if ($row->time_preset === 'this_hour') { - $response['failed']['this_hour'] += $row->count; - } - } - - return $response; - } - - /** - * @return Collection - */ - public function tasks() - { - Carbon::setTestNowAndTimezone(now()->utc()); - - $tasks = StackkitCloudTask::query() - ->newestFirst() - ->where('created_at', '>=', now()->utc()->startOfDay()) - ->when(request('filter') === 'failed', function (Builder $builder) { - return $builder->where('status', 'failed'); - }) - ->when(request('time'), function (Builder $builder) { - [$hour, $minute] = explode(':', request('time')); - - return $builder - ->where('created_at', '>=', now()->setTime((int) $hour, (int) $minute, 0)) - ->where('created_at', '<=', now()->setTime((int) $hour, (int) $minute, 59)); - }) - ->when(request('hour'), function (Builder $builder, $hour) { - return $builder->where('created_at', '>=', now()->setTime((int) $hour, 0, 0)) - ->where('created_at', '<=', now()->setTime((int) $hour, 59, 59)); - }) - ->when(request('queue'), function (Builder $builder, $queue) { - return $builder->where('queue', $queue); - }) - ->when(request('status'), function (Builder $builder, $status) { - return $builder->where('status', $status); - }) - ->limit(100) - ->get(); - - $maxId = $tasks->max('id'); - - return $tasks->map(function (StackkitCloudTask $task) use ($maxId) - { - return [ - 'uuid' => $task->task_uuid, - 'id' => str_pad((string) $task->id, strlen($maxId), '0', STR_PAD_LEFT), - 'name' => $task->name, - 'status' => $task->status, - 'attempts' => $task->getNumberOfAttempts(), - 'created' => $task->created_at ? $task->created_at->diffForHumans() : null, - 'queue' => $task->queue, - ]; - }); - } - - public function task(string $uuid): array - { - $task = StackkitCloudTask::findByUuid($uuid); - - return [ - 'id' => $task->id, - 'status' => $task->status, - 'queue' => $task->queue, - 'events' => $task->getEvents(), - 'payload' => $task->getPayloadPretty(), - 'exception' => $task->getMetadata()['exception'] ?? null, - ]; - } -} diff --git a/src/CloudTasksApiFake.php b/src/CloudTasksApiFake.php index f1af5da..074eb68 100644 --- a/src/CloudTasksApiFake.php +++ b/src/CloudTasksApiFake.php @@ -5,28 +5,14 @@ namespace Stackkit\LaravelGoogleCloudTasksQueue; use Closure; -use Google\Cloud\Tasks\V2\RetryConfig; use Google\Cloud\Tasks\V2\Task; -use Google\Protobuf\Duration; -use Illuminate\Support\Arr; -use Illuminate\Support\Str; use PHPUnit\Framework\Assert; class CloudTasksApiFake implements CloudTasksApiContract { public array $createdTasks = []; - public array $deletedTasks = []; - - public function getRetryConfig(string $queueName): RetryConfig - { - $retryConfig = new RetryConfig(); - - $retryConfig - ->setMinBackoff((new Duration(['seconds' => 0]))) - ->setMaxBackoff((new Duration(['seconds' => 0]))); - return $retryConfig; - } + public array $deletedTasks = []; public function createTask(string $queueName, Task $task): Task { @@ -46,17 +32,22 @@ public function getTask(string $taskName): Task ->setName($taskName); } - - public function getRetryUntilTimestamp(Task $task): ?int + public function exists(string $taskName): bool { - return null; + foreach ($this->createdTasks as $createdTask) { + if ($createdTask['task']->getName() === $taskName) { + return ! in_array($taskName, $this->deletedTasks); + } + } + + return false; } public function assertTaskDeleted(string $taskName): void { Assert::assertTrue( in_array($taskName, $this->deletedTasks), - 'The task [' . $taskName . '] should have been deleted but it is not.' + 'The task ['.$taskName.'] should have been deleted but it is not.' ); } @@ -64,7 +55,7 @@ public function assertTaskNotDeleted(string $taskName): void { Assert::assertTrue( ! in_array($taskName, $this->deletedTasks), - 'The task [' . $taskName . '] should not have been deleted but it was.' + 'The task ['.$taskName.'] should not have been deleted but it was.' ); } diff --git a/src/CloudTasksConnector.php b/src/CloudTasksConnector.php index 8ff23e9..60aa5f8 100644 --- a/src/CloudTasksConnector.php +++ b/src/CloudTasksConnector.php @@ -1,26 +1,20 @@ getSchemeAndHttpHost(); - }; - } - - return new CloudTasksQueue($config, app(CloudTasksClient::class), $config['after_commit'] ?? null); + return new CloudTasksQueue( + config: $config, + client: app(CloudTasksClient::class), + dispatchAfterCommit: $config['after_commit'] ?? null + ); } } diff --git a/src/CloudTasksException.php b/src/CloudTasksException.php deleted file mode 100644 index 2bef1dc..0000000 --- a/src/CloudTasksException.php +++ /dev/null @@ -1,10 +0,0 @@ -container = $container; + $this->driver = $driver; $this->job = $job; - $this->container = Container::getInstance(); - $this->cloudTasksQueue = $cloudTasksQueue; - - $command = TaskHandler::getCommandProperties($job['data']['command']); - $this->queue = $command['queue'] ?? config('queue.connections.' .config('queue.default') . '.queue'); - } - - public function job() - { - return $this->job; + $this->connectionName = $connectionName; + $this->queue = $queue; } public function getJobId(): string { - return $this->job['uuid']; - } - - public function uuid(): string - { - return $this->job['uuid']; + return $this->uuid() ?? throw new Exception(); } + /** + * @throws JsonException + */ public function getRawBody(): string { return json_encode($this->job); @@ -66,89 +62,21 @@ public function setAttempts(int $attempts): void $this->job['internal']['attempts'] = $attempts; } - public function setMaxTries(int $maxTries): void - { - if ($maxTries === -1) { - $maxTries = 0; - } - - $this->maxTries = $maxTries; - } - - public function maxTries(): ?int - { - return $this->maxTries; - } - - public function setQueue(string $queue): void - { - $this->queue = $queue; - } - - public function setRetryUntil(?int $retryUntil): void - { - $this->retryUntil = $retryUntil; - } - - public function retryUntil(): ?int - { - return $this->retryUntil; - } - - // timeoutAt was renamed to retryUntil in 8.x but we still support this. - public function timeoutAt(): ?int - { - return $this->retryUntil; - } - public function delete(): void { - // Laravel automatically calls delete() after a job is processed successfully. However, this is - // not what we want to happen in Cloud Tasks because Cloud Tasks will also delete the task upon - // a 200 OK status, which means a task is deleted twice, possibly resulting in errors. So if the - // task was processed successfully (no errors or failures) then we will not delete the task - // manually and will let Cloud Tasks do it. - $successful = - // If the task has failed, we should be able to delete it permanently - $this->hasFailed() === false - // If the task has errored, it should be released, which in process deletes the errored task - && $this->hasError() === false; - - if ($successful) { - return; - } - - parent::delete(); - - $this->cloudTasksQueue->delete($this); + // Laravel automatically calls delete() after a job is processed successfully. + // However, this is not what we want to happen in Cloud Tasks because Cloud Tasks + // will also delete the task upon a 200 OK status, which means a task is deleted twice. } - public function hasError(): bool + public function release($delay = 0): void { - return data_get($this->job, 'internal.errored') === true; - } + parent::release($delay); - public function release($delay = 0) - { - parent::release(); - - $this->cloudTasksQueue->release($this, $delay); - - $properties = TaskHandler::getCommandProperties($this->job['data']['command']); - $connection = $properties['connection'] ?? config('queue.default'); - - // The package uses the JobReleasedAfterException provided by Laravel to grab - // the payload of the released job in tests to easily run and test a released - // job. Because the event is only accessible in Laravel 9.x, we create an - // identical event to hook into for Laravel versions older than 9.x - if (version_compare(app()->version(), '9.0.0', '<')) { - if (data_get($this->job, 'internal.errored')) { - app('events')->dispatch(new JobReleasedAfterException($connection, $this)); - } - } + $this->driver->release($this, $delay); if (! data_get($this->job, 'internal.errored')) { - app('events')->dispatch(new JobReleased($connection, $this, $delay)); + event(new JobReleased($this->getConnectionName(), $this, $delay)); } } } diff --git a/src/CloudTasksQueue.php b/src/CloudTasksQueue.php index ccb7798..15738eb 100644 --- a/src/CloudTasksQueue.php +++ b/src/CloudTasksQueue.php @@ -1,19 +1,20 @@ client = $client; - $this->config = $config; - $this->dispatchAfterCommit = $dispatchAfterCommit; + // } - /** - * Get the size of the queue. - * - * @param string|null $queue - * @return int - */ - public function size($queue = null) + public static function configureHandlerUrlUsing(Closure $callback): void { - // It is not possible to know the number of tasks in the queue. - return 0; + static::$handlerUrlCallback = $callback; + } + + public static function forgetHandlerUrlCallback(): void + { + self::$handlerUrlCallback = null; + } + + public static function setTaskHeadersUsing(Closure $callback): void + { + static::$taskHeadersCallback = $callback; + } + + public static function forgetTaskHeadersCallback(): void + { + self::$taskHeadersCallback = null; } /** - * Fallback method for Laravel 6x and 7x + * Get the size of the queue. * - * @param \Closure|string|object $job - * @param string $payload - * @param string $queue - * @param \DateTimeInterface|\DateInterval|int|null $delay - * @param callable $callback - * @return mixed + * @param string|null $queue */ - protected function enqueueUsing($job, $payload, $queue, $delay, $callback) + public function size($queue = null): int { - if (method_exists(parent::class, 'enqueueUsing')) { - return parent::enqueueUsing($job, $payload, $queue, $delay, $callback); - } - - return $callback($payload, $queue, $delay); + // It is not possible to know the number of tasks in the queue. + return 0; } /** * Push a new job onto the queue. * - * @param string|object $job - * @param mixed $data - * @param string|null $queue + * @param string|object $job + * @param mixed $data + * @param string|null $queue * @return void */ public function push($job, $data = '', $queue = null) { return $this->enqueueUsing( $job, - $this->createPayload($job, $this->getQueue($queue), $data), + $this->createPayload($job, $queue, $data), $queue, null, - function ($payload, $queue) { - return $this->pushRaw($payload, $queue); + function ($payload, $queue) use ($job) { + return $this->pushRaw($payload, $queue, ['job' => $job]); } ); } @@ -91,36 +87,36 @@ function ($payload, $queue) { /** * Push a raw payload onto the queue. * - * @param string $payload - * @param string|null $queue - * @param array $options + * @param string $payload + * @param string|null $queue * @return string */ public function pushRaw($payload, $queue = null, array $options = []) { - $delay = !empty($options['delay']) ? $options['delay'] : 0; + $delay = ! empty($options['delay']) ? $options['delay'] : 0; + $job = $options['job'] ?? null; - return $this->pushToCloudTasks($queue, $payload, $delay); + return $this->pushToCloudTasks($queue, $payload, $delay, $job); } /** * Push a new job onto the queue after a delay. * - * @param \DateTimeInterface|\DateInterval|int $delay - * @param string|object $job - * @param mixed $data - * @param string|null $queue + * @param \DateTimeInterface|\DateInterval|int $delay + * @param string|object $job + * @param mixed $data + * @param string|null $queue * @return void */ public function later($delay, $job, $data = '', $queue = null) { return $this->enqueueUsing( $job, - $this->createPayload($job, $this->getQueue($queue), $data), + $this->createPayload($job, $queue, $data), $queue, $delay, - function ($payload, $queue, $delay) { - return $this->pushToCloudTasks($queue, $payload, $delay); + function ($payload, $queue, $delay) use ($job) { + return $this->pushToCloudTasks($queue, $payload, $delay, $job); } ); } @@ -128,183 +124,149 @@ function ($payload, $queue, $delay) { /** * Push a job to Cloud Tasks. * - * @param string|null $queue - * @param string $payload - * @param \DateTimeInterface|\DateInterval|int $delay + * @param string|null $queue + * @param string $payload + * @param \DateTimeInterface|\DateInterval|int $delay + * @param string|object $job * @return string */ - protected function pushToCloudTasks($queue, $payload, $delay = 0) + protected function pushToCloudTasks($queue, $payload, $delay, mixed $job) { - $queue = $this->getQueue($queue); - $queueName = $this->client->queueName($this->config['project'], $this->config['location'], $queue); - $availableAt = $this->availableAt($delay); + $queue = $queue ?: $this->config['queue']; - $payload = json_decode($payload, true); + $payload = (array) json_decode($payload, true); - // Laravel 7+ jobs have a uuid, but Laravel 6 doesn't have it. - // Since we are using and expecting the uuid in some places - // we will add it manually here if it's not present yet. - $payload = $this->withUuid($payload); + $task = tap(new Task())->setName($this->taskName($queue, $payload['displayName'])); - // Since 3.x tasks are released back onto the queue after an exception has - // been thrown. This means we lose the native [X-CloudTasks-TaskRetryCount] header - // value and need to manually set and update the number of times a task has been attempted. - $payload = $this->withAttempts($payload); + $payload = $this->enrichPayloadWithAttempts($payload); - $task = $this->createTask(); - $task->setName($this->taskName($queue, $payload)); - - if (!empty($this->config['app_engine'])) { - $path = \Safe\parse_url(route('cloud-tasks.handle-task'), PHP_URL_PATH); - - $appEngineRequest = new AppEngineHttpRequest(); - $appEngineRequest->setRelativeUri($path); - $appEngineRequest->setHttpMethod(HttpMethod::POST); - $appEngineRequest->setBody(json_encode($payload)); - if (!empty($service = $this->config['app_engine_service'])) { - $routing = new AppEngineRouting(); - $routing->setService($service); - $appEngineRequest->setAppEngineRouting($routing); - } - $task->setAppEngineHttpRequest($appEngineRequest); - } else { - $httpRequest = $this->createHttpRequest(); - $httpRequest->setUrl($this->getHandler()); - $httpRequest->setHttpMethod(HttpMethod::POST); - - $httpRequest->setBody(json_encode($payload)); - - $token = new OidcToken; - $token->setServiceAccountEmail($this->config['service_account_email']); - if ($audience = $this->getAudience()) { - $token->setAudience($audience); - } - $httpRequest->setOidcToken($token); - $task->setHttpRequest($httpRequest); - } - - - // The deadline for requests sent to the app. If the app does not respond by - // this deadline then the request is cancelled and the attempt is marked as - // a failure. Cloud Tasks will retry the task according to the RetryConfig. - if (!empty($this->config['dispatch_deadline'])) { - $task->setDispatchDeadline(new Duration(['seconds' => $this->config['dispatch_deadline']])); - } + $this->addPayloadToTask($payload, $task, $job); + $availableAt = $this->availableAt($delay); if ($availableAt > time()) { $task->setScheduleTime(new Timestamp(['seconds' => $availableAt])); } + $queueName = $this->client->queueName($this->config['project'], $this->config['location'], $queue); CloudTasksApi::createTask($queueName, $task); - event((new TaskCreated)->queue($queue)->task($task)); + event(new TaskCreated($queue, $task)); return $payload['uuid']; } - private function withUuid(array $payload): array - { - if (!isset($payload['uuid'])) { - $payload['uuid'] = (string)Str::uuid(); - } - - return $payload; - } - - private function taskName(string $queueName, array $payload): string + private function taskName(string $queueName, string $displayName): string { - $displayName = $this->sanitizeTaskName($payload['displayName']); - return CloudTasksClient::taskName( $this->config['project'], $this->config['location'], $queueName, - $displayName . '-' . $payload['uuid'] . '-' . Carbon::now()->getTimeStampMs(), + str($displayName) + ->afterLast('\\') + ->replaceMatches('![^-\pL\pN\s]+!u', '-') + ->replaceMatches('![-\s]+!u', '-') + ->prepend((string) Str::ulid(), '-') + ->toString(), ); } - private function sanitizeTaskName(string $taskName) - { - // Remove all characters that are not -, letters, numbers, or whitespace - $sanitizedName = preg_replace('![^-\pL\pN\s]+!u', '-', $taskName); - - // Replace all separator characters and whitespace by a - - $sanitizedName = preg_replace('![-\s]+!u', '-', $sanitizedName); + private function enrichPayloadWithAttempts( + array $payload, + ): array { + $payload['internal'] = [ + 'attempts' => $payload['internal']['attempts'] ?? 0, + ]; - return trim($sanitizedName, '-'); + return $payload; } - private function withAttempts(array $payload): array + /** @param string|object $job */ + public function addPayloadToTask(array $payload, Task $task, mixed $job): Task { - if (!isset($payload['internal']['attempts'])) { - $payload['internal']['attempts'] = 0; + $headers = $this->headers($payload); + + if (! empty($this->config['app_engine'])) { + $path = \Safe\parse_url(route('cloud-tasks.handle-task'), PHP_URL_PATH); + + $appEngineRequest = new AppEngineHttpRequest(); + $appEngineRequest->setRelativeUri($path); + $appEngineRequest->setHttpMethod(HttpMethod::POST); + $appEngineRequest->setBody(json_encode($payload)); + $appEngineRequest->setHeaders($headers); + + if (! empty($service = $this->config['app_engine_service'])) { + $routing = new AppEngineRouting(); + $routing->setService($service); + $appEngineRequest->setAppEngineRouting($routing); + } + + $task->setAppEngineHttpRequest($appEngineRequest); + } else { + $httpRequest = new HttpRequest(); + $httpRequest->setUrl($this->getHandler($job)); + $httpRequest->setBody(json_encode($payload)); + $httpRequest->setHttpMethod(HttpMethod::POST); + $httpRequest->setHeaders($headers); + + $token = new OidcToken; + $token->setServiceAccountEmail($this->config['service_account_email']); + $httpRequest->setOidcToken($token); + $task->setHttpRequest($httpRequest); } - return $payload; + return $task; } - /** - * Pop the next job off of the queue. - * - * @param string|null $queue - * @return \Illuminate\Contracts\Queue\Job|null - */ public function pop($queue = null) { - // TODO: Implement pop() method. - } - - private function getQueue(?string $queue = null): string - { - return $queue ?: $this->config['queue']; + // It is not possible to pop a job from the queue. + return null; } - private function createHttpRequest(): HttpRequest + public function delete(CloudTasksJob $job): void { - return app(HttpRequest::class); + // Job deletion will be handled by Cloud Tasks. } - public function delete(CloudTasksJob $job): void + public function release(CloudTasksJob $job, int $delay = 0): void { - $config = $this->config; - - $queue = $job->getQueue() ?: $this->config['queue']; // @todo: make this a helper method somewhere. - - $headerTaskName = request()->headers->get('X-Cloudtasks-Taskname') - ?? request()->headers->get('X-AppEngine-TaskName'); - $taskName = $this->client->taskName( - $config['project'], - $config['location'], - $queue, - (string)$headerTaskName + $this->pushRaw( + payload: $job->getRawBody(), + queue: $job->getQueue(), + options: ['delay' => $delay, 'job' => $job], ); - - CloudTasksApi::deleteTask($taskName); } - public function release(CloudTasksJob $job, int $delay = 0): void + /** @param string|object $job */ + public function getHandler(mixed $job): string { - $job->delete(); + if (static::$handlerUrlCallback) { + return (static::$handlerUrlCallback)($job); + } - $payload = $job->getRawBody(); + if (empty($this->config['handler'])) { + $this->config['handler'] = request()->getSchemeAndHttpHost(); + } - $options = ['delay' => $delay]; + $handler = rtrim($this->config['handler'], '/'); - $this->pushRaw($payload, $job->getQueue(), $options); - } + if (str_ends_with($handler, '/'.config('cloud-tasks.uri'))) { + return $handler; + } - private function createTask(): Task - { - return app(Task::class); + return $handler.'/'.config('cloud-tasks.uri'); } - public function getHandler(): string + /** + * @param array $payload + * @return array + */ + private function headers(mixed $payload): array { - return Config::getHandler($this->config['handler']); - } + if (! static::$taskHeadersCallback) { + return []; + } - public function getAudience(): ?string - { - return Config::getAudience($this->config); + return (static::$taskHeadersCallback)($payload); } } diff --git a/src/CloudTasksServiceProvider.php b/src/CloudTasksServiceProvider.php index d22a281..d6fbe1e 100644 --- a/src/CloudTasksServiceProvider.php +++ b/src/CloudTasksServiceProvider.php @@ -1,17 +1,16 @@ registerClient(); $this->registerConnector(); $this->registerConfig(); - $this->registerViews(); - $this->registerAssets(); - $this->registerMigrations(); $this->registerRoutes(); - $this->registerDashboard(); + $this->registerEvents(); } private function registerClient(): void @@ -33,7 +29,15 @@ private function registerClient(): void return new CloudTasksClient(); }); - $this->app->bind('open-id-verificator', OpenIdVerificatorConcrete::class); + $this->app->singleton('cloud-tasks.worker', function (Application $app) { + return new Worker( + $app['queue'], + $app['events'], + $app[ExceptionHandler::class], + fn () => $app->isDownForMaintenance(), + ); + }); + $this->app->bind('cloud-tasks-api', CloudTasksApiConcrete::class); } @@ -52,157 +56,61 @@ private function registerConnector(): void private function registerConfig(): void { $this->publishes([ - __DIR__ . '/../config/cloud-tasks.php' => config_path('cloud-tasks.php'), + __DIR__.'/../config/cloud-tasks.php' => config_path('cloud-tasks.php'), ], ['cloud-tasks']); - $this->mergeConfigFrom(__DIR__ . '/../config/cloud-tasks.php', 'cloud-tasks'); - } - - private function registerViews(): void - { - if (CloudTasks::dashboardDisabled()) { - // Larastan needs this view registered to check the service provider correctly. - // return; - } - - $this->loadViewsFrom(__DIR__ . '/../views', 'cloud-tasks'); - } - - private function registerAssets(): void - { - if (CloudTasks::dashboardDisabled()) { - return; - } - - $this->publishes([ - __DIR__ . '/../dashboard/dist' => public_path('vendor/cloud-tasks'), - ], ['cloud-tasks']); + $this->mergeConfigFrom(__DIR__.'/../config/cloud-tasks.php', 'cloud-tasks'); } - private function registerMigrations(): void + private function registerRoutes(): void { - if (CloudTasks::dashboardDisabled()) { + if (config('cloud-tasks.disable_task_handler')) { return; } - $this->loadMigrationsFrom([ - __DIR__ . '/../migrations', - ]); - } - - private function registerRoutes(): void - { /** * @var \Illuminate\Routing\Router $router */ $router = $this->app['router']; - $router->post('handle-task', [TaskHandler::class, 'handle'])->name('cloud-tasks.handle-task'); - - if (CloudTasks::dashboardDisabled()) { - return; - } - - $router->post('cloud-tasks-api/login', [CloudTasksApiController::class, 'login'])->name('cloud-tasks.api.login'); - $router->get('cloud-tasks/{view?}', function () { - return view('cloud-tasks::layout', [ - 'manifest' => json_decode(file_get_contents(public_path('vendor/cloud-tasks/manifest.json')), true), - 'isDownForMaintenance' => app()->isDownForMaintenance(), - 'cloudTasksScriptVariables' => [ - 'path' => 'cloud-tasks', - ], - ]); - })->where( - 'view', - '(.+)' - )->name( - 'cloud-tasks.index' - ); - - $router->middleware(Authenticate::class)->group(function () use ($router) { - $router->get('cloud-tasks-api/dashboard', [CloudTasksApiController::class, 'dashboard'])->name('cloud-tasks.api.dashboard'); - $router->get('cloud-tasks-api/tasks', [CloudTasksApiController::class, 'tasks'])->name('cloud-tasks.api.tasks'); - $router->get('cloud-tasks-api/task/{uuid}', [CloudTasksApiController::class, 'task'])->name('cloud-tasks.api.task'); - }); + $router->post(config('cloud-tasks.uri'), [TaskHandler::class, 'handle'])->name('cloud-tasks.handle-task'); } - private function registerDashboard(): void + private function registerEvents(): void { $events = $this->app['events']; - $events->listen(TaskCreated::class, function (TaskCreated $event) { - if (CloudTasks::dashboardDisabled()) { - return; - } - - DashboardService::make()->add($event->queue, $event->task); - }); - $events->listen(JobFailed::class, function (JobFailed $event) { - if (!$event->job instanceof CloudTasksJob) { + if (! $event->job instanceof CloudTasksJob) { return; } - $config = $event->job->cloudTasksQueue->config; - app('queue.failer')->log( - $config['connection'], $event->job->getQueue() ?: $config['queue'], - $event->job->getRawBody(), $event->exception + $event->job->getConnectionName(), + $event->job->getQueue(), + $event->job->getRawBody(), + $event->exception, ); }); - $events->listen(JobProcessing::class, function (JobProcessing $event) { - if (!$event->job instanceof CloudTasksJob) { - return; - } - - if (CloudTasks::dashboardEnabled()) { - DashboardService::make()->markAsRunning($event->job->uuid()); - } - }); - - $events->listen(JobProcessed::class, function (JobProcessed $event) { - if (!$event->job instanceof CloudTasksJob) { - return; - } - - data_set($event->job->job, 'internal.processed', true); - - if (CloudTasks::dashboardEnabled()) { - DashboardService::make()->markAsSuccessful($event->job->uuid()); - } - }); - $events->listen(JobExceptionOccurred::class, function (JobExceptionOccurred $event) { - if (!$event->job instanceof CloudTasksJob) { + if (! $event->job instanceof CloudTasksJob) { return; } data_set($event->job->job, 'internal.errored', true); - - if (CloudTasks::dashboardEnabled()) { - DashboardService::make()->markAsError($event); - } }); $events->listen(JobFailed::class, function ($event) { - if (!$event->job instanceof CloudTasksJob) { + if (! $event->job instanceof CloudTasksJob) { return; } - - if (CloudTasks::dashboardEnabled()) { - DashboardService::make()->markAsFailed($event); - } }); $events->listen(JobReleased::class, function (JobReleased $event) { - if (!$event->job instanceof CloudTasksJob) { + if (! $event->job instanceof CloudTasksJob) { return; } - - if (CloudTasks::dashboardEnabled()) { - DashboardService::make()->markAsReleased($event); - } }); } } diff --git a/src/Config.php b/src/Config.php deleted file mode 100644 index 9819d6e..0000000 --- a/src/Config.php +++ /dev/null @@ -1,80 +0,0 @@ -getHttpRequest() ?: $task->getAppEngineHttpRequest(); - - if (! $httpRequest) { - throw new Exception('Task does not have a HTTP request.'); - } - - return $httpRequest->getBody(); - } - - public function add(string $queue, Task $task): void - { - $uuid = $this->getTaskUuid($task); - - if (StackkitCloudTask::whereTaskUuid($uuid)->exists()) { - return; - } - - $metadata = new TaskMetadata(); - $metadata->payload = $this->getTaskBody($task); - - $data = [ - 'queue' => $queue, - ]; - - $scheduleTime = $task->getScheduleTime(); - - if ($scheduleTime) { - $status = 'scheduled'; - $data['scheduled_at'] = $scheduleTime->toDateTime()->format('Y-m-d H:i:s'); - } else { - $status = 'queued'; - } - - $metadata->addEvent($status, $data); - - DB::table('stackkit_cloud_tasks') - ->insert([ - 'task_uuid' => $uuid, - 'name' => $this->getTaskName($task), - 'queue' => $queue, - 'payload' => $this->getTaskBody($task), - 'status' => $status, - 'metadata' => $metadata->toJson(), - 'created_at' => now()->utc(), - 'updated_at' => now()->utc(), - ]); - } - - public function markAsRunning(string $uuid): void - { - $task = StackkitCloudTask::findByUuid($uuid); - - $task->status = 'running'; - $task->addMetadataEvent([ - 'status' => $task->status, - 'datetime' => now()->utc()->toDateTimeString(), - ]); - - $task->save(); - } - - public function markAsSuccessful(string $uuid): void - { - $task = StackkitCloudTask::findByUuid($uuid); - - if ($task->status === 'released') { - return; - } - - $task->status = 'successful'; - $task->addMetadataEvent([ - 'status' => $task->status, - 'datetime' => now()->utc()->toDateTimeString(), - ]); - - $task->save(); - } - - public function markAsError(JobExceptionOccurred $event): void - { - /** @var CloudTasksJob $job */ - $job = $event->job; - - try { - $task = StackkitCloudTask::findByUuid($job->uuid()); - } catch (ModelNotFoundException $e) { - return; - } - - if ($task->status === 'failed') { - return; - } - - $task->status = 'error'; - $task->addMetadataEvent([ - 'status' => $task->status, - 'datetime' => now()->utc()->toDateTimeString(), - ]); - $task->setMetadata('exception', (string) $event->exception); - - $task->save(); - } - - public function markAsFailed(JobFailed $event): void - { - /** @var CloudTasksJob $job */ - $job = $event->job; - - $task = StackkitCloudTask::findByUuid($job->uuid()); - - $task->status = 'failed'; - $task->addMetadataEvent([ - 'status' => $task->status, - 'datetime' => now()->utc()->toDateTimeString(), - ]); - - $task->save(); - } - - public function markAsReleased(JobReleased $event): void - { - /** @var CloudTasksJob $job */ - $job = $event->job; - - $task = StackkitCloudTask::findByUuid($job->uuid()); - - $task->status = 'released'; - $task->addMetadataEvent([ - 'status' => $task->status, - 'datetime' => now()->utc()->toDateTimeString(), - 'delay' => $event->delay, - ]); - - $task->save(); - } - - private function getTaskName(Task $task): string - { - /** @var array $decode */ - $decode = json_decode($this->getTaskBody($task), true); - - return $decode['displayName']; - } - - private function getTaskUuid(Task $task): string - { - /** @var array $task */ - $task = json_decode($this->getTaskBody($task), true); - - return $task['uuid']; - } -} diff --git a/src/Entities/StatRow.php b/src/Entities/StatRow.php deleted file mode 100644 index a92d18a..0000000 --- a/src/Entities/StatRow.php +++ /dev/null @@ -1,21 +0,0 @@ - $value) { - $object->{$key} = $value; - } - - return $object; - } -} diff --git a/src/Errors.php b/src/Errors.php deleted file mode 100644 index 1d73f64..0000000 --- a/src/Errors.php +++ /dev/null @@ -1,26 +0,0 @@ -job = $job; - $this->connectionName = $connectionName; - $this->delay = $delay; + // } } diff --git a/src/Events/JobReleasedAfterException.php b/src/Events/JobReleasedAfterException.php deleted file mode 100644 index 603fbe3..0000000 --- a/src/Events/JobReleasedAfterException.php +++ /dev/null @@ -1,37 +0,0 @@ -job = $job; - $this->connectionName = $connectionName; - } -} diff --git a/src/Events/TaskCreated.php b/src/Events/TaskCreated.php index a05f415..a95608e 100644 --- a/src/Events/TaskCreated.php +++ b/src/Events/TaskCreated.php @@ -8,20 +8,8 @@ class TaskCreated { - public string $queue; - public Task $task; - - public function task(Task $task): self - { - $this->task = $task; - - return $this; - } - - public function queue(string $queue): self + public function __construct(public string $queue, public Task $task) { - $this->queue = $queue; - - return $this; + // } } diff --git a/src/Events/TaskIncoming.php b/src/Events/TaskIncoming.php new file mode 100644 index 0000000..f25fc32 --- /dev/null +++ b/src/Events/TaskIncoming.php @@ -0,0 +1,15 @@ +task === []; + } + + public function connection(): string + { + if ($connection = data_get($this->command(), 'connection')) { + return $connection; + } + + return config('queue.default'); + } + + public function queue(): string + { + if ($queue = data_get($this->command(), 'queue')) { + return $queue; + } + + return config('queue.connections.'.$this->connection().'.queue'); + } + + public function shortTaskName(): string + { + return request()->header('X-CloudTasks-TaskName') + ?? request()->header('X-AppEngine-TaskName') + ?? throw new Error('Unable to extract taskname from header'); + } + + public function fullyQualifiedTaskName(): string + { + $config = config('queue.connections.'.$this->connection()); + + return CloudTasksClient::taskName( + project: $config['project'], + location: $config['location'], + queue: $this->queue(), + task: $this->shortTaskName(), + ); + } + + public function command(): array + { + $command = $this->task['data']['command']; + + if (str_starts_with($command, 'O:')) { + return (array) unserialize($command, ['allowed_classes' => false]); + } + + if (app()->bound(Encrypter::class)) { + return (array) unserialize(app(Encrypter::class)->decrypt($command)); + } + + return []; + } + + public function toArray(): array + { + return $this->task; + } +} diff --git a/src/LogFake.php b/src/LogFake.php deleted file mode 100644 index e4e86ff..0000000 --- a/src/LogFake.php +++ /dev/null @@ -1,79 +0,0 @@ -loggedMessages[] = $message; - } - - public function alert(string $message, array $context = []): void - { - $this->loggedMessages[] = $message; - } - - public function critical(string $message, array $context = []): void - { - $this->loggedMessages[] = $message; - } - - public function error(string $message, array $context = []): void - { - $this->loggedMessages[] = $message; - } - - public function warning(string $message, array $context = []): void - { - $this->loggedMessages[] = $message; - } - - public function notice(string $message, array $context = []): void - { - $this->loggedMessages[] = $message; - } - - public function info(string $message, array $context = []): void - { - $this->loggedMessages[] = $message; - } - - public function debug(string $message, array $context = []): void - { - $this->loggedMessages[] = $message; - } - - /** - * @param string $level - */ - public function log($level, string $message, array $context = []): void - { - $this->loggedMessages[] = $message; - } - - public function channel(): self - { - return $this; - } - - public function assertLogged(string $message): void - { - PHPUnit::assertTrue(in_array($message, $this->loggedMessages), 'The message [' . $message . '] was not logged.'); - } - - public function assertNotLogged(string $message): void - { - PHPUnit::assertTrue( - ! in_array($message, $this->loggedMessages), - 'The message [' . $message . '] was logged.' - ); - } -} diff --git a/src/OpenIdVerificator.php b/src/OpenIdVerificator.php deleted file mode 100644 index 185186b..0000000 --- a/src/OpenIdVerificator.php +++ /dev/null @@ -1,20 +0,0 @@ -verify( - $token, - [ - 'audience' => Config::getAudience($config), - 'throwException' => true, - ] - ); - } -} diff --git a/src/OpenIdVerificatorFake.php b/src/OpenIdVerificatorFake.php deleted file mode 100644 index 79cedb6..0000000 --- a/src/OpenIdVerificatorFake.php +++ /dev/null @@ -1,26 +0,0 @@ -verify( - $token, - [ - 'audience' => Config::getAudience($config), - 'throwException' => true, - 'certsLocation' => __DIR__ . '/../tests/Support/self-signed-public-key-as-jwk.json', - ] - ); - } -} diff --git a/src/StackkitCloudTask.php b/src/StackkitCloudTask.php deleted file mode 100644 index 4af02f1..0000000 --- a/src/StackkitCloudTask.php +++ /dev/null @@ -1,117 +0,0 @@ -firstOrFail(); - } - - /** - * @param Builder $builder - * @return Builder - */ - public function scopeNewestFirst(Builder $builder): Builder - { - return $builder->orderByDesc('created_at'); - } - - /** - * @param Builder $builder - * @return Builder - */ - public function scopeFailed(Builder $builder): Builder - { - return $builder->whereStatus('failed'); - } - - public function getMetadata(): array - { - $value = $this->metadata; - - if (is_null($value)) { - return []; - } - - $decoded = json_decode($value, true); - - return is_array($decoded) ? $decoded : []; - } - - public function getNumberOfAttempts(): int - { - return collect($this->getEvents()) - ->where('status', 'running') - ->count(); - } - - /** - * @param mixed $value - */ - public function setMetadata(string $key, $value): void - { - $metadata = $this->getMetadata(); - - Arr::set($metadata, $key, $value); - - $this->metadata = json_encode($metadata); - } - - public function addMetadataEvent(array $event): void - { - $metadata = $this->getMetadata(); - - $metadata['events'] ??= []; - - $metadata['events'][] = $event; - - $this->metadata = json_encode($metadata); - } - - public function getEvents(): array - { - Carbon::setTestNowAndTimezone(now()->utc()); - - /** @var array $events */ - $events = Arr::get($this->getMetadata(), 'events', []); - - return collect($events)->map(function ($event) { - /** @var array $event */ - $event['diff'] = Carbon::parse($event['datetime'])->diffForHumans(); - return $event; - })->toArray(); - } - - public function getPayloadPretty(): string - { - $payload = $this->getMetadata()['payload'] ?? '[]'; - - return json_encode( - json_decode($payload), - JSON_PRETTY_PRINT - ); - } -} diff --git a/src/TaskHandler.php b/src/TaskHandler.php index f8786ec..2cab73e 100644 --- a/src/TaskHandler.php +++ b/src/TaskHandler.php @@ -1,208 +1,77 @@ client = $client; + // } public function handle(?string $task = null): void { - $task = $this->captureTask($task); + $task = IncomingTask::fromJson($task ?: request()->getContent()); - $this->loadQueueConnectionConfiguration($task); + event(new TaskIncoming($task)); - $this->setQueue(); - - $this->guard(); - - $this->handleTask($task); - } - - /** - * @param string|array|null $task - * @return array - * @throws JsonException - */ - private function captureTask($task): array - { - $task = $task ?: (string)(request()->getContent()); - - try { - $array = json_decode($task, true); - } catch (JsonException $e) { - $array = []; + if ($task->isInvalid()) { + abort(422, 'Invalid task payload'); } - $validator = validator([ - 'json' => $task, - 'task' => $array, - ], [ - 'json' => 'required|json', - 'task' => 'required|array', - 'task.data' => 'required|array', - ]); - - try { - $validator->validate(); - } catch (ValidationException $e) { - if (config('app.debug')) { - throw $e; - } else { - abort(404); - } + if (! CloudTasksApi::exists($task->fullyQualifiedTaskName())) { + abort(404); } - return json_decode($task, true); - } - - private function loadQueueConnectionConfiguration(array $task): void - { - $command = self::getCommandProperties($task['data']['command']); - $connection = $command['connection'] ?? config('queue.default'); - $baseConfig = config('queue.connections.' . $connection); - $config = (new CloudTasksConnector())->connect($baseConfig)->config; - - // The connection name from the config may not be the actual connection name - $config['connection'] = $connection; + $config = config('queue.connections.'.$task->connection()); - $this->config = $config; - } + $this->config = is_array($config) ? $config : []; - private function setQueue(): void - { - $this->queue = new CloudTasksQueue($this->config, $this->client); + // We want to catch any errors so we have more fine-grained control over + // how tasks are retried. Cloud Tasks will retry the task if a 5xx status + // is returned. Because we manually manage retries by releaseing jobs, + // we never want to return a 5xx status as that will result in duplicate + // job attempts. + rescue(fn () => $this->run($task), report: false); } - private function guard(): void + private function run(IncomingTask $task): void { - $appEngine = ! empty($this->config['app_engine']); - - if ($appEngine) { - // https://cloud.google.com/tasks/docs/creating-appengine-handlers#reading_task_request_headers - // "If your request handler finds any of the headers listed above, it can trust - // that the request is a Cloud Tasks request." - abort_if(empty(request()->header('X-AppEngine-TaskName')), 404); - } else { - OpenIdVerificator::verify(request()->bearerToken(), $this->config); - } - } - - private function handleTask(array $task): void - { - $job = new CloudTasksJob($task, $this->queue); - - $this->loadQueueRetryConfig($job); - - $fullTaskName = $this->client->taskName( - $this->config['project'], - $this->config['location'], - $job->getQueue() ?: $this->config['queue'], - request()->header('X-CloudTasks-TaskName') ?? request()->header('X-AppEngine-TaskName'), + $queue = tap(new CloudTasksQueue($this->config, $this->client))->setConnectionName($task->connection()); + + $job = new CloudTasksJob( + container: Container::getInstance(), + driver: $queue, + job: $task->toArray(), + connectionName: $task->connection(), + queue: $task->queue(), ); - try { - $apiTask = CloudTasksApi::getTask($fullTaskName); - } catch (ApiException $e) { - if (in_array($e->getStatus(), ['NOT_FOUND', 'PRECONDITION_FAILED'])) { - abort(404); - } - - throw $e; - } - - // If the task has a [X-CloudTasks-TaskRetryCount] header higher than 0, then - // we know the job was created using an earlier version of the package. This - // job does not have the attempts tracked internally yet. - $taskRetryCountHeader = request()->header('X-CloudTasks-TaskRetryCount') ?? request()->header('X-AppEngine-TaskRetryCount'); - if ($taskRetryCountHeader && (int)$taskRetryCountHeader > 0) { - $job->setAttempts((int)$taskRetryCountHeader); - } else { - $job->setAttempts($task['internal']['attempts']); - } - - $job->setMaxTries($this->retryConfig->getMaxAttempts()); - - // If the job is being attempted again we also check if a - // max retry duration has been set. If that duration - // has passed, it should stop trying altogether. - if ($job->attempts() > 0) { - $job->setRetryUntil(CloudTasksApi::getRetryUntilTimestamp($apiTask)); - } - $job->setAttempts($job->attempts() + 1); - app('queue.worker')->process($this->config['connection'], $job, $this->getWorkerOptions()); - } - - private function loadQueueRetryConfig(CloudTasksJob $job): void - { - $queue = $job->getQueue() ?: $this->config['queue']; - - $queueName = $this->client->queueName($this->config['project'], $this->config['location'], $queue); - - $this->retryConfig = CloudTasksApi::getRetryConfig($queueName); - } - - public static function getCommandProperties(string $command): array - { - if (Str::startsWith($command, 'O:')) { - return (array)unserialize($command, ['allowed_classes' => false]); - } - - if (app()->bound(Encrypter::class)) { - return (array)unserialize( - app(Encrypter::class)->decrypt($command), - ['allowed_classes' => ['Illuminate\Support\Carbon']] - ); - } - - return []; + tap(app('cloud-tasks.worker'), fn (Worker $worker) => $worker->process( + connectionName: $job->getConnectionName(), + job: $job, + options: $this->getWorkerOptions() + )); } public function getWorkerOptions(): WorkerOptions { $options = new WorkerOptions(); - $prop = version_compare(app()->version(), '8.0.0', '<') ? 'delay' : 'backoff'; - - $options->$prop = $this->config['backoff'] ?? 0; + if (isset($this->config['backoff'])) { + $options->backoff = $this->config['backoff']; + } return $options; } diff --git a/src/TaskMetadata.php b/src/TaskMetadata.php deleted file mode 100644 index 963bd37..0000000 --- a/src/TaskMetadata.php +++ /dev/null @@ -1,51 +0,0 @@ - $status, - 'datetime' => now()->utc()->toDateTimeString(), - ]; - - $this->events[] = array_merge($additional, $event); - } - - public function toArray(): array - { - return [ - 'events' => $this->events, - 'payload' => $this->payload, - ]; - } - - public function toJson(): string - { - return json_encode($this->toArray()); - } - - public static function createFromArray(array $data): TaskMetadata - { - $metadata = new TaskMetadata(); - - $metadata->events = $data['events']; - $metadata->payload = $data['payload']; - - return $metadata; - } -} diff --git a/src/Worker.php b/src/Worker.php new file mode 100644 index 0000000..f00d85f --- /dev/null +++ b/src/Worker.php @@ -0,0 +1,52 @@ +supportsAsyncSignals()) { + $this->listenForSignals(); + + $this->registerTimeoutHandler($job, $options); + } + + return parent::process($connectionName, $job, $options); + } + + public function kill($status = 0, $options = null): void + { + parent::stop($status, $options); + + // When running tests, we cannot run exit because it will kill the PHPunit process. + // So, to still test that the application has exited, we will simply rely on the + // WorkerStopped event that is fired when the worker is stopped. + if (app()->runningUnitTests()) { + return; + } + + exit($status); + } +} diff --git a/tests/CloudTasksApiTest.php b/tests/CloudTasksApiTest.php index 5b5a1c2..2dd6e2a 100644 --- a/tests/CloudTasksApiTest.php +++ b/tests/CloudTasksApiTest.php @@ -5,12 +5,12 @@ namespace Tests; use Google\ApiCore\ApiException; -use Google\Cloud\Tasks\V2\CloudTasksClient; +use Google\Cloud\Tasks\V2\Client\CloudTasksClient; use Google\Cloud\Tasks\V2\HttpMethod; use Google\Cloud\Tasks\V2\HttpRequest; -use Google\Cloud\Tasks\V2\RetryConfig; use Google\Cloud\Tasks\V2\Task; use Google\Protobuf\Timestamp; +use PHPUnit\Framework\Attributes\Test; use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksApi; class CloudTasksApiTest extends TestCase @@ -28,8 +28,8 @@ protected function setUp(): void ]; foreach ($requiredEnvs as $env) { - if (!env($env)) { - $this->fail('Missing [' . $env . '] environment variable.'); + if (! env($env)) { + $this->fail('Missing ['.$env.'] environment variable.'); } } @@ -42,29 +42,7 @@ protected function setUp(): void } - /** - * @test - */ - public function test_get_retry_config() - { - // Act - $retryConfig = CloudTasksApi::getRetryConfig( - $this->client->queueName( - env('CI_CLOUD_TASKS_PROJECT_ID'), - env('CI_CLOUD_TASKS_LOCATION'), - env('CI_CLOUD_TASKS_QUEUE') - ) - ); - - // Assert - $this->assertInstanceOf(RetryConfig::class, $retryConfig); - $this->assertEquals(2, $retryConfig->getMaxAttempts()); - $this->assertEquals(5, $retryConfig->getMaxRetryDuration()->getSeconds()); - } - - /** - * @test - */ + #[Test] public function test_create_task() { // Arrange @@ -88,14 +66,12 @@ public function test_create_task() // Assert $this->assertMatchesRegularExpression( - '/projects\/' . env('CI_CLOUD_TASKS_PROJECT_ID') . '\/locations\/' . env('CI_CLOUD_TASKS_LOCATION') . '\/queues\/' . env('CI_CLOUD_TASKS_QUEUE') . '\/tasks\/\d+$/', + '/projects\/'.env('CI_CLOUD_TASKS_PROJECT_ID').'\/locations\/'.env('CI_CLOUD_TASKS_LOCATION').'\/queues\/'.env('CI_CLOUD_TASKS_QUEUE').'\/tasks\/\d+$/', $taskName ); } - /** - * @test - */ + #[Test] public function test_delete_task_on_non_existing_task() { // Assert @@ -114,9 +90,7 @@ public function test_delete_task_on_non_existing_task() } - /** - * @test - */ + #[Test] public function test_delete_task() { // Arrange @@ -147,44 +121,4 @@ public function test_delete_task() $this->expectExceptionMessage('NOT_FOUND'); CloudTasksApi::getTask($task->getName()); } - - /** - * @test - */ - public function test_get_retry_until_timestamp() - { - // Arrange - $httpRequest = new HttpRequest(); - $httpRequest->setHttpMethod(HttpMethod::GET); - $httpRequest->setUrl('https://httpstat.us/500'); - - $cloudTask = new Task(); - $cloudTask->setHttpRequest($httpRequest); - - $createdTask = CloudTasksApi::createTask( - $this->client->queueName( - env('CI_CLOUD_TASKS_PROJECT_ID'), - env('CI_CLOUD_TASKS_LOCATION'), - env('CI_CLOUD_TASKS_CUSTOM_QUEUE', env('CI_CLOUD_TASKS_QUEUE')) - ), - $cloudTask, - ); - - $secondsSlept = 0; - while ($createdTask->getFirstAttempt() === null) { - $createdTask = CloudTasksApi::getTask($createdTask->getName()); - sleep(1); - $secondsSlept += 1; - - if ($secondsSlept >= 180) { - $this->fail('Task took too long to get executed.'); - } - } - - // The queue max retry duration is 5 seconds. The max retry until timestamp is calculated from the - // first attempt, so we expect it to be [timestamp first attempt] + 5 seconds. - $expected = $createdTask->getFirstAttempt()->getDispatchTime()->getSeconds() + 5; - $actual = CloudTasksApi::getRetryUntilTimestamp($createdTask); - $this->assertSame($expected, $actual); - } } diff --git a/tests/CloudTasksDashboardTest.php b/tests/CloudTasksDashboardTest.php deleted file mode 100644 index c8820c9..0000000 --- a/tests/CloudTasksDashboardTest.php +++ /dev/null @@ -1,657 +0,0 @@ -create(); - - // Act - $response = $this->getJson('/cloud-tasks-api/dashboard'); - - // Assert - $response->assertStatus(200); - } - - /** - * @test - */ - public function it_counts_the_number_of_tasks() - { - // Arrange - Carbon::setTestNow(Carbon::parse('2022-01-01 15:15:00')); - $lastMinute = now()->startOfMinute()->subMinute(); - $thisMinute = now()->startOfMinute(); - $thisHour = now()->startOfHour(); - $thisDay = now()->startOfDay(); - - factory(StackkitCloudTask::class)->create(['status' => 'queued', 'created_at' => $thisMinute]); - factory(StackkitCloudTask::class)->create(['status' => 'queued', 'created_at' => $thisHour]); - factory(StackkitCloudTask::class)->create(['status' => 'queued', 'created_at' => $thisDay]); - factory(StackkitCloudTask::class)->create(['status' => 'queued', 'created_at' => $lastMinute]); - - factory(StackkitCloudTask::class)->create(['status' => 'failed', 'created_at' => $thisMinute]); - factory(StackkitCloudTask::class)->create(['status' => 'failed', 'created_at' => $thisHour]); - factory(StackkitCloudTask::class)->create(['status' => 'failed', 'created_at' => $thisDay]); - factory(StackkitCloudTask::class)->create(['status' => 'failed', 'created_at' => $lastMinute]); - - // Act - $response = $this->getJson('/cloud-tasks-api/dashboard'); - - // Assert - $this->assertEquals(2, $response->json('recent.this_minute')); - $this->assertEquals(6, $response->json('recent.this_hour')); - $this->assertEquals(8, $response->json('recent.this_day')); - - $this->assertEquals(1, $response->json('failed.this_minute')); - $this->assertEquals(3, $response->json('failed.this_hour')); - $this->assertEquals(4, $response->json('failed.this_day')); - } - - /** - * @test - */ - public function tasks_shows_newest_first() - { - // Arrange - factory(StackkitCloudTask::class)->create(['created_at' => now()->subMinute()]); - $task = factory(StackkitCloudTask::class)->create(['created_at' => now()]); - - // Act - $response = $this->getJson('/cloud-tasks-api/tasks'); - - // Assert - $this->assertEquals($task->task_uuid, $response->json('0.uuid')); - } - - /** - * @test - */ - public function it_shows_tasks_only_from_today() - { - // Arrange - factory(StackkitCloudTask::class)->create(['created_at' => today()]); - factory(StackkitCloudTask::class)->create(['created_at' => today()->subDay()]); - - // Act - $response = $this->getJson('/cloud-tasks-api/tasks'); - - // Assert - $this->assertCount(1, $response->json()); - } - - /** - * @test - */ - public function it_can_filter_only_failed_tasks() - { - // Arrange - factory(StackkitCloudTask::class)->create(['status' => 'pending']); - factory(StackkitCloudTask::class)->create(['status' => 'failed']); - - // Act - $response = $this->getJson('/cloud-tasks-api/tasks?filter=failed'); - - // Assert - $this->assertCount(1, $response->json()); - } - - /** - * @test - */ - public function it_can_filter_tasks_created_at_exact_time() - { - // Arrange - factory(StackkitCloudTask::class)->create(['created_at' => now()->setTime(15,4, 59)]); - factory(StackkitCloudTask::class)->create(['created_at' => now()->setTime(16,5, 0)]); - factory(StackkitCloudTask::class)->create(['created_at' => now()->setTime(16,5, 59)]); - factory(StackkitCloudTask::class)->create(['created_at' => now()->setTime(16,6, 0)]); - - // Act - $response = $this->getJson('/cloud-tasks-api/tasks?time=16:05'); - - // Assert - $this->assertCount(2, $response->json()); - } - - /** - * @test - */ - public function it_can_filter_tasks_created_at_exact_hour() - { - // Arrange - factory(StackkitCloudTask::class)->create(['created_at' => now()->setTime(15,59, 59)]); - factory(StackkitCloudTask::class)->create(['created_at' => now()->setTime(16,5, 59)]); - factory(StackkitCloudTask::class)->create(['created_at' => now()->setTime(16,32, 32)]); - - // Act - $response = $this->getJson('/cloud-tasks-api/tasks?hour=16'); - - // Assert - $this->assertCount(2, $response->json()); - } - - /** - * @test - */ - public function it_can_filter_tasks_by_queue() - { - // Arrange - factory(StackkitCloudTask::class)->create(['queue' => 'barbequeue']); - factory(StackkitCloudTask::class)->create(['queue' => 'barbequeue-priority']); - factory(StackkitCloudTask::class)->create(['queue' => 'barbequeue-priority']); - - // Act - $response = $this->getJson('/cloud-tasks-api/tasks?queue=barbequeue-priority'); - - // Assert - $this->assertCount(2, $response->json()); - } - - /** - * @test - */ - public function it_can_filter_tasks_by_status() - { - // Arrange - factory(StackkitCloudTask::class)->create(['status' => 'queued']); - factory(StackkitCloudTask::class)->create(['status' => 'pending']); - factory(StackkitCloudTask::class)->create(['status' => 'failed']); - factory(StackkitCloudTask::class)->create(['status' => 'failed']); - - // Act - $response = $this->getJson('/cloud-tasks-api/tasks?status=failed'); - - // Assert - $this->assertCount(2, $response->json()); - } - - /** - * @test - */ - public function it_shows_max_100_tasks() - { - // Arrange - factory(StackkitCloudTask::class)->times(101)->create(); - - // Act - $response = $this->getJson('/cloud-tasks-api/tasks'); - - // Assert - $this->assertCount(100, $response->json()); - } - - /** - * @test - */ - public function it_returns_the_correct_task_fields() - { - // Arrange - $task = factory(StackkitCloudTask::class)->create(); - - // Act - $response = $this->getJson('/cloud-tasks-api/tasks'); - - // Assert - $this->assertEquals($task->task_uuid, $response->json('0.uuid')); - $this->assertEquals($task->id, $response->json('0.id')); - $this->assertEquals('SimpleJob', $response->json('0.name')); - $this->assertEquals('queued', $response->json('0.status')); - $this->assertEquals(0, $response->json('0.attempts')); - $this->assertEquals('1 second ago', $response->json('0.created')); - $this->assertEquals('barbequeue', $response->json('0.queue')); - } - - /** - * @test - */ - public function it_returns_info_about_a_specific_task() - { - // Arrange - $task = factory(StackkitCloudTask::class)->create(); - - // Act - $response = $this->getJson('/cloud-tasks-api/task/' . $task->task_uuid); - - // Assert - $this->assertEquals($task->id, $response['id']); - $this->assertEquals('queued', $response['status']); - $this->assertEquals('barbequeue', $response['queue']); - $this->assertEquals([], $response['events']); - $this->assertEquals('[]', $response['payload']); - $this->assertEquals(null, $response['exception']); - } - - /** - * @test - * - * @testWith [{"task_type": "http"}] - * [{"task_type": "appengine"}] - */ - public function when_a_job_is_dispatched_it_will_be_added_to_the_dashboard(array $test) - { - // Arrange - $this->withTaskType($test['task_type']); - - CloudTasksApi::fake(); - $tasksBefore = StackkitCloudTask::count(); - $job = $this->dispatch(new SimpleJob()); - $tasksAfter = StackkitCloudTask::count(); - - // Assert - $task = StackkitCloudTask::first(); - $this->assertSame(0, $tasksBefore); - $this->assertSame(1, $tasksAfter); - $this->assertDatabaseHas((new StackkitCloudTask())->getTable(), [ - 'queue' => 'barbequeue', - 'status' => 'queued', - 'name' => SimpleJob::class, - ]); - $this->assertSame($task->getMetadata()['payload'], $job->payload); - } - - /** - * @test - */ - public function when_dashboard_is_disabled_jobs_will_not_be_added_to_the_dashboard() - { - // Arrange - CloudTasksApi::fake(); - config()->set('cloud-tasks.dashboard.enabled', false); - - // Act - $this->dispatch(new SimpleJob()); - - // Assert - $this->assertDatabaseCount((new StackkitCloudTask())->getTable(), 0); - } - - /** - * @test - * - * @testWith [{"task_type": "http"}] - * [{"task_type": "appengine"}] - */ - public function when_a_job_is_scheduled_it_will_be_added_as_such(array $test) - { - // Arrange - $this->withTaskType($test['task_type']); - - CloudTasksApi::fake(); - Carbon::setTestNow(now()); - $tasksBefore = StackkitCloudTask::count(); - - $job = $this->dispatch((new SimpleJob())->delay(now()->addSeconds(10))); - $tasksAfter = StackkitCloudTask::count(); - - // Assert - $task = StackkitCloudTask::first(); - $this->assertSame(0, $tasksBefore); - $this->assertSame(1, $tasksAfter); - $this->assertDatabaseHas((new StackkitCloudTask())->getTable(), [ - 'queue' => 'barbequeue', - 'status' => 'scheduled', - 'name' => SimpleJob::class, - ]); - $this->assertEquals(now()->addSeconds(10)->toDateTimeString(), $task->getEvents()[0]['scheduled_at']); - } - - /** - * @test - * - * @testWith [{"task_type": "http"}] - * [{"task_type": "appengine"}] - */ - public function when_a_job_is_running_it_will_be_updated_in_the_dashboard(array $test) - { - // Arrange - $this->withTaskType($test['task_type']); - - \Illuminate\Support\Carbon::setTestNow(now()); - CloudTasksApi::fake(); - OpenIdVerificator::fake(); - - $this->dispatch(new SimpleJob())->run(); - - // Assert - $task = StackkitCloudTask::firstOrFail(); - $events = $task->getEvents(); - $this->assertCount(3, $events); - $this->assertEquals( - [ - 'status' => 'running', - 'datetime' => now()->toDateTimeString(), - 'diff' => '1 second ago', - ], - $events[1] - ); - } - - /** - * @test - * - * @testWith [{"task_type": "http"}] - * [{"task_type": "appengine"}] - */ - public function when_a_job_is_successful_it_will_be_updated_in_the_dashboard(array $test) - { - // Arrange - $this->withTaskType($test['task_type']); - - \Illuminate\Support\Carbon::setTestNow(now()); - CloudTasksApi::fake(); - OpenIdVerificator::fake(); - - $this->dispatch(new SimpleJob())->run(); - - // Assert - $task = StackkitCloudTask::firstOrFail(); - $events = $task->getEvents(); - $this->assertCount(3, $events); - $this->assertEquals( - [ - 'status' => 'successful', - 'datetime' => now()->toDateTimeString(), - 'diff' => '1 second ago', - ], - $events[2] - ); - } - - /** - * @test - * - * @testWith [{"task_type": "http"}] - * [{"task_type": "appengine"}] - */ - public function when_a_job_errors_it_will_be_updated_in_the_dashboard(array $test) - { - // Arrange - $this->withTaskType($test['task_type']); - - \Illuminate\Support\Carbon::setTestNow(now()); - CloudTasksApi::fake(); - OpenIdVerificator::fake(); - - $this->dispatch(new FailingJob())->run(); - - // Assert - $task = StackkitCloudTask::firstOrFail(); - $events = $task->getEvents(); - $this->assertCount(3, $events); - $this->assertEquals( - [ - 'status' => 'error', - 'datetime' => now()->toDateTimeString(), - 'diff' => '1 second ago', - ], - $events[2] - ); - $this->assertStringContainsString('Error: simulating a failing job', $task->getMetadata()['exception']); - } - - /** - * @test - * - * @testWith [{"task_type": "http"}] - * [{"task_type": "appengine"}] - */ - public function when_a_job_fails_it_will_be_updated_in_the_dashboard(array $test) - { - // Arrange - $this->withTaskType($test['task_type']); - - \Illuminate\Support\Carbon::setTestNow(now()); - CloudTasksApi::fake(); - OpenIdVerificator::fake(); - CloudTasksApi::partialMock()->shouldReceive('getRetryConfig')->andReturn( - (new RetryConfig())->setMaxAttempts(3) - ); - - $job = $this->dispatch(new FailingJob()); - $releasedJob = $job->runAndGetReleasedJob(); - $releasedJob = $releasedJob->runAndGetReleasedJob(); - $releasedJob->run(); - - // Assert - $task = StackkitCloudTask::firstOrFail(); - $events = $task->getEvents(); - $this->assertCount(7, $events); - $this->assertEquals( - [ - 'status' => 'failed', - 'datetime' => now()->toDateTimeString(), - 'diff' => '1 second ago', - ], - $events[6] - ); - } - - /** - * @test - * - * @testWith [{"task_type": "http"}] - * [{"task_type": "appengine"}] - */ - public function when_a_job_is_released_it_will_be_updated_in_the_dashboard(array $test) - { - // Arrange - $this->withTaskType($test['task_type']); - - \Illuminate\Support\Carbon::setTestNow(now()); - CloudTasksApi::fake(); - OpenIdVerificator::fake(); - CloudTasksApi::partialMock()->shouldReceive('getRetryConfig')->andReturn( - (new RetryConfig())->setMaxAttempts(3) - ); - - $this->dispatch(new JobThatWillBeReleased())->run(); - - // Assert - $task = StackkitCloudTask::firstOrFail(); - $events = $task->getEvents(); - - $this->assertCount(3, $events); - $this->assertEquals( - [ - 'status' => 'released', - 'datetime' => now()->toDateTimeString(), - 'diff' => '1 second ago', - 'delay' => 0, - ], - $events[2] - ); - } - - /** - * @test - * - * @testWith [{"task_type": "http"}] - * [{"task_type": "appengine"}] - */ - public function job_release_delay_is_added_to_the_metadata(array $test) - { - // Arrange - $this->withTaskType($test['task_type']); - - \Illuminate\Support\Carbon::setTestNow(now()); - CloudTasksApi::fake(); - OpenIdVerificator::fake(); - CloudTasksApi::partialMock()->shouldReceive('getRetryConfig')->andReturn( - (new RetryConfig())->setMaxAttempts(3) - ); - - $this->dispatch(new JobThatWillBeReleased(15))->run(); - - // Assert - $task = StackkitCloudTask::firstOrFail(); - $events = $task->getEvents(); - - $this->assertCount(3, $events); - $this->assertEquals( - [ - 'status' => 'released', - 'datetime' => now()->toDateTimeString(), - 'diff' => '1 second ago', - 'delay' => 15, - ], - $events[2] - ); - } - - /** - * @test - */ - public function test_publish() - { - // Arrange - config()->set('cloud-tasks.dashboard.enabled', true); - - // Act & Assert - $expectedPublishBase = dirname(__DIR__); - - if (version_compare(app()->version(), '9.0.0', '>=')) { - $this->artisan('vendor:publish --tag=cloud-tasks --force') - ->expectsOutputToContain('Publishing [cloud-tasks] assets.') - ->expectsOutputToContain('Copying file [' . $expectedPublishBase . '/config/cloud-tasks.php] to [config/cloud-tasks.php]') - ->expectsOutputToContain('Copying directory [' . $expectedPublishBase . '/dashboard/dist] to [public/vendor/cloud-tasks]'); - } else { - $this->artisan('vendor:publish --tag=cloud-tasks --force') - ->expectsOutput('Copied File [' . $expectedPublishBase . '/config/cloud-tasks.php] To [/config/cloud-tasks.php]') - ->expectsOutput('Copied Directory [' . $expectedPublishBase . '/dashboard/dist] To [/public/vendor/cloud-tasks]') - ->expectsOutput('Publishing complete.'); - } - } - - /** - * @test - */ - public function when_dashboard_is_enabled_it_adds_the_necessary_routes() - { - // Act - $routes = app(Router::class)->getRoutes(); - - // Assert - $this->assertInstanceOf(Route::class, $routes->getByName('cloud-tasks.handle-task')); - $this->assertInstanceOf(Route::class, $routes->getByName('cloud-tasks.index')); - $this->assertInstanceOf(Route::class, $routes->getByName('cloud-tasks.api.dashboard')); - $this->assertInstanceOf(Route::class, $routes->getByName('cloud-tasks.api.tasks')); - $this->assertInstanceOf(Route::class, $routes->getByName('cloud-tasks.api.task')); - } - - /** - * @test - */ - public function when_dashboard_is_enabled_it_adds_the_necessary_migrations() - { - $this->assertTrue(in_array(dirname(__DIR__) . '/src/../migrations', app('migrator')->paths())); - } - - /** - * @test - */ - public function when_dashboard_is_disabled_it_adds_the_necessary_migrations() - { - $this->assertEmpty(app('migrator')->paths()); - } - - /** - * @test - */ - public function when_dashboard_is_disabled_it_does_not_add_the_dashboard_routes() - { - // Act - $routes = app(Router::class)->getRoutes(); - - // Assert - $this->assertInstanceOf(Route::class, $routes->getByName('cloud-tasks.handle-task')); - $this->assertNull($routes->getByName('cloud-tasks.index')); - $this->assertNull($routes->getByName('cloud-tasks.api.dashboard')); - $this->assertNull($routes->getByName('cloud-tasks.api.tasks')); - $this->assertNull($routes->getByName('cloud-tasks.api.task')); - } - - /** - * @test - */ - public function dashboard_is_password_protected() - { - // Arrange - $this->defaultHeaders['Authorization'] = ''; - - // Act - $response = $this->getJson('/cloud-tasks-api/dashboard'); - - // Assert - $this->assertEquals(403, $response->status()); - } - - /** - * @test - */ - public function can_enter_with_token() - { - // Arrange - $this->defaultHeaders['Authorization'] = 'Bearer ' . encrypt(time() + 10); - - // Act - $response = $this->getJson('/cloud-tasks-api/dashboard'); - - // Assert - $this->assertEquals(200, $response->status()); - } - - /** - * @test - */ - public function token_can_expire() - { - // Arrange - $this->defaultHeaders['Authorization'] = 'Bearer ' . encrypt(Carbon::create(2020, 5, 15, 15, 15, 15)->timestamp); - - // Act & Assert - Carbon::setTestNow(Carbon::create(2020, 5, 15, 15, 15, 14)); - $this->assertEquals(200, $this->getJson('/cloud-tasks-api/dashboard')->status()); - Carbon::setTestNow(Carbon::create(2020, 5, 15, 15, 15, 15)); - $this->assertEquals(403, $this->getJson('/cloud-tasks-api/dashboard')->status()); - } - - /** - * @test - */ - public function there_is_a_login_endpoint() - { - // Arrange - Carbon::setTestNow($now = now()); - config()->set('cloud-tasks.dashboard.password', 'test123'); - - // Act - $invalidPassword = $this->postJson('/cloud-tasks-api/login', ['password' => 'hey']); - $validPassword = $this->postJson('/cloud-tasks-api/login', ['password' => 'test123']); - - // Assert - $this->assertSame('', $invalidPassword->content()); - $this->assertStringStartsWith('ey', $validPassword->content()); - $validUntil = decrypt($validPassword->content()); - - // the token should be valid for 15 minutes. - $this->assertSame($now->timestamp + 900, $validUntil); - } -} diff --git a/tests/ConfigHandlerTest.php b/tests/ConfigHandlerTest.php index 6c30e3c..437473c 100644 --- a/tests/ConfigHandlerTest.php +++ b/tests/ConfigHandlerTest.php @@ -1,17 +1,59 @@ setConfigValue('handler', $handler); + + $this->dispatch(new SimpleJob()); + + CloudTasksApi::assertTaskCreated(function (Task $task) use ($expectedHandler) { + return $task->getHttpRequest()->getUrl() === $expectedHandler; + }); + } + + #[Test] + public function the_handle_route_task_uri_can_be_configured(): void + { + CloudTasksApi::fake(); + + $this->app['config']->set('cloud-tasks.uri', 'my-custom-route'); + + $this->dispatch(new SimpleJob()); + + CloudTasksApi::assertTaskCreated(function (Task $task) { + return $task->getHttpRequest()->getUrl() === 'https://docker.for.mac.localhost:8080/my-custom-route'; + }); + } + + #[Test] + public function the_handle_route_task_uri_in_combination_with_path_can_be_configured(): void + { + CloudTasksApi::fake(); + + $this->setConfigValue('handler', 'https://example.com/api'); + $this->app['config']->set('cloud-tasks.uri', 'my-custom-route'); + + $this->dispatch(new SimpleJob()); + + CloudTasksApi::assertTaskCreated(function (Task $task) { + return $task->getHttpRequest()->getUrl() === 'https://example.com/api/my-custom-route'; + }); } public static function handlerDataProvider(): array diff --git a/tests/IncomingTaskTest.php b/tests/IncomingTaskTest.php new file mode 100644 index 0000000..16b49b8 --- /dev/null +++ b/tests/IncomingTaskTest.php @@ -0,0 +1,136 @@ +withTaskType($taskType); + Str::createUlidsUsingSequence(['01HSR4V9QE2F4T0K8RBAYQ88KE']); + + // Act + $this->dispatch(new $job)->run(); + + // Assert + Event::assertDispatched(function (TaskIncoming $event) use ($job) { + return $event->task->fullyQualifiedTaskName() === 'projects/my-test-project/locations/europe-west6/queues/barbequeue/tasks/01HSR4V9QE2F4T0K8RBAYQ88KE-'.class_basename($job) + && $event->task->connection() === 'my-cloudtasks-connection' + && $event->task->queue() === 'barbequeue'; + }); + } + + #[Test] + #[TestWith([SimpleJob::class, 'cloudtasks'])] + #[TestWith([SimpleJob::class, 'appengine'])] + #[TestWith([EncryptedJob::class, 'cloudtasks'])] + #[TestWith([EncryptedJob::class, 'appengine'])] + public function it_reads_the_custom_queue(string $job, string $taskType) + { + // Arrange + $this->withTaskType($taskType); + + // Act + $this->dispatch((new $job)->onQueue('other-queue'))->run(); + + // Assert + Event::assertDispatched(function (TaskIncoming $event) { + return $event->task->queue() === 'other-queue'; + }); + } + + #[Test] + #[TestWith([SimpleJob::class, 'cloudtasks'])] + #[TestWith([SimpleJob::class, 'appengine'])] + #[TestWith([EncryptedJob::class, 'cloudtasks'])] + #[TestWith([EncryptedJob::class, 'appengine'])] + public function it_reads_the_custom_connection(string $job, string $taskType) + { + // Arrange + $this->withTaskType($taskType); + + // Act + $this->dispatch((new $job)->onConnection('my-other-cloudtasks-connection'))->run(); + + // Assert + Event::assertDispatched(function (TaskIncoming $event) { + return $event->task->connection() === 'my-other-cloudtasks-connection' + && $event->task->queue() === 'other-barbequeue'; + }); + } + + #[Test] + #[TestWith([SimpleJob::class, 'cloudtasks'])] + #[TestWith([SimpleJob::class, 'appengine'])] + #[TestWith([EncryptedJob::class, 'cloudtasks'])] + #[TestWith([EncryptedJob::class, 'appengine'])] + public function it_reads_the_custom_connection_with_custom_queue(string $job, string $taskType) + { + // Arrange + $this->withTaskType($taskType); + + // Act + $this->dispatch( + (new $job) + ->onConnection('my-other-cloudtasks-connection') + ->onQueue('custom-barbequeue') + )->run(); + + // Assert + Event::assertDispatched(function (TaskIncoming $event) { + return $event->task->connection() === 'my-other-cloudtasks-connection' + && $event->task->queue() === 'custom-barbequeue'; + }); + } + + #[Test] + public function it_can_convert_the_incoming_task_to_array() + { + // Act + $incomingTask = IncomingTask::fromJson('{"internal":{"connection":"my-other-cloudtasks-connection","queue":"custom-barbequeue","taskName":"projects/my-test-project/locations/europe-west6/queues/barbequeue/tasks/01HSR4V9QE2F4T0K8RBAYQ88KE-SimpleJob"}}'); + + // Act + $array = $incomingTask->toArray(); + + // Assert + $this->assertIsArray($array); + $this->assertSame('my-other-cloudtasks-connection', $array['internal']['connection']); + } + + #[Test] + public function test_invalid_function() + { + // Act + $incomingTask = IncomingTask::fromJson('{ invalid json }'); + + // Act + $this->assertTrue($incomingTask->isInvalid()); + } +} diff --git a/tests/QueueAppEngineTest.php b/tests/QueueAppEngineTest.php index 994444f..1f2e8e4 100644 --- a/tests/QueueAppEngineTest.php +++ b/tests/QueueAppEngineTest.php @@ -5,6 +5,7 @@ namespace Tests; use Google\Cloud\Tasks\V2\Task; +use PHPUnit\Framework\Attributes\Test; use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksApi; use Tests\Support\SimpleJob; @@ -17,9 +18,7 @@ protected function setUp(): void $this->withTaskType('appengine'); } - /** - * @test - */ + #[Test] public function an_app_engine_http_request_with_the_handler_url_is_made() { // Arrange @@ -34,9 +33,7 @@ public function an_app_engine_http_request_with_the_handler_url_is_made() }); } - /** - * @test - */ + #[Test] public function it_routes_to_the_service() { // Arrange @@ -51,9 +48,7 @@ public function it_routes_to_the_service() }); } - /** - * @test - */ + #[Test] public function it_contains_the_payload() { // Arrange diff --git a/tests/QueueTest.php b/tests/QueueTest.php index 5338afe..c24c8f3 100644 --- a/tests/QueueTest.php +++ b/tests/QueueTest.php @@ -5,23 +5,25 @@ namespace Tests; use Google\Cloud\Tasks\V2\HttpMethod; -use Google\Cloud\Tasks\V2\RetryConfig; use Google\Cloud\Tasks\V2\Task; use Illuminate\Queue\Events\JobProcessed; use Illuminate\Queue\Events\JobProcessing; use Illuminate\Queue\Events\JobQueued; +use Illuminate\Queue\Events\JobReleasedAfterException; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Str; +use Override; +use PHPUnit\Framework\Attributes\Test; use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksApi; +use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksQueue; use Stackkit\LaravelGoogleCloudTasksQueue\Events\JobReleased; -use Stackkit\LaravelGoogleCloudTasksQueue\LogFake; -use Stackkit\LaravelGoogleCloudTasksQueue\OpenIdVerificator; -use Stackkit\LaravelGoogleCloudTasksQueue\TaskHandler; +use Stackkit\LaravelGoogleCloudTasksQueue\IncomingTask; use Tests\Support\FailingJob; use Tests\Support\FailingJobWithExponentialBackoff; +use Tests\Support\JobOutput; use Tests\Support\JobThatWillBeReleased; use Tests\Support\SimpleJob; use Tests\Support\User; @@ -29,9 +31,16 @@ class QueueTest extends TestCase { - /** - * @test - */ + #[Override] + protected function tearDown(): void + { + parent::tearDown(); + + CloudTasksQueue::forgetHandlerUrlCallback(); + CloudTasksQueue::forgetTaskHeadersCallback(); + } + + #[Test] public function a_http_request_with_the_handler_url_is_made() { // Arrange @@ -46,9 +55,7 @@ public function a_http_request_with_the_handler_url_is_made() }); } - /** - * @test - */ + #[Test] public function it_posts_to_the_handler() { // Arrange @@ -63,10 +70,8 @@ public function it_posts_to_the_handler() }); } - /** - * @test - */ - public function it_posts_to_the_correct_handler_url() + #[Test] + public function it_posts_to_the_configured_handler_url() { // Arrange $this->setConfigValue('handler', 'https://docker.for.mac.localhost:8081'); @@ -81,9 +86,26 @@ public function it_posts_to_the_correct_handler_url() }); } - /** - * @test - */ + #[Test] + public function it_posts_to_the_callback_handler_url() + { + // Arrange + $this->setConfigValue('handler', 'https://docker.for.mac.localhost:8081'); + CloudTasksApi::fake(); + CloudTasksQueue::configureHandlerUrlUsing(static fn (SimpleJob $job) => 'https://example.com/api/my-custom-route?job='.$job->id); + + // Act + $job = new SimpleJob(); + $job->id = 1; + $this->dispatch($job); + + // Assert + CloudTasksApi::assertTaskCreated(function (Task $task): bool { + return $task->getHttpRequest()->getUrl() === 'https://example.com/api/my-custom-route?job=1'; + }); + } + + #[Test] public function it_posts_the_serialized_job_payload_to_the_handler() { // Arrange @@ -102,9 +124,7 @@ public function it_posts_the_serialized_job_payload_to_the_handler() }); } - /** - * @test - */ + #[Test] public function it_will_set_the_scheduled_time_when_dispatching_later() { // Arrange @@ -120,28 +140,7 @@ public function it_will_set_the_scheduled_time_when_dispatching_later() }); } - /** - * @test - */ - public function test_dispatch_deadline_config() - { - // Arrange - CloudTasksApi::fake(); - $this->setConfigValue('dispatch_deadline', 30); - - // Act - $this->dispatch(new SimpleJob()); - - // Assert - CloudTasksApi::assertTaskCreated(function (Task $task) { - return $task->hasDispatchDeadline() - && $task->getDispatchDeadline()->getSeconds() === 30; - }); - } - - /** - * @test - */ + #[Test] public function it_posts_the_task_the_correct_queue() { // Arrange @@ -154,7 +153,7 @@ public function it_posts_the_task_the_correct_queue() // Assert CloudTasksApi::assertTaskCreated(function (Task $task, string $queueName): bool { $decoded = json_decode($task->getHttpRequest()->getBody(), true); - $command = TaskHandler::getCommandProperties($decoded['data']['command']); + $command = IncomingTask::fromJson($task->getHttpRequest()->getBody())->command(); return $decoded['displayName'] === SimpleJob::class && ($command['queue'] ?? null) === null @@ -163,7 +162,7 @@ public function it_posts_the_task_the_correct_queue() CloudTasksApi::assertTaskCreated(function (Task $task, string $queueName): bool { $decoded = json_decode($task->getHttpRequest()->getBody(), true); - $command = TaskHandler::getCommandProperties($decoded['data']['command']); + $command = IncomingTask::fromJson($task->getHttpRequest()->getBody())->command(); return $decoded['displayName'] === FailingJob::class && $command['queue'] === 'my-special-queue' @@ -171,15 +170,9 @@ public function it_posts_the_task_the_correct_queue() }); } - /** - * @test - */ + #[Test] public function it_can_dispatch_after_commit_inline() { - if (version_compare(app()->version(), '8.0.0', '<')) { - $this->markTestSkipped('Not supported by Laravel 7.x and below.'); - } - // Arrange CloudTasksApi::fake(); Event::fake(); @@ -197,15 +190,9 @@ public function it_can_dispatch_after_commit_inline() }); } - /** - * @test - */ + #[Test] public function it_can_dispatch_after_commit_through_config() { - if (version_compare(app()->version(), '8.0.0', '<')) { - $this->markTestSkipped('Not supported by Laravel 7.x and below.'); - } - // Arrange CloudTasksApi::fake(); Event::fake(); @@ -224,58 +211,46 @@ public function it_can_dispatch_after_commit_through_config() }); } - /** - * @test - */ + #[Test] public function jobs_can_be_released() { // Arrange CloudTasksApi::fake(); - OpenIdVerificator::fake(); Event::fake([ - $this->getJobReleasedAfterExceptionEvent(), + JobReleasedAfterException::class, JobReleased::class, ]); // Act - $this->dispatch(new JobThatWillBeReleased())->run(); + $this->dispatch(new JobThatWillBeReleased()) + ->runAndGetReleasedJob() + ->run(); // Assert - Event::assertNotDispatched($this->getJobReleasedAfterExceptionEvent()); - CloudTasksApi::assertDeletedTaskCount(0); // it returned 200 OK so we dont delete it, but Google does - $releasedJob = null; - Event::assertDispatched(JobReleased::class, function (JobReleased $event) use (&$releasedJob) { - $releasedJob = $event->job; - return true; - }); CloudTasksApi::assertTaskCreated(function (Task $task) { $body = $task->getHttpRequest()->getBody(); $decoded = json_decode($body, true); + return $decoded['data']['commandName'] === 'Tests\\Support\\JobThatWillBeReleased' && $decoded['internal']['attempts'] === 1; }); - $this->runFromPayload($releasedJob->getRawBody()); - - CloudTasksApi::assertDeletedTaskCount(0); CloudTasksApi::assertTaskCreated(function (Task $task) { $body = $task->getHttpRequest()->getBody(); $decoded = json_decode($body, true); + return $decoded['data']['commandName'] === 'Tests\\Support\\JobThatWillBeReleased' && $decoded['internal']['attempts'] === 2; }); } - /** - * @test - */ + #[Test] public function jobs_can_be_released_with_a_delay() { // Arrange CloudTasksApi::fake(); - OpenIdVerificator::fake(); Event::fake([ - $this->getJobReleasedAfterExceptionEvent(), + JobReleasedAfterException::class, JobReleased::class, ]); Carbon::setTestNow(now()->addDay()); @@ -296,13 +271,12 @@ public function jobs_can_be_released_with_a_delay() }); } - /** @test */ + #[Test] public function test_default_backoff() { // Arrange CloudTasksApi::fake(); - OpenIdVerificator::fake(); - Event::fake($this->getJobReleasedAfterExceptionEvent()); + Event::fake(JobReleasedAfterException::class); // Act $this->dispatch(new FailingJob())->run(); @@ -313,15 +287,14 @@ public function test_default_backoff() }); } - /** @test */ + #[Test] public function test_backoff_from_queue_config() { // Arrange Carbon::setTestNow(now()->addDay()); $this->setConfigValue('backoff', 123); CloudTasksApi::fake(); - OpenIdVerificator::fake(); - Event::fake($this->getJobReleasedAfterExceptionEvent()); + Event::fake(JobReleasedAfterException::class); // Act $this->dispatch(new FailingJob())->run(); @@ -333,19 +306,17 @@ public function test_backoff_from_queue_config() }); } - /** @test */ + #[Test] public function test_backoff_from_job() { // Arrange Carbon::setTestNow(now()->addDay()); CloudTasksApi::fake(); - OpenIdVerificator::fake(); - Event::fake($this->getJobReleasedAfterExceptionEvent()); + Event::fake(JobReleasedAfterException::class); // Act $failingJob = new FailingJob(); - $prop = version_compare(app()->version(), '8.0.0', '<') ? 'delay' : 'backoff'; - $failingJob->$prop = 123; + $failingJob->backoff = 123; $this->dispatch($failingJob)->run(); // Assert @@ -355,17 +326,12 @@ public function test_backoff_from_job() }); } - /** @test */ + #[Test] public function test_exponential_backoff_from_job_method() { - if (version_compare(app()->version(), '8.0.0', '<')) { - $this->markTestSkipped('Not supported by Laravel 7.x and below.'); - } - // Arrange Carbon::setTestNow(now()->addDay()); CloudTasksApi::fake(); - OpenIdVerificator::fake(); // Act $releasedJob = $this->dispatch(new FailingJobWithExponentialBackoff()) @@ -388,72 +354,68 @@ public function test_exponential_backoff_from_job_method() }); } - /** @test */ + #[Test] public function test_failing_method_on_job() { // Arrange CloudTasksApi::fake(); - CloudTasksApi::partialMock()->shouldReceive('getRetryConfig')->andReturn( - (new RetryConfig())->setMaxAttempts(1) - ); - - OpenIdVerificator::fake(); - Log::swap(new LogFake()); + Event::fake(JobOutput::class); // Act - $this->dispatch(new FailingJob())->run(); + $this->dispatch(new FailingJob()) + ->runAndGetReleasedJob() + ->runAndGetReleasedJob() + ->runAndGetReleasedJob(); // Assert - Log::assertLogged('FailingJob:failed'); + Event::assertDispatched(fn (JobOutput $event) => $event->output === 'FailingJob:failed'); } - /** @test */ + #[Test] public function test_queue_before_and_after_hooks() { // Arrange CloudTasksApi::fake(); - OpenIdVerificator::fake(); - Log::swap(new LogFake()); + Event::fake(JobOutput::class); // Act Queue::before(function (JobProcessing $event) { - logger('Queue::before:' . $event->job->payload()['data']['commandName']); + event(new JobOutput('Queue::before:'.$event->job->payload()['data']['commandName'])); }); Queue::after(function (JobProcessed $event) { - logger('Queue::after:' . $event->job->payload()['data']['commandName']); + event(new JobOutput('Queue::after:'.$event->job->payload()['data']['commandName'])); }); $this->dispatch(new SimpleJob())->run(); // Assert - Log::assertLogged('Queue::before:Tests\Support\SimpleJob'); - Log::assertLogged('Queue::after:Tests\Support\SimpleJob'); + Event::assertDispatched(fn (JobOutput $event) => $event->output === 'Queue::before:Tests\Support\SimpleJob'); + Event::assertDispatched(fn (JobOutput $event) => $event->output === 'Queue::after:Tests\Support\SimpleJob'); } - /** @test */ + #[Test] public function test_queue_looping_hook_not_supported_with_this_package() { // Arrange CloudTasksApi::fake(); - OpenIdVerificator::fake(); - Log::swap(new LogFake()); + Event::fake(JobOutput::class); // Act Queue::looping(function () { - logger('Queue::looping'); + event(new JobOutput('Queue::looping')); }); $this->dispatch(new SimpleJob())->run(); // Assert - Log::assertNotLogged('Queue::looping'); + Event::assertDispatchedTimes(JobOutput::class, times: 1); + Event::assertDispatched(fn (JobOutput $event) => $event->output === 'SimpleJob:success'); } - /** @test */ + #[Test] public function test_ignoring_jobs_with_deleted_models() { // Arrange CloudTasksApi::fake(); - OpenIdVerificator::fake(); - Log::swap(new LogFake()); + Event::fake(JobOutput::class); $user1 = User::create([ 'name' => 'John', @@ -468,34 +430,68 @@ public function test_ignoring_jobs_with_deleted_models() ]); // Act - $this->dispatch(new UserJob($user1))->runWithoutExceptionHandler(); + $this->dispatch(new UserJob($user1))->run(); $job = $this->dispatch(new UserJob($user2)); $user2->delete(); - $job->runWithoutExceptionHandler(); + $job->run(); // Act - Log::assertLogged('UserJob:John'); + Event::assertDispatched(fn (JobOutput $event) => $event->output === 'UserJob:John'); CloudTasksApi::assertTaskNotDeleted($job->task->getName()); } - /** - * @test - */ - public function it_adds_a_task_name_based_on_the_display_name() + #[Test] + public function it_adds_a_pre_defined_task_name() { // Arrange CloudTasksApi::fake(); - Carbon::setTestNow(Carbon::create(2023, 6, 1, 20, 2, 37)); + Str::createUlidsUsingSequence(['01HSR4V9QE2F4T0K8RBAYQ88KE']); // Act $this->dispatch((new SimpleJob())); // Assert - CloudTasksApi::assertTaskCreated(function (Task $task, string $queueName): bool { - $uuid = \Safe\json_decode($task->getHttpRequest()->getBody(), true)['uuid']; + CloudTasksApi::assertTaskCreated(function (Task $task): bool { + return $task->getName() === 'projects/my-test-project/locations/europe-west6/queues/barbequeue/tasks/01HSR4V9QE2F4T0K8RBAYQ88KE-SimpleJob'; + }); + } + + #[Test] + public function headers_can_be_added_to_the_task() + { + // Arrange + CloudTasksApi::fake(); - return $task->getName() === 'projects/my-test-project/locations/europe-west6/queues/barbequeue/tasks/Tests-Support-SimpleJob-' . $uuid . '-1685649757000'; + // Act + CloudTasksQueue::setTaskHeadersUsing(static fn () => [ + 'X-MyHeader' => 'MyValue', + ]); + + $this->dispatch((new SimpleJob())); + + // Assert + CloudTasksApi::assertTaskCreated(function (Task $task): bool { + return $task->getHttpRequest()->getHeaders()['X-MyHeader'] === 'MyValue'; + }); + } + + #[Test] + public function headers_can_be_added_to_the_task_with_job_context() + { + // Arrange + CloudTasksApi::fake(); + + // Act + CloudTasksQueue::setTaskHeadersUsing(static fn (array $payload) => [ + 'X-MyHeader' => $payload['displayName'], + ]); + + $this->dispatch((new SimpleJob())); + + // Assert + CloudTasksApi::assertTaskCreated(function (Task $task): bool { + return $task->getHttpRequest()->getHeaders()['X-MyHeader'] === SimpleJob::class; }); } } diff --git a/tests/Support/BaseJob.php b/tests/Support/BaseJob.php new file mode 100644 index 0000000..4f9cc94 --- /dev/null +++ b/tests/Support/BaseJob.php @@ -0,0 +1,16 @@ +payload = $payload; + $this->task = $task; + $this->testCase = $testCase; + } + + public function run(): void + { + $header = match (true) { + $this->task->hasHttpRequest() => 'HTTP_X_CLOUDTASKS_TASKNAME', + $this->task->hasAppEngineHttpRequest() => 'HTTP_X_APPENGINE_TASKNAME', + default => throw new Error('Task does not have a request.'), + }; + + $this->testCase->call( + method: 'POST', + uri: route('cloud-tasks.handle-task'), + server: [ + $header => (string) str($this->task->getName())->after('/tasks/'), + ], + content: $this->payload, + ); + } + + public function runAndGetReleasedJob(): self + { + $this->run(); + + $releasedTask = end($this->testCase->createdTasks); + + if (! $releasedTask) { + $this->testCase->fail('No task was released.'); + } + + $payload = $releasedTask->getAppEngineHttpRequest()?->getBody() + ?: $releasedTask->getHttpRequest()->getBody(); + + return new self( + $payload, + $releasedTask, + $this->testCase + ); + } +} diff --git a/tests/Support/EncryptedJob.php b/tests/Support/EncryptedJob.php index 8f8e4ff..2ba3868 100644 --- a/tests/Support/EncryptedJob.php +++ b/tests/Support/EncryptedJob.php @@ -1,20 +1,15 @@ addMinutes(5); + } +} diff --git a/tests/Support/FailingJobWithRetryUntil.php b/tests/Support/FailingJobWithRetryUntil.php new file mode 100644 index 0000000..46a1a8c --- /dev/null +++ b/tests/Support/FailingJobWithRetryUntil.php @@ -0,0 +1,15 @@ +addMinutes(5); + } +} diff --git a/tests/Support/FailingJobWithUnlimitedTries.php b/tests/Support/FailingJobWithUnlimitedTries.php new file mode 100644 index 0000000..7f92520 --- /dev/null +++ b/tests/Support/FailingJobWithUnlimitedTries.php @@ -0,0 +1,10 @@ +releaseDelay = $releaseDelay; + // } - /** - * Execute the job. - * - * @return void - */ public function handle() { - logger('JobThatWillBeReleased:beforeRelease'); $this->release($this->releaseDelay); - logger('JobThatWillBeReleased:afterRelease'); } } diff --git a/tests/Support/SimpleJob.php b/tests/Support/SimpleJob.php index 34e1912..4825355 100644 --- a/tests/Support/SimpleJob.php +++ b/tests/Support/SimpleJob.php @@ -1,5 +1,7 @@ user->name); + event(new JobOutput('UserJob:'.$this->user->name)); } } diff --git a/tests/Support/self-signed-private-key.txt b/tests/Support/self-signed-private-key.txt deleted file mode 100644 index e287c02..0000000 --- a/tests/Support/self-signed-private-key.txt +++ /dev/null @@ -1,15 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIICXAIBAAKBgQC8kGa1pSjbSYZVebtTRBLxBz5H4i2p/llLCrEeQhta5kaQu/Rn -vuER4W8oDH3+3iuIYW4VQAzyqFpwuzjkDI+17t5t0tyazyZ8JXw+KgXTxldMPEL9 -5+qVhgXvwtihXC1c5oGbRlEDvDF6Sa53rcFVsYJ4ehde/zUxo6UvS7UrBQIDAQAB -AoGAb/MXV46XxCFRxNuB8LyAtmLDgi/xRnTAlMHjSACddwkyKem8//8eZtw9fzxz -bWZ/1/doQOuHBGYZU8aDzzj59FZ78dyzNFoF91hbvZKkg+6wGyd/LrGVEB+Xre0J -Nil0GReM2AHDNZUYRv+HYJPIOrB0CRczLQsgFJ8K6aAD6F0CQQDzbpjYdx10qgK1 -cP59UHiHjPZYC0loEsk7s+hUmT3QHerAQJMZWC11Qrn2N+ybwwNblDKv+s5qgMQ5 -5tNoQ9IfAkEAxkyffU6ythpg/H0Ixe1I2rd0GbF05biIzO/i77Det3n4YsJVlDck -ZkcvY3SK2iRIL4c9yY6hlIhs+K9wXTtGWwJBAO9Dskl48mO7woPR9uD22jDpNSwe -k90OMepTjzSvlhjbfuPN1IdhqvSJTDychRwn1kIJ7LQZgQ8fVz9OCFZ/6qMCQGOb -qaGwHmUK6xzpUbbacnYrIM6nLSkXgOAwv7XXCojvY614ILTK3iXiLBOxPu5Eu13k -eUz9sHyD6vkgZzjtxXECQAkp4Xerf5TGfQXGXhxIX52yH+N2LtujCdkQZjXAsGdm -B2zNzvrlgRmgBrklMTrMYgm1NPcW+bRLGcwgW2PTvNM= ------END RSA PRIVATE KEY----- diff --git a/tests/Support/self-signed-public-key-as-jwk.json b/tests/Support/self-signed-public-key-as-jwk.json deleted file mode 100644 index 0937c5d..0000000 --- a/tests/Support/self-signed-public-key-as-jwk.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "keys": [ - { - "kty": "RSA", - "n": "vJBmtaUo20mGVXm7U0QS8Qc-R-Itqf5ZSwqxHkIbWuZGkLv0Z77hEeFvKAx9_t4riGFuFUAM8qhacLs45AyPte7ebdLcms8mfCV8PioF08ZXTDxC_efqlYYF78LYoVwtXOaBm0ZRA7wxekmud63BVbGCeHoXXv81MaOlL0u1KwU", - "e": "AQAB", - "alg": "RS256", - "kid": "abc123", - "use": "sig" - } - ] -} diff --git a/tests/Support/self-signed-public-key.txt b/tests/Support/self-signed-public-key.txt deleted file mode 100644 index 5996602..0000000 --- a/tests/Support/self-signed-public-key.txt +++ /dev/null @@ -1,6 +0,0 @@ ------BEGIN PUBLIC KEY----- -MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC8kGa1pSjbSYZVebtTRBLxBz5H -4i2p/llLCrEeQhta5kaQu/RnvuER4W8oDH3+3iuIYW4VQAzyqFpwuzjkDI+17t5t -0tyazyZ8JXw+KgXTxldMPEL95+qVhgXvwtihXC1c5oGbRlEDvDF6Sa53rcFVsYJ4 -ehde/zUxo6UvS7UrBQIDAQAB ------END PUBLIC KEY----- diff --git a/tests/TaskHandlerTest.php b/tests/TaskHandlerTest.php index 089ba4e..1c2359c 100644 --- a/tests/TaskHandlerTest.php +++ b/tests/TaskHandlerTest.php @@ -1,26 +1,23 @@ set('app.debug', $debug); - - // Act - $response = $this->postJson(action([TaskHandler::class, 'handle'])); - - // Assert - if ($debug) { - $response->assertJsonValidationErrors('task'); - } else { - $response->assertNotFound(); - } - } - - /** - * @test - * @testWith [true] - * [false] - */ - public function it_returns_responses_for_invalid_json($debug) - { - // Arrange - config()->set('app.debug', $debug); - - // Act - $response = $this->call( - 'POST', - action([TaskHandler::class, 'handle']), - [], - [], - [], - [ - 'HTTP_ACCEPT' => 'application/json', - ], - 'test', - ); - - // Assert - if ($debug) { - $response->assertJsonValidationErrors('task'); - } else { - $response->assertNotFound(); - } - } - - /** - * @test - * @testWith ["{\"invalid\": \"data\"}"] - * ["{\"data\": \"\"}"] - * ["{\"data\": \"test\"}"] - */ - public function it_returns_responses_for_invalid_payloads(string $payload) - { - // Arrange - - // Act - $response = $this->call( - 'POST', - action([TaskHandler::class, 'handle']), - [], - [], - [], - [ - 'HTTP_ACCEPT' => 'application/json', - ], - $payload, - ); - - // Assert - $response->assertJsonValidationErrors('task.data'); - } - - /** - * @test - */ - public function the_task_handler_needs_an_open_id_token() - { - // Assert - $this->expectException(CloudTasksException::class); - $this->expectExceptionMessage('Missing [Authorization] header'); - - // Act - $this->dispatch(new SimpleJob())->runWithoutExceptionHandler(); - } - - /** - * @test - */ - public function the_task_handler_throws_an_exception_if_the_id_token_is_invalid() - { - // Arrange - request()->headers->set('Authorization', 'Bearer my-invalid-token'); - - // Assert - $this->expectException(UnexpectedValueException::class); - $this->expectExceptionMessage('Wrong number of segments'); - - // Act - $this->dispatch(new SimpleJob())->runWithoutExceptionHandler(); - } - - /** - * @test - */ - public function it_validates_the_token_expiration() - { - // Arrange - OpenIdVerificator::fake(); - $this->addIdTokenToHeader(function (array $base) { - return ['exp' => time() - 5] + $base; - }); - - // Assert - $this->expectException(ExpiredException::class); - $this->expectExceptionMessage('Expired token'); - - // Act - $this->dispatch(new SimpleJob())->runWithoutExceptionHandler(); - } - - /** - * @test - */ - public function it_validates_the_token_aud() - { - // Arrange - OpenIdVerificator::fake(); - $this->addIdTokenToHeader(function (array $base) { - return ['aud' => 'invalid-aud'] + $base; - }); - - // Assert - $this->expectException(UnexpectedValueException::class); - $this->expectExceptionMessage('Audience does not match'); - - // Act - $this->dispatch(new SimpleJob())->runWithoutExceptionHandler(); - } - - /** - * @test - */ + #[Test] public function it_can_run_a_task() { // Arrange - OpenIdVerificator::fake(); - Log::swap(new LogFake()); - Event::fake([JobProcessing::class, JobProcessed::class]); + Event::fake(JobOutput::class); // Act - $this->dispatch(new SimpleJob())->runWithoutExceptionHandler(); + $this->dispatch(new SimpleJob())->run(); // Assert - Log::assertLogged('SimpleJob:success'); + Event::assertDispatched(fn (JobOutput $event) => $event->output === 'SimpleJob:success'); } - /** - * @test - */ + #[Test] public function it_can_run_a_task_using_the_task_connection() { // Arrange - OpenIdVerificator::fake(); - Log::swap(new LogFake()); - Event::fake([JobProcessing::class, JobProcessed::class]); + + Event::fake(JobOutput::class); $this->app['config']->set('queue.default', 'non-existing-connection'); // Act $job = new SimpleJob(); $job->connection = 'my-cloudtasks-connection'; - $this->dispatch($job)->runWithoutExceptionHandler(); + $this->dispatch($job)->run(); // Assert - Log::assertLogged('SimpleJob:success'); + Event::assertDispatched(fn (JobOutput $event) => $event->output === 'SimpleJob:success'); } - /** - * @test - */ + #[Test] public function after_max_attempts_it_will_log_to_failed_table() { // Arrange - OpenIdVerificator::fake(); - CloudTasksApi::partialMock()->shouldReceive('getRetryConfig')->andReturn( - (new RetryConfig())->setMaxAttempts(3) - ); - $job = $this->dispatch(new FailingJob()); + $job = $this->dispatch(new FailingJobWithMaxTries()); // Act & Assert $this->assertDatabaseCount('failed_jobs', 0); @@ -239,174 +77,116 @@ public function after_max_attempts_it_will_log_to_failed_table() $this->assertDatabaseCount('failed_jobs', 1); } - /** - * @test - */ - public function after_max_attempts_it_will_delete_the_task() + #[Test] + public function after_max_attempts_it_will_no_longer_execute_the_task() { // Arrange - OpenIdVerificator::fake(); - - CloudTasksApi::partialMock()->shouldReceive('getRetryConfig')->andReturn( - (new RetryConfig())->setMaxAttempts(3) - ); - + Event::fake([JobOutput::class]); $job = $this->dispatch(new FailingJob()); // Act & Assert $releasedJob = $job->runAndGetReleasedJob(); - CloudTasksApi::assertDeletedTaskCount(1); - CloudTasksApi::assertTaskDeleted($job->task->getName()); + Event::assertDispatched(JobOutput::class, 1); $this->assertDatabaseCount('failed_jobs', 0); $releasedJob = $releasedJob->runAndGetReleasedJob(); - CloudTasksApi::assertDeletedTaskCount(2); - CloudTasksApi::assertTaskDeleted($job->task->getName()); + Event::assertDispatched(JobOutput::class, 2); $this->assertDatabaseCount('failed_jobs', 0); $releasedJob->run(); - CloudTasksApi::assertDeletedTaskCount(3); - CloudTasksApi::assertTaskDeleted($job->task->getName()); + Event::assertDispatched(JobOutput::class, 4); $this->assertDatabaseCount('failed_jobs', 1); } - /** - * @test - */ - public function after_max_retry_until_it_will_log_to_failed_table_and_delete_the_task() + #[Test] + #[TestWith([['now' => '2020-01-01 00:00:00', 'try_at' => '2020-01-01 00:00:00', 'should_fail' => false]])] + #[TestWith([['now' => '2020-01-01 00:00:00', 'try_at' => '2020-01-01 00:04:59', 'should_fail' => false]])] + #[TestWith([['now' => '2020-01-01 00:00:00', 'try_at' => '2020-01-01 00:05:00', 'should_fail' => true]])] + public function after_max_retry_until_it_will_log_to_failed_table(array $args) { // Arrange - OpenIdVerificator::fake(); - CloudTasksApi::partialMock()->shouldReceive('getRetryConfig')->andReturn( - (new RetryConfig())->setMaxRetryDuration(new Duration(['seconds' => 30])) - ); - CloudTasksApi::partialMock()->shouldReceive('getRetryUntilTimestamp')->andReturn(1); - $job = $this->dispatch(new FailingJob()); + $this->travelTo($args['now']); + + $job = $this->dispatch(new FailingJobWithRetryUntil()); // Act $releasedJob = $job->runAndGetReleasedJob(); // Assert - CloudTasksApi::assertDeletedTaskCount(1); - CloudTasksApi::assertTaskDeleted($job->task->getName()); $this->assertDatabaseCount('failed_jobs', 0); // Act - CloudTasksApi::partialMock()->shouldReceive('getRetryUntilTimestamp')->andReturn(1); + $this->travelTo($args['try_at']); $releasedJob->run(); // Assert - CloudTasksApi::assertDeletedTaskCount(2); - CloudTasksApi::assertTaskDeleted($job->task->getName()); - $this->assertDatabaseCount('failed_jobs', 1); + $this->assertDatabaseCount('failed_jobs', $args['should_fail'] ? 1 : 0); } - /** - * @test - */ + #[Test] public function test_unlimited_max_attempts() { - // Arrange - OpenIdVerificator::fake(); - CloudTasksApi::partialMock()->shouldReceive('getRetryConfig')->andReturn( - // -1 is a valid option in Cloud Tasks to indicate there is no max. - (new RetryConfig())->setMaxAttempts(-1) - ); + // Assert + Event::fake(JobOutput::class); // Act - $job = $this->dispatch(new FailingJob()); - foreach (range(1, 50) as $attempt) { - $job->run(); - CloudTasksApi::assertDeletedTaskCount($attempt); - CloudTasksApi::assertTaskDeleted($job->task->getName()); - $this->assertDatabaseCount('failed_jobs', 0); + $job = $this->dispatch(new FailingJobWithUnlimitedTries()); + + foreach (range(0, 50) as $attempt) { + usleep(1000); + $job = $job->runAndGetReleasedJob(); } + + Event::assertDispatched(JobOutput::class, 51); } - /** - * @test - */ + #[Test] public function test_max_attempts_in_combination_with_retry_until() { - // Laravel 5, 6, 7: check both max_attempts and retry_until before failing a job. - // Laravel 8+: if retry_until, only check that - // Arrange - OpenIdVerificator::fake(); - CloudTasksApi::partialMock()->shouldReceive('getRetryConfig')->andReturn( - (new RetryConfig()) - ->setMaxAttempts(3) - ->setMaxRetryDuration(new Duration(['seconds' => 3])) - ); - CloudTasksApi::partialMock()->shouldReceive('getRetryUntilTimestamp')->andReturn(time() + 10)->byDefault(); + $this->travelTo('2020-01-01 00:00:00'); - $job = $this->dispatch(new FailingJob()); + $job = $this->dispatch(new FailingJobWithMaxTriesAndRetryUntil()); - // Act & Assert - $releasedJob = $job->runAndGetReleasedJob(); - $releasedJob = $releasedJob->runAndGetReleasedJob(); - - # After 2 attempts both Laravel versions should report the same: 2 errors and 0 failures. - $task = StackkitCloudTask::whereTaskUuid($job->payloadAsArray('uuid'))->firstOrFail(); - $this->assertEquals(2, $task->getNumberOfAttempts()); - $this->assertEquals('error', $task->status); - - $releasedJob->run(); + // When retryUntil is specified, the maxAttempts is ignored. - # Max attempts was reached - # Laravel 5, 6, 7: fail because max attempts was reached - # Laravel 8+: don't fail because retryUntil has not yet passed. + // Act & Assert - if (version_compare(app()->version(), '8.0.0', '<')) { - $this->assertEquals('failed', $task->fresh()->status); - return; - } else { - $this->assertEquals('error', $task->fresh()->status); - } + // The max attempts is 3, but the retryUntil is set to 5 minutes from now. + // So when we attempt the job 4 times, it should still not fail. + $job = $job + ->runAndGetReleasedJob() + ->runAndGetReleasedJob() + ->runAndGetReleasedJob() + ->runAndGetReleasedJob(); - CloudTasksApi::shouldReceive('getRetryUntilTimestamp')->andReturn(time() - 1); - $releasedJob->run(); + $this->assertDatabaseCount('failed_jobs', 0); - $this->assertEquals('failed', $task->fresh()->status); + // Now we travel to 5 minutes from now, and the job should fail. + $this->travelTo('2020-01-01 00:05:00'); + $job->run(); + $this->assertDatabaseCount('failed_jobs', 1); } - /** - * @test - */ + #[Test] public function it_can_handle_encrypted_jobs() { - if (version_compare(app()->version(), '8.0.0', '<')) { - $this->markTestSkipped('Not supported by Laravel 7.x and below.'); - } - // Arrange - OpenIdVerificator::fake(); - Log::swap(new LogFake()); + Event::fake(JobOutput::class); // Act $job = $this->dispatch(new EncryptedJob()); $job->run(); // Assert - $this->assertStringContainsString( - 'O:26:"Tests\Support\EncryptedJob"', - decrypt($job->payloadAsArray('data.command')), - ); - - Log::assertLogged('EncryptedJob:success'); + Event::assertDispatched(fn (JobOutput $event) => $event->output === 'EncryptedJob:success'); } - /** - * @test - */ + #[Test] public function failing_jobs_are_released() { // Arrange - OpenIdVerificator::fake(); - CloudTasksApi::partialMock()->shouldReceive('getRetryConfig')->andReturn( - (new RetryConfig())->setMaxAttempts(3) - ); - Event::fake($this->getJobReleasedAfterExceptionEvent()); + Event::fake(JobReleasedAfterException::class); // Act $job = $this->dispatch(new FailingJob()); @@ -417,85 +197,68 @@ public function failing_jobs_are_released() $job->run(); - CloudTasksApi::assertDeletedTaskCount(1); CloudTasksApi::assertCreatedTaskCount(2); - CloudTasksApi::assertTaskDeleted($job->task->getName()); - Event::assertDispatched($this->getJobReleasedAfterExceptionEvent(), function ($event) { + Event::assertDispatched(JobReleasedAfterException::class, function ($event) { return $event->job->attempts() === 1; }); } - /** - * @test - */ + #[Test] public function attempts_are_tracked_internally() { // Arrange - OpenIdVerificator::fake(); - Event::fake($this->getJobReleasedAfterExceptionEvent()); + Event::fake(JobReleasedAfterException::class); // Act & Assert $job = $this->dispatch(new FailingJob()); - $job->run(); - $releasedJob = null; - Event::assertDispatched($this->getJobReleasedAfterExceptionEvent(), function ($event) use (&$releasedJob) { + $released = $job->runAndGetReleasedJob(); + + Event::assertDispatched(JobReleasedAfterException::class, function ($event) use (&$releasedJob) { $releasedJob = $event->job->getRawBody(); + return $event->job->attempts() === 1; }); - $this->runFromPayload($releasedJob); + $released->run(); - Event::assertDispatched($this->getJobReleasedAfterExceptionEvent(), function ($event) { + Event::assertDispatched(JobReleasedAfterException::class, function ($event) { return $event->job->attempts() === 2; }); } - /** - * @test - */ - public function attempts_are_copied_from_x_header() + #[Test] + public function retried_jobs_get_a_new_name() { // Arrange - OpenIdVerificator::fake(); - Event::fake($this->getJobReleasedAfterExceptionEvent()); + Event::fake(JobReleasedAfterException::class); + CloudTasksApi::fake(); // Act & Assert - $job = $this->dispatch(new FailingJob()); - request()->headers->set('X-CloudTasks-TaskRetryCount', 6); - $job->run(); - - Event::assertDispatched($this->getJobReleasedAfterExceptionEvent(), function ($event) { - return $event->job->attempts() === 7; - }); + $this->assertCount(0, $this->createdTasks); + $this->dispatch(new FailingJob())->runAndGetReleasedJob(); + $this->assertCount(2, $this->createdTasks); + $this->assertNotEquals($this->createdTasks[0]->getName(), $this->createdTasks[1]->getName()); } - /** - * @test - */ - public function retried_jobs_get_a_new_name() + #[Test] + public function test_job_timeout() { // Arrange - OpenIdVerificator::fake(); - Event::fake($this->getJobReleasedAfterExceptionEvent()); - CloudTasksApi::fake(); + Event::fake(JobOutput::class); - // Act & Assert - Carbon::setTestNow(Carbon::createFromTimestamp(1685035628)); - $job = $this->dispatch(new FailingJob()); - Carbon::setTestNow(Carbon::createFromTimestamp(1685035629)); - - $job->run(); + // Act + $this->dispatch(new SimpleJobWithTimeout())->run(); // Assert - CloudTasksApi::assertCreatedTaskCount(2); - CloudTasksApi::assertTaskCreated(function (Task $task): bool { - [$timestamp] = array_reverse(explode('-', $task->getName())); - return $timestamp === '1685035628000'; - }); - CloudTasksApi::assertTaskCreated(function (Task $task): bool { - [$timestamp] = array_reverse(explode('-', $task->getName())); - return $timestamp === '1685035629000'; - }); + $events = Event::dispatched(JobOutput::class)->map(fn ($event) => $event[0]->output)->toArray(); + $this->assertEquals([ + 'SimpleJobWithTimeout:1', + 'SimpleJobWithTimeout:2', + 'SimpleJobWithTimeout:3', + 'SimpleJobWithTimeout:worker-stopping', + 'SimpleJobWithTimeout:4', + 'SimpleJobWithTimeout:5', + ], $events); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 048d6a9..026b3c3 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,97 +1,59 @@ withFactories(__DIR__ . '/../factories'); - - $this->defaultHeaders['Authorization'] = 'Bearer ' . encrypt(time() + 10); - - Event::listen( - $this->getJobReleasedAfterExceptionEvent(), - function ($event) { - $this->releasedJobPayload = $event->job->getRawBody(); - } - ); + Event::listen(TaskCreated::class, function (TaskCreated $event) { + $this->createdTasks[] = $event->task; + }); } - /** - * Get package providers. At a minimum this is the package being tested, but also - * would include packages upon which our package depends, e.g. Cartalyst/Sentry - * In a normal app environment these would be added to the 'providers' array in - * the config/app.php file. - * - * @param \Illuminate\Foundation\Application $app - * - * @return array - */ protected function getPackageProviders($app) { return [ - \Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksServiceProvider::class, + CloudTasksServiceProvider::class, ]; } - /** - * Define database migrations. - * - * @return void - */ protected function defineDatabaseMigrations() { - $this->loadMigrationsFrom(__DIR__ . '/../migrations'); - $this->loadMigrationsFrom(__DIR__ . '/../vendor/orchestra/testbench-core/laravel/migrations'); + // Necessary to test the [failed_jobs] table. + + $this->loadMigrationsFrom(__DIR__.'/../vendor/orchestra/testbench-core/laravel/migrations'); } - /** - * Define environment setup. - * - * @param \Illuminate\Foundation\Application $app - * @return void - */ protected function getEnvironmentSetUp($app) { - foreach (glob(storage_path('framework/cache/data/*/*/*')) as $file) { - unlink($file); - } - $app['config']->set('database.default', 'testbench'); $port = env('DB_DRIVER') === 'mysql' ? 3307 : 5432; $app['config']->set('database.connections.testbench', [ - 'driver' => env('DB_DRIVER', 'mysql'), + 'driver' => env('DB_DRIVER', 'mysql'), 'host' => '127.0.0.1', 'port' => $port, 'database' => 'cloudtasks', 'username' => 'cloudtasks', 'password' => 'cloudtasks', - 'prefix' => '', + 'prefix' => '', ]); $app['config']->set('cache.default', 'file'); @@ -103,158 +65,37 @@ protected function getEnvironmentSetUp($app) 'location' => 'europe-west6', 'handler' => env('CLOUD_TASKS_HANDLER', 'https://docker.for.mac.localhost:8080'), 'service_account_email' => 'info@stackkit.io', - 'signed_audience' => true, ]); - $app['config']->set('queue.failed.driver', 'database-uuids'); - $app['config']->set('queue.failed.database', 'testbench'); - $disableDashboardPrefix = 'when_dashboard_is_disabled'; + $app['config']->set('queue.connections.my-other-cloudtasks-connection', [ + ...config('queue.connections.my-cloudtasks-connection'), + 'queue' => 'other-barbequeue', + 'project' => 'other-my-test-project', + ]); - $testName = method_exists($this, 'name') ? $this->name() : $this->getName(); - if (substr($testName, 0, strlen($disableDashboardPrefix)) === $disableDashboardPrefix) { - $app['config']->set('cloud-tasks.dashboard.enabled', false); - } else { - $app['config']->set('cloud-tasks.dashboard.enabled', true); - } + $app['config']->set('queue.failed.driver', 'database-uuids'); + $app['config']->set('queue.failed.database', 'testbench'); } protected function setConfigValue($key, $value) { - $this->app['config']->set('queue.connections.my-cloudtasks-connection.' . $key, $value); + $this->app['config']->set('queue.connections.my-cloudtasks-connection.'.$key, $value); } - public function dispatch($job) + public function dispatch($job): DispatchedJob { $payload = null; - $payloadAsArray = []; $task = null; - Event::listen(TaskCreated::class, function (TaskCreated $event) use (&$payload, &$payloadAsArray, &$task) { + Event::listen(TaskCreated::class, function (TaskCreated $event) use (&$payload, &$task) { $request = $event->task->getHttpRequest() ?? $event->task->getAppEngineHttpRequest(); $payload = $request->getBody(); - $payloadAsArray = json_decode($payload, true); $task = $event->task; - - [,,,,,,,$taskName] = explode('/', $task->getName()); - - if ($task->hasHttpRequest()) { - request()->headers->set('X-Cloudtasks-Taskname', $taskName); - } - - if ($task->hasAppEngineHttpRequest()) { - request()->headers->set('X-AppEngine-TaskName', $taskName); - } }); dispatch($job); - return new class($payload, $task, $this) { - public string $payload; - public Task $task; - public TestCase $testCase; - - public function __construct(string $payload, Task $task, TestCase $testCase) - { - $this->payload = $payload; - $this->task = $task; - $this->testCase = $testCase; - } - - public function run(): void - { - rescue(function (): void { - app(TaskHandler::class)->handle($this->payload); - }); - } - - public function runWithoutExceptionHandler(): void - { - app(TaskHandler::class)->handle($this->payload); - } - - public function runAndGetReleasedJob(): self - { - rescue(function (): void { - app(TaskHandler::class)->handle($this->payload); - }); - - return new self( - $this->testCase->releasedJobPayload, - $this->task, - $this->testCase - ); - } - - public function payloadAsArray(string $key = '') - { - $decoded = json_decode($this->payload, true); - - return data_get($decoded, $key ?: null); - } - }; - } - - public function runFromPayload(string $payload): void - { - rescue(function () use ($payload) { - app(TaskHandler::class)->handle($payload); - }); - } - - public function assertTaskDeleted(string $taskId): void - { - try { - $this->client->getTask($taskId); - - $this->fail('Getting the task should throw an exception but it did not.'); - } catch (ApiException $e) { - $this->assertStringContainsString('The task no longer exists', $e->getMessage()); - } - } - - public function assertTaskExists(string $taskId): void - { - try { - $task = $this->client->getTask($taskId); - - $this->assertInstanceOf(Task::class, $task); - } catch (ApiException $e) { - $this->fail('Task [' . $taskId . '] should exist but it does not (or something else went wrong).'); - } - } - - protected function addIdTokenToHeader(?Closure $closure = null): void - { - $base = [ - 'iss' => 'https://accounts.google.com', - 'aud' => 'https://docker.for.mac.localhost:8080', - 'exp' => time() + 10, - ]; - - if ($closure) { - $base = $closure($base); - } - - $privateKey = file_get_contents(__DIR__ . '/../tests/Support/self-signed-private-key.txt'); - - $token = JWT::encode($base, $privateKey, 'RS256', 'abc123'); - - request()->headers->set('Authorization', 'Bearer ' . $token); - } - - protected function assertDatabaseCount($table, int $count, $connection = null) - { - $this->assertEquals($count, DB::connection($connection)->table($table)->count()); - } - - public function getJobReleasedAfterExceptionEvent(): string - { - // The JobReleasedAfterException event is not available in Laravel versions - // below 9.x so instead for those versions we throw our own event which - // is identical to the Laravel one. - return version_compare(app()->version(), '9.0.0', '<') - ? PackageJobReleasedAfterException::class - : JobReleasedAfterException::class; + return new DispatchedJob($payload, $task, $this); } public function withTaskType(string $taskType): void @@ -263,7 +104,6 @@ public function withTaskType(string $taskType): void case 'appengine': $this->setConfigValue('handler', null); $this->setConfigValue('service_account_email', null); - $this->setConfigValue('signed_audience', null); $this->setConfigValue('app_engine', true); $this->setConfigValue('app_engine_service', 'api'); @@ -274,7 +114,6 @@ public function withTaskType(string $taskType): void $this->setConfigValue('handler', 'https://docker.for.mac.localhost:8080'); $this->setConfigValue('service_account_email', 'info@stackkit.io'); - $this->setConfigValue('signed_audience', true); break; } } diff --git a/views/layout.blade.php b/views/layout.blade.php deleted file mode 100644 index c7f0474..0000000 --- a/views/layout.blade.php +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - Cloud Tasks for Laravel - - - - - @foreach ($manifest['index.html']['css'] as $css) - - @endforeach - - -
- - - - - -