diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f3f8d8e..2869804 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -7,55 +7,23 @@ on: jobs: php-tests: - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest strategy: matrix: - php: [8.1, 8.0, 7.4, 7.3, 7.2] - laravel: [9.*, 8.*, 7.*, 6.*, 5.8.*, 5.7.*, 5.6.*] - os: [ubuntu-latest] - include: - - laravel: 9.* - testbench: 7.* - - laravel: 8.* - testbench: 6.* - - laravel: 7.* - testbench: 5.* - - laravel: 6.* - testbench: 4.* - - laravel: 5.8.* - testbench: 3.8.* - - laravel: 5.7.* - testbench: 3.7.* - - laravel: 5.6.* - testbench: 3.6.* - exclude: - - laravel: 9.* - php: 7.2 - - laravel: 9.* - php: 7.3 - - laravel: 9.* - php: 7.4 - - laravel: 8.* - php: 7.2 - - laravel: 5.8.* - php: 8.0 - - laravel: 5.7.* - php: 8.0 - - laravel: 5.6.* - php: 8.0 - - laravel: 5.6.* - php: 8.1 - - laravel: 5.7.* - php: 8.1 - - laravel: 5.8.* - php: 8.1 - - laravel: 6.* - php: 8.1 - - laravel: 7.* - php: 8.1 + db: ['mysql', 'pgsql'] + payload: + - { 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.*' } - name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} + name: PHP ${{ matrix.payload.php }} - Laravel ${{ matrix.payload.laravel }} - DB ${{ matrix.db }} steps: - name: Checkout code @@ -64,13 +32,34 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ matrix.php }} + php-version: ${{ matrix.payload.php }} extensions: mbstring, dom, fileinfo coverage: none + - name: Set up MySQL and PostgreSQL + run: | + MYSQL_PORT=3307 POSTGRES_PORT=5432 docker compose up ${{ matrix.db }} -d - name: Install dependencies run: | - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer require "laravel/framework:${{ matrix.payload.laravel }}" "orchestra/testbench:${{ matrix.payload.testbench }}" --no-interaction --no-update composer update --prefer-stable --prefer-dist --no-interaction --no-suggest + if [ "${{ matrix.db }}" = "mysql" ]; then + while ! mysqladmin ping --host=127.0.0.1 --user=cloudtasks --port=3307 --password=cloudtasks --silent; do + echo "Waiting for MySQL..." + sleep 1 + done + else + echo "Not waiting for MySQL." + fi - name: Execute tests - run: vendor/bin/phpunit + env: + DB_DRIVER: ${{ matrix.db }} + CI_CLOUD_TASKS_PROJECT_ID: ${{ secrets.CI_CLOUD_TASKS_PROJECT_ID }} + CI_CLOUD_TASKS_QUEUE: ${{ secrets.CI_CLOUD_TASKS_QUEUE }} + CI_CLOUD_TASKS_LOCATION: ${{ secrets.CI_CLOUD_TASKS_LOCATION }} + CI_CLOUD_TASKS_SERVICE_ACCOUNT_EMAIL: ${{ secrets.CI_CLOUD_TASKS_SERVICE_ACCOUNT_EMAIL }} + CI_SERVICE_ACCOUNT_JSON_KEY: ${{ secrets.CI_SERVICE_ACCOUNT_JSON_KEY }} + CI_CLOUD_TASKS_CUSTOM_QUEUE: ${{ matrix.payload.queue }} + run: | + echo $CI_SERVICE_ACCOUNT_JSON_KEY > tests/Support/gcloud-key-valid.json + vendor/bin/phpunit diff --git a/CHANGELOG.md b/CHANGELOG.md index f2987e0..12630ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## 3.0.0 - 2022-04-03 + +**Added** + +- Added support for PostgreSQL +- Added a dashboard used to monitor jobs + +**Removed** + +- Dropped support for PHP 7.2 and 7.3 +- Dropped support for Laravel 5.x + ## 2.3.0 - 2022-02-09 **Changed** diff --git a/README.md b/README.md index a5ae394..8b4b053 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- +

Build Status @@ -9,172 +9,147 @@ # Introduction -This package allows you to use Google Cloud Tasks as your queue driver. +This package allows Google Cloud Tasks to be used as the queue driver. -# How it works - -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. - -For more information on how to configure the Cloud Tasks queue, read the next section [Configuring the queue](#configuring-the-queue) - -This package uses the HTTP request handler and doesn't support AppEngine. But feel free to contribute! +

+ +

-# Requirements +
+ + Requirements + -This package requires Laravel 5.6 or higher. +
+ 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. Please check the table below for supported Laravel and PHP versions: |Laravel Version| PHP Version | -|---|---| -| 5.6 | 7.2 or 7.3 or 7.4 -| 5.7 | 7.2 or 7.3 or 7.4 -| 5.8 | 7.2 or 7.3 or 7.4 -| 6.x | 7.2 or 7.3 or 7.4 or 8.0 -| 7.x | 7.2 or 7.3 or 7.4 or 8.0 -| 8.x | 7.3 or 7.4 or 8.0 or 8.1 + |---|---| +| 6.x | 7.4 or 8.0 +| 7.x | 7.4 or 8.0 +| 8.x | 7.4 or 8.0 or 8.1 | 9.x | 8.0 or 8.1 +
+
+ Installation +
-# Installation - -(1) Require the package using Composer - -```bash -composer require stackkit/laravel-google-cloud-tasks-queue -``` - + Require the package using Composer -[Official documentation - Creating Cloud Tasks queues](https://cloud.google.com/tasks/docs/creating-queues) + ```console + composer require stackkit/laravel-google-cloud-tasks-queue + ``` -(2) Add a new queue connection to `config/queue.php` + Add a new queue connection to `config/queue.php` -``` -'cloudtasks' => [ - 'driver' => 'cloudtasks', - 'project' => env('STACKKIT_CLOUD_TASKS_PROJECT', ''), - 'location' => env('STACKKIT_CLOUD_TASKS_LOCATION', ''), - 'handler' => env('STACKKIT_CLOUD_TASKS_HANDLER', ''), - 'queue' => env('STACKKIT_CLOUD_TASKS_QUEUE', 'default'), - 'service_account_email' => env('STACKKIT_CLOUD_TASKS_SERVICE_EMAIL', ''), -], -``` + ```php + 'cloudtasks' => [ + 'driver' => 'cloudtasks', + 'project' => env('STACKKIT_CLOUD_TASKS_PROJECT', ''), + 'location' => env('STACKKIT_CLOUD_TASKS_LOCATION', ''), + 'handler' => env('STACKKIT_CLOUD_TASKS_HANDLER', ''), + 'queue' => env('STACKKIT_CLOUD_TASKS_QUEUE', 'default'), + 'service_account_email' => env('STACKKIT_CLOUD_TASKS_SERVICE_EMAIL', ''), + ], + ``` -(3) Update the `QUEUE_CONNECTION` environment variable +Update the `QUEUE_CONNECTION` environment variable -``` -QUEUE_CONNECTION=cloudtasks -``` - -(4) [Laravel ^8.0 and above only] configure failed tasks to use the `database-uuids` driver in `config/queue.php` - -``` -'failed' => [ - 'database' => env('DB_CONNECTION', 'mysql'), - 'table' => 'failed_jobs', - 'driver' => 'database-uuids', -], -``` - -(5) Create a new Cloud Tasks queue using `gcloud` - -````bash -gcloud tasks queues create [QUEUE_ID] -```` + ```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 AppEngine is hosted|`europe-west6` -|`STACKKIT_CLOUD_TASKS_HANDLER`|The URL that Cloud Tasks will call to process a job. This should be the URL to your Laravel app with the `handle-task` path added|`https://.com/handle-task` -|`STACKKIT_CLOUD_TASKS_QUEUE`|The queue a job will be added to|`emails` -|`STACKKIT_CLOUD_TASKS_SERVICE_EMAIL`|The email address of the AppEngine service account. Important, it should have the *Cloud Tasks Enqueuer* role. This is used for securing the handler.|`my-service-account@appspot.gserviceaccount.com` - -# Authentication - -Set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable with a path to the credentials file. - -More info: https://cloud.google.com/docs/authentication/production - -## Service Account Roles - -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 - -# Configuring the queue - -When you first create a queue using `gcloud tasks queues create`, the default settings will look something like this: - -``` -rateLimits: - maxBurstSize: 100 - maxConcurrentDispatches: 1000 - maxDispatchesPerSecond: 500.0 -retryConfig: - maxAttempts: 100 - maxBackoff: 3600s - maxDoublings: 16 - minBackoff: 0.100s -``` - -## Configurable settings - -### maxBurstSize +| 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` +| `STACKKIT_CLOUD_TASKS_SERVICE_EMAIL` | 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` +
+
+ + How it works + +
+ Using Cloud Tasks as a Laravel queue driver is fundamentally different than other Laravel queue drivers, like Redis. -Max burst size limits how fast tasks in queue are processed when many tasks are in the queue and the rate is high. - -### maxConcurrentDispatches +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. +
+
+ Dashboard (beta) +
+ The package comes with a beautiful dashboard that can be used to monitor all queued jobs. -The maximum number of concurrent tasks that Cloud Tasks allows to be dispatched for this queue -### maxDispatchesPerSecond + -The maximum rate at which tasks are dispatched from this queue. + --- -### maxAttempts +_Experimental_ -Number of attempts per task. Cloud Tasks will attempt the task max_attempts times (that is, if the first attempt fails, then there will be max_attempts - 1 retries). Must be >= -1.| +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. -### maxBackoff +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. -A task will be scheduled for retry between min_backoff and max_backoff duration after it fails + --- -### maxDoublings -The time between retries will double max_doublings times. +To make use of it, enable it through the `.env` file: -A task's retry interval starts at min_backoff, then doubles max_doublings times, then increases linearly, and finally retries retries at intervals of max_backoff up to max_attempts times. - -For example, if min_backoff is 10s, max_backoff is 300s, and max_doublings is 3, then the a task will first be retried in 10s. The retry interval will double three times, and then increase linearly by 2^3 * 10s. Finally, the task will retry at intervals of max_backoff until the task has been attempted max_attempts times. Thus, the requests will retry at 10s, 20s, 40s, 80s, 160s, 240s, 300s, 300s, .... + ```dotenv + STACKKIT_CLOUD_TASKS_DASHBOARD_ENABLED=true + STACKKIT_CLOUD_TASKS_DASHBOARD_PASSWORD=MySecretLoginPasswordPleaseChangeThis + ``` -## Recommended settings for Laravel +Then publish its assets and migrations: -To simulate a single `queue:work/queue:listen` process, simply set the `maxConcurrentDispatches` to 1: + ```console + php artisan vendor:publish --tag=cloud-tasks + php artisan migrate + ``` -``` -gcloud tasks queues update [QUEUE_ID] --max-concurrent-dispatches=1 -``` +The dashboard is accessible at the URI: /cloud-tasks -More information on configuring queues: +
+
+ Authentication +
-https://cloud.google.com/tasks/docs/configuring-queues +Set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable with a path to the credentials file. -# Security +More info: https://cloud.google.com/docs/authentication/production -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. +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. 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. +
diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..93d0a71 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,18 @@ +# From 2.x to 3.x + +PHP 7.2 and 7.3, and Laravel 5.x are no longer supported. + +## Update handler URL (Impact: high) + +The handler URL environment has been simplified. Please change it like this: + +```dotenv +# Before +STACKKIT_CLOUD_TASKS_HANDLER=https://my-app/handle-task +# After +STACKKIT_CLOUD_TASKS_HANDLER=https://my-app +``` + +It's also allowed to remove this variable entirely in 3.x: The package will automatically use the application URL if the `STACKKIT_CLOUD_TASKS_HANDLER` +environment is not present. If you omit it, please ensure the [trusted proxy](https://laravel.com/docs/9.x/requests#configuring-trusted-proxies) have been configured +in your application. Otherwise, you might run into weird issues. :-) diff --git a/assets/cloud-tasks-home.png b/assets/cloud-tasks-home.png new file mode 100644 index 0000000..4ed2802 Binary files /dev/null and b/assets/cloud-tasks-home.png differ diff --git a/assets/dashboard.png b/assets/dashboard.png new file mode 100644 index 0000000..0051f17 Binary files /dev/null and b/assets/dashboard.png differ diff --git a/logo.png b/assets/logo.png similarity index 100% rename from logo.png rename to assets/logo.png diff --git a/composer.json b/composer.json index 5683ac1..c01c313 100644 --- a/composer.json +++ b/composer.json @@ -9,13 +9,15 @@ ], "require": { "ext-json": "*", - "google/cloud-tasks": "^v1.9", - "firebase/php-jwt": "^5.5", - "phpseclib/phpseclib": "~2.0" + "phpseclib/phpseclib": "~2.0", + "google/cloud-tasks": "^1.10", + "thecodingmachine/safe": "^1.0|^2.0" }, "require-dev": { - "mockery/mockery": "^1.2", - "orchestra/testbench": "^3.5 || ^3.6 || ^3.7 || ^3.8 || ^4.0 || ^5.0" + "orchestra/testbench": "^4.0 || ^5.0 || ^6.0 || ^7.0", + "nunomaduro/larastan": "^1.0 || ^2.0", + "thecodingmachine/phpstan-safe-rule": "^1.2", + "laravel/legacy-factories": "^1.3" }, "autoload": { "psr-4": { @@ -24,7 +26,8 @@ }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/" + "Tests\\": "tests/", + "Factories\\": "factories/" } }, "extra": { diff --git a/config/cloud-tasks.php b/config/cloud-tasks.php new file mode 100644 index 0000000..c8cbdca --- /dev/null +++ b/config/cloud-tasks.php @@ -0,0 +1,10 @@ + [ + 'enabled' => env('STACKKIT_CLOUD_TASKS_DASHBOARD_ENABLED', false), + 'password' => env('STACKKIT_CLOUD_TASKS_DASHBOARD_PASSWORD', 'MyPassword1!') + ], +]; diff --git a/dashboard/.env.production b/dashboard/.env.production new file mode 100644 index 0000000..292a14c --- /dev/null +++ b/dashboard/.env.production @@ -0,0 +1 @@ +VITE_API_URL= diff --git a/dashboard/.gitignore b/dashboard/.gitignore new file mode 100644 index 0000000..a84704d --- /dev/null +++ b/dashboard/.gitignore @@ -0,0 +1,4 @@ +node_modules +.DS_Store +dist-ssr +*.local \ No newline at end of file diff --git a/dashboard/.prettierignore b/dashboard/.prettierignore new file mode 100644 index 0000000..85dd8c4 --- /dev/null +++ b/dashboard/.prettierignore @@ -0,0 +1,6 @@ +# Ignore artifacts: +build +coverage +.vscode +node_modules +.idea diff --git a/dashboard/.prettierrc.js b/dashboard/.prettierrc.js new file mode 100644 index 0000000..0614ee7 --- /dev/null +++ b/dashboard/.prettierrc.js @@ -0,0 +1,6 @@ +module.exports = { + trailingComma: 'es5', + tabWidth: 2, + semi: false, + singleQuote: true, +} diff --git a/dashboard/.prettierrc.json b/dashboard/.prettierrc.json new file mode 100644 index 0000000..b2095be --- /dev/null +++ b/dashboard/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "semi": false, + "singleQuote": true +} diff --git a/dashboard/README.md b/dashboard/README.md new file mode 100644 index 0000000..c0793a8 --- /dev/null +++ b/dashboard/README.md @@ -0,0 +1,7 @@ +# 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 new file mode 100644 index 0000000..fedb792 --- /dev/null +++ b/dashboard/dist/manifest.json @@ -0,0 +1,16 @@ +{ + "index.html": { + "file": "assets/index.5a46c6a0.js", + "src": "index.html", + "isEntry": true, + "imports": [ + "_vendor.433de25e.js" + ], + "css": [ + "assets/index.1002db9a.css" + ] + }, + "_vendor.433de25e.js": { + "file": "assets/vendor.433de25e.js" + } +} \ No newline at end of file diff --git a/dashboard/dist/pw_maze_white.png b/dashboard/dist/pw_maze_white.png new file mode 100644 index 0000000..6646483 Binary files /dev/null and b/dashboard/dist/pw_maze_white.png differ diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 0000000..4333263 --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite App + + +
+ + + diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json new file mode 100644 index 0000000..61de1ad --- /dev/null +++ b/dashboard/package-lock.json @@ -0,0 +1,2829 @@ +{ + "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 new file mode 100644 index 0000000..412ac7e --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,22 @@ +{ + "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 new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/dashboard/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/dashboard/public/favicon.ico b/dashboard/public/favicon.ico new file mode 100644 index 0000000..df36fcf Binary files /dev/null and b/dashboard/public/favicon.ico differ diff --git a/dashboard/src/App.vue b/dashboard/src/App.vue new file mode 100644 index 0000000..eea59d2 --- /dev/null +++ b/dashboard/src/App.vue @@ -0,0 +1,29 @@ + + + + diff --git a/dashboard/src/api.js b/dashboard/src/api.js new file mode 100644 index 0000000..82df227 --- /dev/null +++ b/dashboard/src/api.js @@ -0,0 +1,84 @@ +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 new file mode 100644 index 0000000..f3d2503 Binary files /dev/null and b/dashboard/src/assets/logo.png differ diff --git a/dashboard/src/components/Dashboard.vue b/dashboard/src/components/Dashboard.vue new file mode 100644 index 0000000..7d4fffa --- /dev/null +++ b/dashboard/src/components/Dashboard.vue @@ -0,0 +1,107 @@ + + + diff --git a/dashboard/src/components/Failed.vue b/dashboard/src/components/Failed.vue new file mode 100644 index 0000000..5009a2c --- /dev/null +++ b/dashboard/src/components/Failed.vue @@ -0,0 +1,29 @@ + + + diff --git a/dashboard/src/components/FilterCard.vue b/dashboard/src/components/FilterCard.vue new file mode 100644 index 0000000..a24526b --- /dev/null +++ b/dashboard/src/components/FilterCard.vue @@ -0,0 +1,80 @@ + + + diff --git a/dashboard/src/components/Icon.vue b/dashboard/src/components/Icon.vue new file mode 100644 index 0000000..a29e556 --- /dev/null +++ b/dashboard/src/components/Icon.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/dashboard/src/components/Login.vue b/dashboard/src/components/Login.vue new file mode 100644 index 0000000..17f04f8 --- /dev/null +++ b/dashboard/src/components/Login.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/dashboard/src/components/Menu.vue b/dashboard/src/components/Menu.vue new file mode 100644 index 0000000..3458e78 --- /dev/null +++ b/dashboard/src/components/Menu.vue @@ -0,0 +1,31 @@ + diff --git a/dashboard/src/components/Overview.vue b/dashboard/src/components/Overview.vue new file mode 100644 index 0000000..8679fd0 --- /dev/null +++ b/dashboard/src/components/Overview.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/dashboard/src/components/Queued.vue b/dashboard/src/components/Queued.vue new file mode 100644 index 0000000..03335e7 --- /dev/null +++ b/dashboard/src/components/Queued.vue @@ -0,0 +1,29 @@ + + + diff --git a/dashboard/src/components/Recent.vue b/dashboard/src/components/Recent.vue new file mode 100644 index 0000000..58c4855 --- /dev/null +++ b/dashboard/src/components/Recent.vue @@ -0,0 +1,29 @@ + + + diff --git a/dashboard/src/components/Spinner.vue b/dashboard/src/components/Spinner.vue new file mode 100644 index 0000000..c2a0d55 --- /dev/null +++ b/dashboard/src/components/Spinner.vue @@ -0,0 +1,22 @@ + diff --git a/dashboard/src/components/Status.vue b/dashboard/src/components/Status.vue new file mode 100644 index 0000000..e542088 --- /dev/null +++ b/dashboard/src/components/Status.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/dashboard/src/components/Task.vue b/dashboard/src/components/Task.vue new file mode 100644 index 0000000..b8297ea --- /dev/null +++ b/dashboard/src/components/Task.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/dashboard/src/components/TaskRowSpinner.vue b/dashboard/src/components/TaskRowSpinner.vue new file mode 100644 index 0000000..a877678 --- /dev/null +++ b/dashboard/src/components/TaskRowSpinner.vue @@ -0,0 +1,28 @@ + diff --git a/dashboard/src/index.css b/dashboard/src/index.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/dashboard/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/dashboard/src/main.js b/dashboard/src/main.js new file mode 100644 index 0000000..4b958dd --- /dev/null +++ b/dashboard/src/main.js @@ -0,0 +1,102 @@ +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 new file mode 100644 index 0000000..c3d7982 --- /dev/null +++ b/dashboard/tailwind.config.js @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..3cbd7b6 --- /dev/null +++ b/dashboard/vite.config.js @@ -0,0 +1,11 @@ +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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..774c616 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + mysql: + image: mysql:8 + ports: + - '${MYSQL_PORT:-3307}:3306' + environment: + MYSQL_USER: 'cloudtasks' + MYSQL_PASSWORD: 'cloudtasks' + MYSQL_DATABASE: 'cloudtasks' + MYSQL_ROOT_PASSWORD: 'root' + pgsql: + image: postgres:14 + ports: + - '${POSTGRES_PORT:-5432}:5432' + environment: + POSTGRES_USER: 'cloudtasks' + POSTGRES_PASSWORD: 'cloudtasks' + POSTGRES_DB: 'cloudtasks' diff --git a/factories/StackkitCloudTaskFactory.php b/factories/StackkitCloudTaskFactory.php new file mode 100644 index 0000000..ac0991e --- /dev/null +++ b/factories/StackkitCloudTaskFactory.php @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..2455a5a --- /dev/null +++ b/migrations/2021_10_16_171140_create_stackkit_cloud_tasks_table.php @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..b2e12de --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,10 @@ +includes: + - ./vendor/nunomaduro/larastan/extension.neon + - ./vendor/thecodingmachine/phpstan-safe-rule/phpstan-safe-rule.neon +parameters: + paths: + - src + level: 9 + checkMissingIterableValueType: false + ignoreErrors: + - '/Cannot call method when\(\) on mixed/' diff --git a/phpunit.xml b/phpunit.xml index 994539f..8ab5281 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -8,10 +8,12 @@ convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false"> - - - ./tests/ + + ./tests/ConfigTest.php + ./tests/TaskHandlerTest.php + ./tests/CloudTasksApiTest.php + ./tests/CloudTasksDashboardTest.php @@ -21,14 +23,8 @@ - - + + - - - - src/ - - diff --git a/src/Authenticate.php b/src/Authenticate.php new file mode 100644 index 0000000..aef3c13 --- /dev/null +++ b/src/Authenticate.php @@ -0,0 +1,18 @@ +json('', 403); + } +} diff --git a/src/CloudTasks.php b/src/CloudTasks.php new file mode 100644 index 0000000..b9ca554 --- /dev/null +++ b/src/CloudTasks.php @@ -0,0 +1,53 @@ +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 new file mode 100644 index 0000000..bc8f4bd --- /dev/null +++ b/src/CloudTasksApi.php @@ -0,0 +1,29 @@ +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; + } + + public function createTask(string $queueName, Task $task): Task + { + return $this->client->createTask($queueName, $task); + } + + public function deleteTask(string $taskName): void + { + $this->client->deleteTask($taskName); + } + + public function getTask(string $taskName): Task + { + return $this->client->getTask($taskName); + } + + public function getRetryUntilTimestamp(string $taskName): ?int + { + $task = $this->getTask($taskName); + + $attempt = $task->getFirstAttempt(); + + if (!$attempt instanceof Attempt) { + return null; + } + + $queueName = implode('/', array_slice(explode('/', $task->getName()), 0, 6)); + + $retryConfig = $this->getRetryConfig($queueName); + + $maxRetryDuration = $retryConfig->getMaxRetryDuration(); + $dispatchTime = $attempt->getDispatchTime(); + + if (! $maxRetryDuration instanceof Duration || ! $dispatchTime instanceof Timestamp) { + return null; + } + + $maxDurationInSeconds = (int) $maxRetryDuration->getSeconds(); + + $firstAttemptTimestamp = $dispatchTime->toDateTime()->getTimestamp(); + + return $firstAttemptTimestamp + $maxDurationInSeconds; + } +} diff --git a/src/CloudTasksApiContract.php b/src/CloudTasksApiContract.php new file mode 100644 index 0000000..d43e1ec --- /dev/null +++ b/src/CloudTasksApiContract.php @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..da3b56c --- /dev/null +++ b/src/CloudTasksApiFake.php @@ -0,0 +1,94 @@ +setMinBackoff((new Duration(['seconds' => 0]))) + ->setMaxBackoff((new Duration(['seconds' => 0]))); + + return $retryConfig; + } + + public function createTask(string $queueName, Task $task): Task + { + $task->setName(Str::uuid()->toString()); + + $this->createdTasks[] = compact('queueName', 'task'); + + return $task; + } + + public function deleteTask(string $taskName): void + { + $this->deletedTasks[] = $taskName; + } + + public function getTask(string $taskName): Task + { + return (new Task()) + ->setName($taskName); + } + + + public function getRetryUntilTimestamp(string $taskName): ?int + { + return null; + } + + public function assertTaskDeleted(string $taskName): void + { + $taskUuids = array_map(function ($fullTaskName) { + return Arr::last(explode('/', $fullTaskName)); + }, $this->deletedTasks); + + Assert::assertTrue( + in_array($taskName, $taskUuids), + 'The task [' . $taskName . '] should have been deleted but it is not.' + ); + } + + public function assertTaskNotDeleted(string $taskName): void + { + $taskUuids = array_map(function ($fullTaskName) { + return Arr::last(explode('/', $fullTaskName)); + }, $this->deletedTasks); + + Assert::assertTrue( + ! in_array($taskName, $taskUuids), + 'The task [' . $taskName . '] should not have been deleted but it was.' + ); + } + + public function assertDeletedTaskCount(int $count): void + { + Assert::assertCount($count, $this->deletedTasks); + } + + public function assertTaskCreated(Closure $closure): void + { + $count = count(array_filter($this->createdTasks, function ($createdTask) use ($closure) { + return $closure($createdTask['task'], $createdTask['queueName']); + })); + + Assert::assertTrue($count > 0, 'Task was not created.'); + } +} diff --git a/src/CloudTasksConnector.php b/src/CloudTasksConnector.php index a9be76f..79465fe 100644 --- a/src/CloudTasksConnector.php +++ b/src/CloudTasksConnector.php @@ -11,6 +11,18 @@ public function connect(array $config) { Config::validate($config); + // The handler is the URL which Cloud Tasks will call with the job payload. This + // URL of the handler can be manually set through an environment variable, but + // if it is not then we will choose a sensible default (the current app url) + if (empty($config['handler'])) { + // At this point (during service provider boot) the trusted proxy middleware + // has not been set up, and so we are not ready to get the scheme and host + // So we wrap it and get it later, after the middleware has been set up. + $config['handler'] = function () { + return request()->getSchemeAndHttpHost(); + }; + } + return new CloudTasksQueue($config, app(CloudTasksClient::class)); } } diff --git a/src/CloudTasksJob.php b/src/CloudTasksJob.php index ba000a8..c9f4e5a 100644 --- a/src/CloudTasksJob.php +++ b/src/CloudTasksJob.php @@ -2,89 +2,104 @@ namespace Stackkit\LaravelGoogleCloudTasksQueue; -use Google\Cloud\Tasks\V2\CloudTasksClient; use Illuminate\Container\Container; use Illuminate\Queue\Jobs\Job as LaravelJob; use Illuminate\Contracts\Queue\Job as JobContract; +use function Safe\json_encode; class CloudTasksJob extends LaravelJob implements JobContract { - private $job; - private $attempts; - private $maxTries; - public $retryUntil = null; + private array $job; + private ?int $attempts; + private ?int $maxTries; + public ?int $retryUntil = null; /** * @var CloudTasksQueue */ - private $cloudTasksQueue; + public $cloudTasksQueue; - public function __construct($job, CloudTasksQueue $cloudTasksQueue) + public function __construct(array $job, CloudTasksQueue $cloudTasksQueue) { $this->job = $job; $this->container = Container::getInstance(); $this->cloudTasksQueue = $cloudTasksQueue; + /** @var \stdClass $command */ + $command = unserialize($job['data']['command']); + $this->queue = $command->queue; } - public function getJobId() + public function getJobId(): string { return $this->job['uuid']; } - public function getRawBody() + public function uuid(): string + { + return $this->job['uuid']; + } + + public function getRawBody(): string { return json_encode($this->job); } - public function attempts() + public function attempts(): ?int { return $this->attempts; } - public function setAttempts($attempts) + public function setAttempts(int $attempts): void { $this->attempts = $attempts; } - public function setMaxTries($maxTries) + public function setMaxTries(int $maxTries): void { - if ((int) $maxTries === -1) { + if ($maxTries === -1) { $maxTries = 0; } $this->maxTries = $maxTries; } - public function maxTries() + public function maxTries(): ?int { return $this->maxTries; } - public function setQueue($queue) + public function setQueue(string $queue): void { $this->queue = $queue; } - public function setRetryUntil($retryUntil) + public function setRetryUntil(?int $retryUntil): void { $this->retryUntil = $retryUntil; } - public function retryUntil() + public function retryUntil(): ?int { return $this->retryUntil; } // timeoutAt was renamed to retryUntil in 8.x but we still support this. - public function timeoutAt() + public function timeoutAt(): ?int { return $this->retryUntil; } - public function delete() + public function delete(): void { parent::delete(); $this->cloudTasksQueue->delete($this); } + + public function fire(): void + { + $this->attempts++; + + parent::fire(); + } } diff --git a/src/CloudTasksQueue.php b/src/CloudTasksQueue.php index 20530eb..883efd9 100644 --- a/src/CloudTasksQueue.php +++ b/src/CloudTasksQueue.php @@ -10,57 +10,104 @@ use Google\Protobuf\Timestamp; use Illuminate\Contracts\Queue\Queue as QueueContract; use Illuminate\Queue\Queue as LaravelQueue; -use Illuminate\Support\InteractsWithTime; +use Illuminate\Support\Str; +use function Safe\json_encode; +use function Safe\json_decode; class CloudTasksQueue extends LaravelQueue implements QueueContract { - use InteractsWithTime; - + /** + * @var CloudTasksClient + */ private $client; - private $default; - private $config; + + public array $config; public function __construct(array $config, CloudTasksClient $client) { $this->client = $client; - $this->default = $config['queue']; $this->config = $config; } + /** + * Get the size of the queue. + * + * @param string|null $queue + * @return int + */ public function size($queue = null) { - // TODO: Implement size() method. + // 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 + * @return void + */ public function push($job, $data = '', $queue = null) { - return $this->pushToCloudTasks($queue, $this->createPayload( + $this->pushToCloudTasks($queue, $this->createPayload( $job, $this->getQueue($queue), $data )); } + /** + * Push a raw payload onto the queue. + * + * @param string $payload + * @param string|null $queue + * @param array $options + * @return void + */ public function pushRaw($payload, $queue = null, array $options = []) { - return $this->pushToCloudTasks($queue, $payload); + $this->pushToCloudTasks($queue, $payload); } + /** + * 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 + * @return void + */ public function later($delay, $job, $data = '', $queue = null) { - return $this->pushToCloudTasks($queue, $this->createPayload( + $this->pushToCloudTasks($queue, $this->createPayload( $job, $this->getQueue($queue), $data ), $delay); } - protected function pushToCloudTasks($queue, $payload, $delay = 0, $attempts = 0) + /** + * Push a job to Cloud Tasks. + * + * @param string|null $queue + * @param string $payload + * @param \DateTimeInterface|\DateInterval|int $delay + * @return void + */ + protected function pushToCloudTasks($queue, $payload, $delay = 0) { $queue = $this->getQueue($queue); $queueName = $this->client->queueName($this->config['project'], $this->config['location'], $queue); $availableAt = $this->availableAt($delay); $httpRequest = $this->createHttpRequest(); - $httpRequest->setUrl($this->config['handler']); + $httpRequest->setUrl(Config::getHandler($this->config['handler'])); $httpRequest->setHttpMethod(HttpMethod::POST); - $httpRequest->setBody($payload); + $httpRequest->setBody( + // 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. + $this->withUuid($payload) + ); $task = $this->createTask(); $task->setHttpRequest($httpRequest); @@ -73,45 +120,61 @@ protected function pushToCloudTasks($queue, $payload, $delay = 0, $attempts = 0) $task->setScheduleTime(new Timestamp(['seconds' => $availableAt])); } - $this->client->createTask($queueName, $task); + $createdTask = CloudTasksApi::createTask($queueName, $task); + + event((new TaskCreated)->queue($queue)->task($task)); + } + + private function withUuid(string $payload): string + { + /** @var array $decoded */ + $decoded = json_decode($payload, true); + + if (!isset($decoded['uuid'])) { + $decoded['uuid'] = (string) Str::uuid(); + } + + return json_encode($decoded); } + /** + * 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($queue = null) + private function getQueue(?string $queue = null): string { - return $queue ?: $this->default; + return $queue ?: $this->config['queue']; } - /** - * @return HttpRequest - */ - private function createHttpRequest() + private function createHttpRequest(): HttpRequest { return app(HttpRequest::class); } - public function delete(CloudTasksJob $job) + public function delete(CloudTasksJob $job): void { $config = $this->config; + $queue = $job->getQueue() ?: $this->config['queue']; // @todo: make this a helper method somewhere. + $taskName = $this->client->taskName( $config['project'], $config['location'], - $job->getQueue(), - request()->header('X-Cloudtasks-Taskname') + $queue, + (string) request()->headers->get('X-Cloudtasks-Taskname') ); - $this->client->deleteTask($taskName); + CloudTasksApi::deleteTask($taskName); } - /** - * @return Task - */ - private function createTask() + private function createTask(): Task { return app(Task::class); } diff --git a/src/CloudTasksServiceProvider.php b/src/CloudTasksServiceProvider.php index cda6513..07b3c6f 100644 --- a/src/CloudTasksServiceProvider.php +++ b/src/CloudTasksServiceProvider.php @@ -3,35 +3,184 @@ namespace Stackkit\LaravelGoogleCloudTasksQueue; use Google\Cloud\Tasks\V2\CloudTasksClient; -use Illuminate\Queue\QueueManager; -use Illuminate\Routing\Router; +use Illuminate\Queue\Events\JobExceptionOccurred; +use Illuminate\Queue\Events\JobFailed; +use Illuminate\Queue\Events\JobProcessed; +use Illuminate\Queue\Events\JobProcessing; use Illuminate\Support\ServiceProvider as LaravelServiceProvider; +use function Safe\file_get_contents; +use function Safe\json_decode; class CloudTasksServiceProvider extends LaravelServiceProvider { - public function boot(QueueManager $queue, Router $router) + public function boot(): void { $this->registerClient(); - $this->registerConnector($queue); - $this->registerRoutes($router); + $this->registerConnector(); + $this->registerConfig(); + $this->registerViews(); + $this->registerAssets(); + $this->registerMigrations(); + $this->registerRoutes(); + $this->registerDashboard(); } - private function registerClient() + private function registerClient(): void { $this->app->singleton(CloudTasksClient::class, function () { return new CloudTasksClient(); }); + + $this->app->bind('open-id-verificator', OpenIdVerificatorConcrete::class); + $this->app->bind('cloud-tasks-api', CloudTasksApiConcrete::class); } - private function registerConnector(QueueManager $queue) + private function registerConnector(): void { + /** + * @var \Illuminate\Queue\QueueManager $queue + */ + $queue = $this->app['queue']; + $queue->addConnector('cloudtasks', function () { return new CloudTasksConnector; }); } - private function registerRoutes(Router $router) + private function registerConfig(): void + { + $this->publishes([ + __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']); + } + + private function registerMigrations(): void + { + if (CloudTasks::dashboardDisabled()) { + 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'); + }); + } + + private function registerDashboard(): void { - $router->post('handle-task', [TaskHandler::class, 'handle']); + app('events')->listen(TaskCreated::class, function (TaskCreated $event) { + if (CloudTasks::dashboardDisabled()) { + return; + } + + DashboardService::make()->add($event->queue, $event->task); + }); + + app('events')->listen(JobFailed::class, function (JobFailed $event) { + 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 + ); + }); + + app('events')->listen(JobProcessing::class, function (JobProcessing $event) { + if (!CloudTasks::dashboardEnabled()) { + return; + } + + if ($event->job instanceof CloudTasksJob) { + DashboardService::make()->markAsRunning($event->job->uuid()); + } + }); + + app('events')->listen(JobProcessed::class, function (JobProcessed $event) { + if (!CloudTasks::dashboardEnabled()) { + return; + } + + if ($event->job instanceof CloudTasksJob) { + DashboardService::make()->markAsSuccessful($event->job->uuid()); + } + }); + + app('events')->listen(JobExceptionOccurred::class, function (JobExceptionOccurred $event) { + if (!CloudTasks::dashboardEnabled()) { + return; + } + + DashboardService::make()->markAsError($event); + }); + + app('events')->listen(JobFailed::class, function ($event) { + if (!CloudTasks::dashboardEnabled()) { + return; + } + + DashboardService::make()->markAsFailed($event); + }); } } diff --git a/src/Config.php b/src/Config.php index ab82603..4370d15 100644 --- a/src/Config.php +++ b/src/Config.php @@ -2,11 +2,14 @@ namespace Stackkit\LaravelGoogleCloudTasksQueue; +use Closure; use Error; +use Exception; +use Safe\Exceptions\UrlException; class Config { - public static function validate(array $config) + public static function validate(array $config): void { if (empty($config['project'])) { throw new Error(Errors::invalidProject()); @@ -16,12 +19,55 @@ public static function validate(array $config) throw new Error(Errors::invalidLocation()); } - if (empty($config['handler'])) { - throw new Error(Errors::invalidHandler()); - } - if (empty($config['service_account_email'])) { throw new Error(Errors::invalidServiceAccountEmail()); } } + + /** + * @param Closure|string $handler + */ + public static function getHandler($handler): string + { + $handler = value($handler); + + try { + $parse = \Safe\parse_url($handler); + + if (empty($parse['host'])) { + throw new UrlException(); + } + + // A mistake which can unknowingly be made is that the task handler URL is + // (still) set to localhost. That will never work because Cloud Tasks + // should always call a public address / hostname to process tasks. + if (in_array($parse['host'], ['localhost', '127.0.0.1', '::1'])) { + throw new Exception(sprintf( + 'Unable to push task to Cloud Tasks because the handler URL is set to a local host: %s. ' . + 'This does not work because Google is not able to call the given local URL. ' . + 'If you are developing on locally, consider using Ngrok or Expose for Laravel to expose your local ' . + 'application to the internet.', + $handler + )); + } + + // Versions 1.x and 2.x required the full path (e.g. my-app.com/handle-task). In 3.x and beyond + // it is no longer necessary to also include the path and simply setting the handler + // URL is enough. If someone upgrades and forgets we will warn them here. + if (!empty($parse['path'])) { + throw new Exception( + 'Unable to push task to Cloud Tasks because the task handler URL (' . $handler . ') is not ' . + 'compatible. To fix this, please remove \'' . $parse['path'] . '\' from the URL, ' . + 'or copy from here: STACKKIT_CLOUD_TASKS_HANDLER=' . $parse['scheme'] . '://' . $parse['host'] + ); + } + + return $handler . '/handle-task'; + } catch (UrlException $e) { + throw new Exception( + 'Unable to push task to Cloud Tasks because the task handler URL (' . $handler . ') is ' . + 'malformed. Please inspect the URL closely for any mistakes.' + ); + } + } } diff --git a/src/DashboardService.php b/src/DashboardService.php new file mode 100644 index 0000000..49e34d2 --- /dev/null +++ b/src/DashboardService.php @@ -0,0 +1,147 @@ +getHttpRequest(); + + if (! $httpRequest instanceof HttpRequest) { + throw new Exception('Task does not have a HTTP request.'); + } + + return $httpRequest->getBody(); + } + + public function add(string $queue, Task $task): void + { + $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' => $this->getTaskUuid($task), + '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); + + $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(); + } + + 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 new file mode 100644 index 0000000..a92d18a --- /dev/null +++ b/src/Entities/StatRow.php @@ -0,0 +1,21 @@ + $value) { + $object->{$key} = $value; + } + + return $object; + } +} diff --git a/src/Errors.php b/src/Errors.php index c34a345..58c648c 100644 --- a/src/Errors.php +++ b/src/Errors.php @@ -4,22 +4,17 @@ class Errors { - public static function invalidProject() + public static function invalidProject(): string { return 'Google Cloud project not provided. To fix this, set the STACKKIT_CLOUD_TASKS_PROJECT environment variable'; } - public static function invalidLocation() + public static function invalidLocation(): string { return 'Google Cloud Tasks location not provided. To fix this, set the STACKKIT_CLOUD_TASKS_LOCATION environment variable'; } - public static function invalidHandler() - { - return 'Google Cloud Tasks handler not provided. To fix this, set the STACKKIT_CLOUD_TASKS_HANDLER environment variable'; - } - - public static function invalidServiceAccountEmail() + public static function invalidServiceAccountEmail(): string { return 'Google Service Account email address not provided. This is needed to secure the handler so it is only accessible by Google. To fix this, set the STACKKIT_CLOUD_TASKS_SERVICE_EMAIL environment variable'; } diff --git a/src/LogFake.php b/src/LogFake.php new file mode 100644 index 0000000..d9a32c6 --- /dev/null +++ b/src/LogFake.php @@ -0,0 +1,71 @@ +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.'); + } +} diff --git a/src/OpenIdVerificator.php b/src/OpenIdVerificator.php index 0cbcfe8..185186b 100644 --- a/src/OpenIdVerificator.php +++ b/src/OpenIdVerificator.php @@ -1,113 +1,20 @@ guzzle = $guzzle; - $this->rsa = $rsa; - $this->jwt = $jwt; - } - - public function decodeOpenIdToken($openIdToken, $kid, $cache = true) - { - if (!$cache) { - $this->forgetFromCache(); - } - - $publicKey = $this->getPublicKey($kid); - - try { - return $this->jwt->decode($openIdToken, $publicKey, ['RS256']); - } catch (SignatureInvalidException $e) { - if (!$cache) { - throw $e; - } - - return $this->decodeOpenIdToken($openIdToken, $kid, false); - } - } - - public function getPublicKey($kid = null) - { - $v3Certs = Cache::get(self::V3_CERTS); - - if (is_null($v3Certs)) { - $v3Certs = $this->getFreshCertificates(); - Cache::put(self::V3_CERTS, $v3Certs, Carbon::now()->addSeconds($this->maxAge[self::URL_OPENID_CONFIG])); - } - - $cert = $kid ? collect($v3Certs)->firstWhere('kid', '=', $kid) : $v3Certs[0]; - - return $this->extractPublicKeyFromCertificate($cert); - } - - private function getFreshCertificates() - { - $jwksUri = $this->callApiAndReturnValue(self::URL_OPENID_CONFIG, 'jwks_uri'); - - return $this->callApiAndReturnValue($jwksUri, 'keys'); - } - - private function extractPublicKeyFromCertificate($certificate) - { - $modulus = new BigInteger(JWT::urlsafeB64Decode($certificate['n']), 256); - $exponent = new BigInteger(JWT::urlsafeB64Decode($certificate['e']), 256); - - $this->rsa->loadKey(compact('modulus', 'exponent')); - - return $this->rsa->getPublicKey(); - } - - public function getKidFromOpenIdToken($openIdToken) - { - return $this->callApiAndReturnValue(self::URL_TOKEN_INFO . '?id_token=' . $openIdToken, 'kid'); - } - - private function callApiAndReturnValue($url, $value) - { - $response = $this->guzzle->get($url); - - $data = json_decode($response->getBody(), true); - - $maxAge = 0; - foreach ($response->getHeader('Cache-Control') as $line) { - preg_match('/max-age=(\d+)/', $line, $matches); - $maxAge = isset($matches[1]) ? (int) $matches[1] : 0; - } - - $this->maxAge[$url] = $maxAge; - - return Arr::get($data, $value); - } - - public function isCached() + protected static function getFacadeAccessor() { - return Cache::has(self::V3_CERTS); + return 'open-id-verificator'; } - public function forgetFromCache() + public static function fake(): void { - Cache::forget(self::V3_CERTS); + self::swap(new OpenIdVerificatorFake()); } } diff --git a/src/OpenIdVerificatorConcrete.php b/src/OpenIdVerificatorConcrete.php new file mode 100644 index 0000000..01f4c0b --- /dev/null +++ b/src/OpenIdVerificatorConcrete.php @@ -0,0 +1,26 @@ +verify( + $token, + [ + 'audience' => $config['handler'], + 'throwException' => true, + ] + ); + } +} diff --git a/src/OpenIdVerificatorFake.php b/src/OpenIdVerificatorFake.php new file mode 100644 index 0000000..f109207 --- /dev/null +++ b/src/OpenIdVerificatorFake.php @@ -0,0 +1,26 @@ +verify( + $token, + [ + 'audience' => $config['handler'], + 'throwException' => true, + 'certsLocation' => __DIR__ . '/../tests/Support/self-signed-public-key-as-jwk.json', + ] + ); + } +} diff --git a/src/StackkitCloudTask.php b/src/StackkitCloudTask.php new file mode 100644 index 0000000..4af02f1 --- /dev/null +++ b/src/StackkitCloudTask.php @@ -0,0 +1,117 @@ +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/TaskCreated.php b/src/TaskCreated.php new file mode 100644 index 0000000..96f0f45 --- /dev/null +++ b/src/TaskCreated.php @@ -0,0 +1,27 @@ +task = $task; + + return $this; + } + + public function queue(string $queue): self + { + $this->queue = $queue; + + return $this; + } +} diff --git a/src/TaskHandler.php b/src/TaskHandler.php index 7b0c42f..bf5951d 100644 --- a/src/TaskHandler.php +++ b/src/TaskHandler.php @@ -2,20 +2,27 @@ namespace Stackkit\LaravelGoogleCloudTasksQueue; -use Google\Cloud\Tasks\V2\Attempt; use Google\Cloud\Tasks\V2\CloudTasksClient; use Google\Cloud\Tasks\V2\RetryConfig; -use Illuminate\Http\Request; -use Illuminate\Queue\Events\JobFailed; -use Illuminate\Queue\Worker; +use Illuminate\Bus\Queueable; +use Illuminate\Queue\Jobs\Job; use Illuminate\Queue\WorkerOptions; +use stdClass; +use UnexpectedValueException; +use function Safe\json_decode; class TaskHandler { - private $request; - private $publicKey; + /** + * @var array + */ private $config; + /** + * @var CloudTasksClient + */ + private $client; + /** * @var CloudTasksQueue */ @@ -26,18 +33,12 @@ class TaskHandler */ private $retryConfig = null; - public function __construct(CloudTasksClient $client, Request $request, OpenIdVerificator $publicKey) + public function __construct(CloudTasksClient $client) { $this->client = $client; - $this->request = $request; - $this->publicKey = $publicKey; } - /** - * @param $task - * @throws CloudTasksException - */ - public function handle($task = null) + public function handle(?array $task = null): void { $task = $task ?: $this->captureTask(); @@ -45,24 +46,25 @@ public function handle($task = null) $this->setQueue(); - $this->authorizeRequest(); - - $this->listenForEvents(); + OpenIdVerificator::verify(request()->bearerToken(), $this->config); $this->handleTask($task); } - private function loadQueueConnectionConfiguration($task) + private function loadQueueConnectionConfiguration(array $task): void { + /** + * @var stdClass $command + */ $command = unserialize($task['data']['command']); $connection = $command->connection ?? config('queue.default'); $this->config = array_merge( - config("queue.connections.{$connection}"), + (array) config("queue.connections.{$connection}"), ['connection' => $connection] ); } - private function setQueue() + private function setQueue(): void { $this->queue = new CloudTasksQueue($this->config, $this->client); } @@ -70,45 +72,7 @@ private function setQueue() /** * @throws CloudTasksException */ - public function authorizeRequest() - { - if (!$this->request->hasHeader('Authorization')) { - throw new CloudTasksException('Missing [Authorization] header'); - } - - $openIdToken = $this->request->bearerToken(); - $kid = $this->publicKey->getKidFromOpenIdToken($openIdToken); - - $decodedToken = $this->publicKey->decodeOpenIdToken($openIdToken, $kid); - - $this->validateToken($decodedToken); - } - - /** - * https://developers.google.com/identity/protocols/oauth2/openid-connect#validatinganidtoken - * - * @param $openIdToken - * @throws CloudTasksException - */ - protected function validateToken($openIdToken) - { - if (!in_array($openIdToken->iss, ['https://accounts.google.com', 'accounts.google.com'])) { - throw new CloudTasksException('The given OpenID token is not valid'); - } - - if ($openIdToken->aud != $this->config['handler']) { - throw new CloudTasksException('The given OpenID token is not valid'); - } - - if ($openIdToken->exp < time()) { - throw new CloudTasksException('The given OpenID token has expired'); - } - } - - /** - * @throws CloudTasksException - */ - private function captureTask() + private function captureTask(): array { $input = (string) (request()->getContent()); @@ -118,93 +82,44 @@ private function captureTask() $task = json_decode($input, true); - if (is_null($task)) { + if (!is_array($task)) { throw new CloudTasksException('Could not decode incoming task'); } return $task; } - private function listenForEvents() - { - app('events')->listen(JobFailed::class, function ($event) { - app('queue.failer')->log( - $this->config['connection'], $event->job->getQueue(), - $event->job->getRawBody(), $event->exception - ); - }); - } - - /** - * @param $task - * @throws CloudTasksException - */ - private function handleTask($task) + private function handleTask(array $task): void { $job = new CloudTasksJob($task, $this->queue); - $this->loadQueueRetryConfig(); + $this->loadQueueRetryConfig($job); - $job->setAttempts(request()->header('X-CloudTasks-TaskRetryCount') + 1); - $job->setQueue(request()->header('X-Cloudtasks-Queuename')); + $job->setAttempts((int) request()->header('X-CloudTasks-TaskExecutionCount')); $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() > 1) { - $job->setRetryUntil($this->getRetryUntilTimestamp($job)); - } + if ($job->attempts() > 0) { + $taskName = request()->header('X-Cloudtasks-Taskname'); - $worker = $this->getQueueWorker(); + if (!is_string($taskName)) { + throw new UnexpectedValueException('Expected task name to be a string.'); + } - $worker->process($this->config['connection'], $job, new WorkerOptions()); - } - - private function loadQueueRetryConfig() - { - $queueName = $this->client->queueName( - $this->config['project'], - $this->config['location'], - request()->header('X-Cloudtasks-Queuename') - ); + $job->setRetryUntil(CloudTasksApi::getRetryUntilTimestamp($taskName)); + } - $this->retryConfig = $this->client->getQueue($queueName)->getRetryConfig(); + app('queue.worker')->process($this->config['connection'], $job, new WorkerOptions()); } - private function getRetryUntilTimestamp(CloudTasksJob $job) + private function loadQueueRetryConfig(CloudTasksJob $job): void { - $task = $this->client->getTask( - $this->client->taskName( - $this->config['project'], - $this->config['location'], - $job->getQueue(), - request()->header('X-Cloudtasks-Taskname') - ) - ); - - $attempt = $task->getFirstAttempt(); - - if (!$attempt instanceof Attempt) { - return null; - } - - if (! $this->retryConfig->hasMaxRetryDuration()) { - return null; - } + $queue = $job->getQueue() ?: $this->config['queue']; - $maxDurationInSeconds = $this->retryConfig->getMaxRetryDuration()->getSeconds(); + $queueName = $this->client->queueName($this->config['project'], $this->config['location'], $queue); - $firstAttemptTimestamp = $attempt->getDispatchTime()->toDateTime()->getTimestamp(); - - return $firstAttemptTimestamp + $maxDurationInSeconds; - } - - /** - * @return Worker - */ - private function getQueueWorker() - { - return app('queue.worker'); + $this->retryConfig = CloudTasksApi::getRetryConfig($queueName); } } diff --git a/src/TaskMetadata.php b/src/TaskMetadata.php new file mode 100644 index 0000000..963bd37 --- /dev/null +++ b/src/TaskMetadata.php @@ -0,0 +1,51 @@ + $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/tests/CloudTasksApiTest.php b/tests/CloudTasksApiTest.php new file mode 100644 index 0000000..19413f4 --- /dev/null +++ b/tests/CloudTasksApiTest.php @@ -0,0 +1,190 @@ +fail('Missing [' . $env . '] environment variable.'); + } + } + + $this->setConfigValue('project', env('CI_CLOUD_TASKS_PROJECT_ID')); + $this->setConfigValue('queue', env('CI_CLOUD_TASKS_QUEUE')); + $this->setConfigValue('location', env('CI_CLOUD_TASKS_LOCATION')); + $this->setConfigValue('service_account_email', env('CI_CLOUD_TASKS_SERVICE_ACCOUNT_EMAIL')); + + $this->client = new CloudTasksClient(); + + } + + /** + * @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 + */ + public function test_create_task() + { + // Arrange + $httpRequest = new HttpRequest(); + $httpRequest->setHttpMethod(HttpMethod::GET); + $httpRequest->setUrl('https://example.com'); + + $cloudTask = new Task(); + $cloudTask->setHttpRequest($httpRequest); + + // Act + $task = CloudTasksApi::createTask( + $this->client->queueName( + env('CI_CLOUD_TASKS_PROJECT_ID'), + env('CI_CLOUD_TASKS_LOCATION'), + env('CI_CLOUD_TASKS_QUEUE') + ), + $cloudTask + ); + $taskName = $task->getName(); + + // Assert + $this->assertMatchesRegularExpression( + '/projects\/' . env('CI_CLOUD_TASKS_PROJECT_ID') . '\/locations\/' . env('CI_CLOUD_TASKS_LOCATION') . '\/queues\/' . env('CI_CLOUD_TASKS_QUEUE') . '\/tasks\/\d+$/', + $taskName + ); + } + + /** + * @test + */ + public function test_delete_task_on_non_existing_task() + { + // Assert + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Requested entity was not found.'); + + // Act + CloudTasksApi::deleteTask( + $this->client->taskName( + env('CI_CLOUD_TASKS_PROJECT_ID'), + env('CI_CLOUD_TASKS_LOCATION'), + env('CI_CLOUD_TASKS_QUEUE'), + 'non-existing-id' + ), + ); + + } + + /** + * @test + */ + public function test_delete_task() + { + // Arrange + $httpRequest = new HttpRequest(); + $httpRequest->setHttpMethod(HttpMethod::GET); + $httpRequest->setUrl('https://example.com'); + + $cloudTask = new Task(); + $cloudTask->setHttpRequest($httpRequest); + $cloudTask->setScheduleTime(new Timestamp(['seconds' => time() + 10])); + + $task = CloudTasksApi::createTask( + $this->client->queueName( + env('CI_CLOUD_TASKS_PROJECT_ID'), + env('CI_CLOUD_TASKS_LOCATION'), + env('CI_CLOUD_TASKS_QUEUE') + ), + $cloudTask + ); + + // Act + $fresh = CloudTasksApi::getTask($task->getName()); + $this->assertInstanceOf(Task::class, $fresh); + + CloudTasksApi::deleteTask($task->getName()); + + $this->expectException(ApiException::class); + $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->getName()); + $this->assertSame($expected, $actual); + } +} diff --git a/tests/CloudTasksDashboardTest.php b/tests/CloudTasksDashboardTest.php new file mode 100644 index 0000000..4ca8578 --- /dev/null +++ b/tests/CloudTasksDashboardTest.php @@ -0,0 +1,548 @@ +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 + */ + public function when_a_job_is_dispatched_it_will_be_added_to_the_dashboard() + { + // Arrange + 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, + ]); + $payload = \Safe\json_decode($task->getMetadata()['payload'], true); + $this->assertSame($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 + */ + public function when_a_job_is_scheduled_it_will_be_added_as_such() + { + // Arrange + 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 + */ + public function when_a_job_is_running_it_will_be_updated_in_the_dashboard() + { + // Arrange + \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 + */ + public function when_a_job_is_successful_it_will_be_updated_in_the_dashboard() + { + // Arrange + \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 + */ + public function when_a_job_errors_it_will_be_updated_in_the_dashboard() + { + // Arrange + \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 + */ + public function when_a_job_fails_it_will_be_updated_in_the_dashboard() + { + // Arrange + \Illuminate\Support\Carbon::setTestNow(now()); + CloudTasksApi::fake(); + OpenIdVerificator::fake(); + CloudTasksApi::partialMock()->shouldReceive('getRetryConfig')->andReturn( + (new RetryConfig())->setMaxAttempts(3) + ); + + $job = $this->dispatch(new FailingJob()); + $job->run(); + $job->run(); + $job->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 + */ + public function test_publish() + { + // Arrange + config()->set('cloud-tasks.dashboard.enabled', true); + + // Act & Assert + $expectedPublishBase = dirname(__DIR__); + + $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/ConfigTest.php b/tests/ConfigTest.php index 0632bed..317a801 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -31,7 +31,7 @@ public function location_is_required() } /** @test */ - public function handler_is_required() + public function there_is_a_sensible_handler_default() { $this->setConfigValue('handler', ''); diff --git a/tests/GooglePublicKeyTest.php b/tests/GooglePublicKeyTest.php deleted file mode 100644 index f8e6b4a..0000000 --- a/tests/GooglePublicKeyTest.php +++ /dev/null @@ -1,89 +0,0 @@ -guzzle = Mockery::mock(new Client()); - - $this->publicKey = new OpenIdVerificator($this->guzzle, new RSA(), new JWT()); - } - - /** @test */ - public function it_fetches_the_gcloud_public_key() - { - $this->assertStringContainsString('-----BEGIN PUBLIC KEY-----', $this->publicKey->getPublicKey()); - } - - /** @test */ - public function it_caches_the_gcloud_public_key() - { - $this->assertFalse($this->publicKey->isCached()); - - $this->publicKey->getPublicKey(); - - $this->assertTrue($this->publicKey->isCached()); - } - - /** @test */ - public function it_will_return_the_cached_gcloud_public_key() - { - Event::fake(); - - $this->publicKey->getPublicKey(); - - Event::assertDispatched(CacheMissed::class); - Event::assertDispatched(KeyWritten::class); - - $this->publicKey->getPublicKey(); - - Event::assertDispatched(CacheHit::class); - - $this->guzzle->shouldHaveReceived('get')->twice(); - } - - /** @test */ - public function public_key_is_cached_according_to_cache_control_headers() - { - Event::fake(); - - $this->publicKey->getPublicKey(); - - $this->publicKey->getPublicKey(); - - Carbon::setTestNow(Carbon::now()->addSeconds(3600)); - $this->publicKey->getPublicKey(); - - Carbon::setTestNow(Carbon::now()->addSeconds(5)); - $this->publicKey->getPublicKey(); - - Event::assertDispatched(CacheMissed::class, 2); - Event::assertDispatched(KeyWritten::class, 2); - - } -} diff --git a/tests/QueueTest.php b/tests/QueueTest.php index f8ec4b0..88f9fcc 100644 --- a/tests/QueueTest.php +++ b/tests/QueueTest.php @@ -1,127 +1,119 @@ client = Mockery::mock(CloudTasksClient::class)->makePartial(); - $this->http = Mockery::mock(new HttpRequest)->makePartial(); - $this->task = Mockery::mock(new Task); - - $this->app->instance(CloudTasksClient::class, $this->client); - $this->app->instance(HttpRequest::class, $this->http); - $this->app->instance(Task::class, $this->task); - - // ensure we don't actually call the Google API - $this->client->shouldReceive('createTask')->andReturnNull(); - } - - /** @test */ + /** + * @test + */ public function a_http_request_with_the_handler_url_is_made() { - SimpleJob::dispatch(); + // Arrange + CloudTasksApi::fake(); + + // Act + $this->dispatch(new SimpleJob()); - $this->http - ->shouldHaveReceived('setUrl') - ->with('https://localhost/my-handler') - ->once(); + // Assert + CloudTasksApi::assertTaskCreated(function (Task $task): bool { + return $task->getHttpRequest()->getUrl() === 'http://docker.for.mac.localhost:8080'; + }); } - /** @test */ + /** + * @test + */ public function it_posts_to_the_handler() { - SimpleJob::dispatch(); + // Arrange + CloudTasksApi::fake(); - $this->http->shouldHaveReceived('setHttpMethod')->with(HttpMethod::POST)->once(); + // Act + $this->dispatch(new SimpleJob()); + + // Assert + CloudTasksApi::assertTaskCreated(function (Task $task): bool { + return $task->getHttpRequest()->getHttpMethod() === HttpMethod::POST; + }); } - /** @test */ + /** + * @test + */ public function it_posts_the_serialized_job_payload_to_the_handler() { - $job = new SimpleJob(); - $job->dispatch(); - - $this->http->shouldHaveReceived('setBody')->with(Mockery::on(function ($payload) use ($job) { - $decoded = json_decode($payload, true); + // Arrange + CloudTasksApi::fake(); - if ($decoded['displayName'] != 'Tests\Support\SimpleJob') { - return false; - } + // Act + $this->dispatch($job = new SimpleJob()); - if ($decoded['job'] != 'Illuminate\Queue\CallQueuedHandler@call') { - return false; - } + // Assert + CloudTasksApi::assertTaskCreated(function (Task $task) use ($job): bool { + $decoded = json_decode($task->getHttpRequest()->getBody(), true); - if ($decoded['data']['commandName'] != 'Tests\Support\SimpleJob') { - return false; - } - - if ($decoded['data']['command'] != serialize($job)) { - return false; - } - - return true; - })); - } - - /** @test */ - public function it_creates_a_task_containing_the_http_request() - { - $this->task->shouldReceive('setHttpRequest')->once()->with($this->http); - - SimpleJob::dispatch(); + return $decoded['displayName'] === SimpleJob::class + && $decoded['job'] === 'Illuminate\Queue\CallQueuedHandler@call' + && $decoded['data']['command'] === serialize($job); + }); } - /** @test */ + /** + * @test + */ public function it_will_set_the_scheduled_time_when_dispatching_later() { - $inFiveMinutes = Carbon::now()->addMinutes(5); + // Arrange + CloudTasksApi::fake(); - SimpleJob::dispatch()->delay($inFiveMinutes); + // Act + $inFiveMinutes = now()->addMinutes(5); + $this->dispatch((new SimpleJob())->delay($inFiveMinutes)); - $this->task->shouldHaveReceived('setScheduleTime')->once()->with(Mockery::on(function (Timestamp $timestamp) use ($inFiveMinutes) { - return $timestamp->getSeconds() === $inFiveMinutes->timestamp; - })); + // Assert + CloudTasksApi::assertTaskCreated(function (Task $task) use ($inFiveMinutes): bool { + return $task->getScheduleTime()->getSeconds() === $inFiveMinutes->timestamp; + }); } - /** @test */ + /** + * @test + */ public function it_posts_the_task_the_correct_queue() { - SimpleJob::dispatch(); - - $this->client - ->shouldHaveReceived('createTask') - ->withArgs(function ($queueName) { - return $queueName === 'projects/test-project/locations/europe-west6/queues/test-queue'; - }); - } - - /** @test */ - public function it_posts_the_correct_task_the_queue() - { - SimpleJob::dispatch(); - - $this->client - ->shouldHaveReceived('createTask') - ->withArgs(function ($queueName, $task) { - return $task === $this->task; - }); + // Arrange + CloudTasksApi::fake(); + + // Act + $this->dispatch((new SimpleJob())); + $this->dispatch((new FailingJob())->onQueue('my-special-queue')); + + // Assert + CloudTasksApi::assertTaskCreated(function (Task $task, string $queueName): bool { + $decoded = json_decode($task->getHttpRequest()->getBody(), true); + $command = unserialize($decoded['data']['command']); + + return $decoded['displayName'] === SimpleJob::class + && $command->queue === null + && $queueName === 'projects/my-test-project/locations/europe-west6/queues/barbequeue'; + }); + + CloudTasksApi::assertTaskCreated(function (Task $task, string $queueName): bool { + $decoded = json_decode($task->getHttpRequest()->getBody(), true); + $command = unserialize($decoded['data']['command']); + + return $decoded['displayName'] === FailingJob::class + && $command->queue === 'my-special-queue' + && $queueName === 'projects/my-test-project/locations/europe-west6/queues/my-special-queue'; + }); } } diff --git a/tests/Support/SimpleJob.php b/tests/Support/SimpleJob.php index 4a2c8cf..34e1912 100644 --- a/tests/Support/SimpleJob.php +++ b/tests/Support/SimpleJob.php @@ -30,6 +30,6 @@ public function __construct() */ public function handle() { - Mail::to('johndoe@example.com')->send(new TestMailable()); + logger('SimpleJob:success'); } } diff --git a/tests/Support/TestMailable.php b/tests/Support/TestMailable.php deleted file mode 100644 index cab9d6b..0000000 --- a/tests/Support/TestMailable.php +++ /dev/null @@ -1,10 +0,0 @@ -request = request(); - - // We don't have a valid token to test with, so for now act as if its always valid - $this->app->instance(JWT::class, ($this->jwt = Mockery::mock(new JWT())->byDefault()->makePartial())); - $this->jwt->shouldReceive('decode')->andReturn((object) [ - 'iss' => 'accounts.google.com', - 'aud' => 'https://localhost/my-handler', - 'exp' => time() + 10 - ])->byDefault(); - - // Ensure we don't fetch the Google public key each test... - $googlePublicKey = Mockery::mock(app(OpenIdVerificator::class)); - $googlePublicKey->shouldReceive('getPublicKey')->andReturnNull(); - $googlePublicKey->shouldReceive('getKidFromOpenIdToken')->andReturnNull(); - - $cloudTasksClient = Mockery::mock(new CloudTasksClient())->byDefault(); - $this->cloudTasksClient = $cloudTasksClient; - - // Ensure we don't fetch the Queue name and attempts each test... - $cloudTasksClient->shouldReceive('queueName')->andReturn('my-queue'); - $cloudTasksClient->shouldReceive('getQueue') - ->byDefault() - ->andReturn(new class { - public function getRetryConfig() { - return new class { - public function getMaxAttempts() { - return 3; - } - - public function hasMaxRetryDuration() { - return true; - } - - public function getMaxRetryDuration() { - return new class { - public function getSeconds() { - return 30; - } - }; - } - }; - } - }); - $cloudTasksClient->shouldReceive('taskName')->andReturn('FakeTaskName'); - $cloudTasksClient->shouldReceive('getTask')->byDefault()->andReturn(new class { - public function getFirstAttempt() { - return null; - } - }); - - $cloudTasksClient->shouldReceive('deleteTask')->andReturnNull(); - - $this->handler = new TaskHandler( - $cloudTasksClient, - request(), - $googlePublicKey - ); - - $this->request->headers->add(['Authorization' => 'Bearer 123']); + CloudTasksApi::fake(); } - /** @test */ - public function it_needs_an_authorization_header() + /** + * @test + */ + public function the_task_handler_needs_an_open_id_token() { - $this->request->headers->remove('Authorization'); - + // Assert $this->expectException(CloudTasksException::class); $this->expectExceptionMessage('Missing [Authorization] header'); - $this->handler->handle($this->simpleJob()); + // Act + $this->dispatch(new SimpleJob())->runWithoutExceptionHandler(); } - /** @test */ - public function it_will_validate_the_token_iss() + /** + * @test + */ + public function the_task_handler_throws_an_exception_if_the_id_token_is_invalid() { - $this->jwt->shouldReceive('decode')->andReturn((object) [ - 'iss' => 'test', - ]); - $this->expectException(CloudTasksException::class); - $this->expectExceptionMessage('The given OpenID token is not valid'); - $this->handler->handle($this->simpleJob()); - } + // Arrange + request()->headers->set('Authorization', 'Bearer my-invalid-token'); - /** @test */ - public function it_will_validate_the_token_handler() - { - $this->jwt->shouldReceive('decode')->andReturn((object) [ - 'iss' => 'accounts.google.com', - 'aud' => '__incorrect_aud__' - ]); - $this->expectException(CloudTasksException::class); - $this->expectExceptionMessage('The given OpenID token is not valid'); - $this->handler->handle($this->simpleJob()); - } + // Assert + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Wrong number of segments'); - /** @test */ - public function it_will_validate_the_token_expiration() - { - $this->jwt->shouldReceive('decode')->andReturn((object) [ - 'iss' => 'accounts.google.com', - 'aud' => 'https://localhost/my-handler', - 'exp' => time() - 1 - ]); - $this->expectException(CloudTasksException::class); - $this->expectExceptionMessage('The given OpenID token has expired'); - $this->handler->handle($this->simpleJob()); + // Act + $this->dispatch(new SimpleJob())->runWithoutExceptionHandler(); } - /** @test */ - public function in_case_of_signature_verification_failure_it_will_retry() + /** + * @test + */ + public function it_validates_the_token_expiration() { - Event::fake(); + // Arrange + OpenIdVerificator::fake(); + $this->addIdTokenToHeader(function (array $base) { + return ['exp' => time() - 5] + $base; + }); - $this->jwt->shouldReceive('decode')->andThrow(SignatureInvalidException::class); + // Assert + $this->expectException(ExpiredException::class); + $this->expectExceptionMessage('Expired token'); - $this->expectException(SignatureInvalidException::class); + // 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; + }); - $this->handler->handle($this->simpleJob()); + // Assert + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Audience does not match'); - Event::assertDispatched(CacheHit::class); - Event::assertDispatched(KeyWritten::class); + // Act + $this->dispatch(new SimpleJob())->runWithoutExceptionHandler(); } - /** @test */ - public function it_runs_the_incoming_job() + /** + * @test + */ + public function it_can_run_a_task() { - Mail::fake(); - - request()->headers->add(['Authorization' => 'Bearer 123']); + // Arrange + OpenIdVerificator::fake(); + Log::swap(new LogFake()); + Event::fake([JobProcessing::class, JobProcessed::class]); - $this->handler->handle($this->simpleJob()); + // Act + $this->dispatch(new SimpleJob())->runWithoutExceptionHandler(); - Mail::assertSent(TestMailable::class); + // Assert + Log::assertLogged('SimpleJob:success'); } - /** @test */ + /** + * @test + */ public function after_max_attempts_it_will_log_to_failed_table() { - $this->request->headers->add(['X-Cloudtasks-Queuename' => 'my-queue']); + // Arrange + OpenIdVerificator::fake(); + CloudTasksApi::partialMock()->shouldReceive('getRetryConfig')->andReturn( + (new RetryConfig())->setMaxAttempts(3) + ); + $job = $this->dispatch(new FailingJob()); - $this->request->headers->add(['X-CloudTasks-TaskRetryCount' => 1]); - try { - $this->handler->handle($this->failingJob()); - } catch (\Throwable $e) { - // - } + // Act & Assert + $this->assertDatabaseCount('failed_jobs', 0); - $this->assertCount(0, DB::table('failed_jobs')->get()); + $job->run(); + $this->assertDatabaseCount('failed_jobs', 0); - $this->request->headers->add(['X-CloudTasks-TaskRetryCount' => 2]); - try { - $this->handler->handle($this->failingJob()); - } catch (\Throwable $e) { - // - } + $job->run(); + $this->assertDatabaseCount('failed_jobs', 0); - $this->assertDatabaseHas('failed_jobs', [ - 'connection' => 'my-cloudtasks-connection', - 'queue' => 'my-queue', - 'payload' => rtrim($this->failingJobPayload()), - ]); + $job->run(); + $this->assertDatabaseCount('failed_jobs', 1); } - /** @test */ + /** + * @test + */ public function after_max_attempts_it_will_delete_the_task() { - $this->request->headers->add(['X-CloudTasks-TaskRetryCount' => 2]); + // Arrange + OpenIdVerificator::fake(); - rescue(function () { - $this->handler->handle($this->failingJob()); - }); + CloudTasksApi::partialMock()->shouldReceive('getRetryConfig')->andReturn( + (new RetryConfig())->setMaxAttempts(3) + ); - $this->cloudTasksClient->shouldHaveReceived('deleteTask')->once(); + $job = $this->dispatch(new FailingJob()); + + // Act & Assert + $job->run(); + CloudTasksApi::assertDeletedTaskCount(0); + CloudTasksApi::assertTaskNotDeleted($job->task->getName()); + $this->assertDatabaseCount('failed_jobs', 0); + + $job->run(); + CloudTasksApi::assertDeletedTaskCount(0); + CloudTasksApi::assertTaskNotDeleted($job->task->getName()); + $this->assertDatabaseCount('failed_jobs', 0); + + $job->run(); + CloudTasksApi::assertDeletedTaskCount(1); + CloudTasksApi::assertTaskDeleted($job->task->getName()); + $this->assertDatabaseCount('failed_jobs', 1); } - /** @test */ - public function after_max_retry_until_it_will_delete_the_task() + /** + * @test + */ + public function after_max_retry_until_it_will_log_to_failed_table_and_delete_the_task() { - $this->request->headers->add(['X-CloudTasks-TaskRetryCount' => 1]); - - $this->cloudTasksClient - ->shouldReceive('getTask') - ->byDefault() - ->andReturn(new class { - public function getFirstAttempt() { - return (new Attempt()) - ->setDispatchTime(new Timestamp([ - 'seconds' => time() - 29, - ])); - } - }); - - rescue(function () { - $this->handler->handle($this->failingJob()); - }); + // 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->cloudTasksClient->shouldNotHaveReceived('deleteTask'); + // Act + $job->run(); - $this->cloudTasksClient->shouldReceive('getTask') - ->andReturn(new class { - public function getFirstAttempt() { - return (new Attempt()) - ->setDispatchTime(new Timestamp([ - 'seconds' => time() - 30, - ])); - } - }); + // Assert + CloudTasksApi::assertDeletedTaskCount(0); + CloudTasksApi::assertTaskNotDeleted($job->task->getName()); + $this->assertDatabaseCount('failed_jobs', 0); - rescue(function () { - $this->handler->handle($this->failingJob()); - }); + // Act + CloudTasksApi::partialMock()->shouldReceive('getRetryUntilTimestamp')->andReturn(1); + $job->run(); - $this->cloudTasksClient->shouldHaveReceived('deleteTask')->once(); + // Assert + CloudTasksApi::assertDeletedTaskCount(1); + CloudTasksApi::assertTaskDeleted($job->task->getName()); + $this->assertDatabaseCount('failed_jobs', 1); } - /** @test */ + /** + * @test + */ public function test_unlimited_max_attempts() { - $this->cloudTasksClient->shouldReceive('getQueue') - ->byDefault() - ->andReturn(new class { - public function getRetryConfig() { - return new class { - public function getMaxAttempts() { - return -1; - } - - public function hasMaxRetryDuration() { - return false; - } - }; - } - }); - - for ($i = 0; $i < 50; $i++) { - $this->request->headers->add(['X-CloudTasks-TaskRetryCount' => $i]); - - rescue(function () { - $this->handler->handle($this->failingJob()); - }); - - $this->cloudTasksClient->shouldNotHaveReceived('deleteTask'); + // 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) + ); + + // Act + $job = $this->dispatch(new FailingJob()); + foreach (range(1, 50) as $attempt) { + $job->run(); + CloudTasksApi::assertDeletedTaskCount(0); + CloudTasksApi::assertTaskNotDeleted($job->task->getName()); + $this->assertDatabaseCount('failed_jobs', 0); } } /** * @test - * @dataProvider whenIsJobFailingProvider */ - public function job_max_attempts_is_ignored_if_has_retry_until($example) + 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 - $this->request->headers->add(['X-CloudTasks-TaskRetryCount' => $example['retryCount']]); + OpenIdVerificator::fake(); + CloudTasksApi::partialMock()->shouldReceive('getRetryConfig')->andReturn( + (new RetryConfig()) + ->setMaxAttempts(3) + ->setMaxRetryDuration(new Duration(['seconds' => 3])) + ); + CloudTasksApi::partialMock()->shouldReceive('getRetryUntilTimestamp')->andReturn(time() + 10)->byDefault(); - if (array_key_exists('travelSeconds', $example)) { - Carbon::setTestNow(Carbon::now()->addSeconds($example['travelSeconds'])); - } + $job = $this->dispatch(new FailingJob()); - $this->cloudTasksClient->shouldReceive('getQueue') - ->byDefault() - ->andReturn(new class() { - public function getRetryConfig() { - return new class { - public function getMaxAttempts() { - return 3; - } - - public function hasMaxRetryDuration() { - return true; - } - - public function getMaxRetryDuration() { - return new class { - public function getSeconds() { - return 30; - } - }; - } - }; - } - }); - - $this->cloudTasksClient - ->shouldReceive('getTask') - ->byDefault() - ->andReturn(new class { - public function getFirstAttempt() { - return (new Attempt()) - ->setDispatchTime(new Timestamp([ - 'seconds' => time(), - ])); - } - }); - - rescue(function () { - $this->handler->handle($this->failingJob()); - }); + // Act & Assert + $job->run(); + $job->run(); - if ($example['shouldHaveFailed']) { - $this->cloudTasksClient->shouldHaveReceived('deleteTask'); - } else { - $this->cloudTasksClient->shouldNotHaveReceived('deleteTask'); - } - } + # After 2 attempts both Laravel versions should report the same: 2 errors and 0 failures. + $task = StackkitCloudTask::whereTaskUuid($job->payload['uuid'])->firstOrFail(); + $this->assertEquals(2, $task->getNumberOfAttempts()); + $this->assertEquals('error', $task->status); - public function whenIsJobFailingProvider() - { - $this->createApplication(); - - // 8.x behavior: if retryUntil, only check that. - // 6.x behavior: if retryUntil, check that, otherwise check maxAttempts - - // max retry count is 3 - // max retryUntil is 30 seconds - - if (version_compare(app()->version(), '8.0.0', '>=')) { - return [ - [ - [ - 'retryCount' => 1, - 'shouldHaveFailed' => false, - ], - ], - [ - [ - 'retryCount' => 2, - 'shouldHaveFailed' => false, - ], - ], - [ - [ - 'retryCount' => 1, - 'travelSeconds' => 29, - 'shouldHaveFailed' => false, - ], - ], - [ - [ - 'retryCount' => 1, - 'travelSeconds' => 31, - 'shouldHaveFailed' => true, - ], - ], - [ - [ - 'retryCount' => 1, - 'travelSeconds' => 32, - 'shouldHaveFailed' => true, - ], - ], - [ - [ - 'retryCount' => 1, - 'travelSeconds' => 31, - 'shouldHaveFailed' => true, - ], - ], - ]; - } + $job->run(); - return [ - [ - [ - 'retryCount' => 1, - 'shouldHaveFailed' => false, - ], - ], - [ - [ - 'retryCount' => 2, - 'shouldHaveFailed' => true, - ], - ], - [ - [ - 'retryCount' => 1, - 'travelSeconds' => 29, - 'shouldHaveFailed' => false, - ], - ], - [ - [ - 'retryCount' => 1, - 'travelSeconds' => 31, - 'shouldHaveFailed' => true, - ], - ], - [ - [ - 'retryCount' => 1, - 'travelSeconds' => 32, - 'shouldHaveFailed' => true, - ], - ], - [ - [ - 'retryCount' => 1, - 'travelSeconds' => 32, - 'shouldHaveFailed' => true, - ], - ], - ]; - } + # 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. - private function simpleJob() - { - return json_decode(file_get_contents(__DIR__ . '/Support/test-job-payload.json'), true); - } + if (version_compare(app()->version(), '8.0.0', '<')) { + $this->assertEquals('failed', $task->fresh()->status); + return; + } else { + $this->assertEquals('error', $task->fresh()->status); + } - private function failingJobPayload() - { - return file_get_contents(__DIR__ . '/Support/failing-job-payload.json'); - } + CloudTasksApi::shouldReceive('getRetryUntilTimestamp')->andReturn(time() - 1); + $job->run(); - private function failingJob() - { - return json_decode($this->failingJobPayload(), true); + $this->assertEquals('failed', $task->fresh()->status); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index f0fdf78..bcd27a7 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,31 +2,36 @@ namespace Tests; +use Closure; +use Firebase\JWT\JWT; +use Google\ApiCore\ApiException; +use Google\Cloud\Tasks\V2\Queue; +use Google\Cloud\Tasks\V2\RetryConfig; +use Google\Cloud\Tasks\V2\Task; +use Illuminate\Foundation\Testing\DatabaseTransactions; +use Illuminate\Support\Facades\DB; +use Google\Cloud\Tasks\V2\CloudTasksClient; +use Illuminate\Support\Facades\Event; +use Mockery; +use Stackkit\LaravelGoogleCloudTasksQueue\TaskCreated; +use Stackkit\LaravelGoogleCloudTasksQueue\TaskHandler; + class TestCase extends \Orchestra\Testbench\TestCase { - public static $migrated = false; + use DatabaseTransactions; + + /** + * @var \Mockery\Mock|CloudTasksClient $client + */ + public $client; protected function setUp(): void { parent::setUp(); - // There is probably a more sane way to do this - if (!static::$migrated) { - if (file_exists(database_path('database.sqlite'))) { - unlink(database_path('database.sqlite')); - } - - touch(database_path('database.sqlite')); - - foreach(glob(database_path('migrations/*.php')) as $file) { - unlink($file); - } - - $this->artisan('queue:failed-table'); - $this->artisan('migrate'); + $this->withFactories(__DIR__ . '/../factories'); - static::$migrated = true; - } + $this->defaultHeaders['Authorization'] = 'Bearer ' . encrypt(time() + 10); } /** @@ -46,6 +51,17 @@ protected function getPackageProviders($app) ]; } + /** + * Define database migrations. + * + * @return void + */ + protected function defineDatabaseMigrations() + { + $this->loadMigrationsFrom(__DIR__ . '/../migrations'); + $this->loadMigrationsFrom(__DIR__ . '/../vendor/orchestra/testbench-core/laravel/migrations'); + } + /** * Define environment setup. * @@ -58,20 +74,144 @@ protected function getEnvironmentSetUp($app) 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'), + 'host' => '127.0.0.1', + 'port' => $port, + 'database' => 'cloudtasks', + 'username' => 'cloudtasks', + 'password' => 'cloudtasks', + 'prefix' => '', + ]); + $app['config']->set('cache.default', 'file'); $app['config']->set('queue.default', 'my-cloudtasks-connection'); $app['config']->set('queue.connections.my-cloudtasks-connection', [ 'driver' => 'cloudtasks', - 'queue' => 'test-queue', - 'project' => 'test-project', + 'queue' => 'barbequeue', + 'project' => 'my-test-project', 'location' => 'europe-west6', - 'handler' => 'https://localhost/my-handler', + 'handler' => env('CLOUD_TASKS_HANDLER', 'http://docker.for.mac.localhost:8080'), 'service_account_email' => 'info@stackkit.io', ]); + $app['config']->set('queue.failed.driver', 'database-uuids'); + $app['config']->set('queue.failed.database', 'testbench'); + + $disableDashboardPrefix = 'when_dashboard_is_disabled'; + + if (substr($this->getName(), 0, strlen($disableDashboardPrefix)) === $disableDashboardPrefix) { + $app['config']->set('cloud-tasks.dashboard.enabled', false); + } else { + $app['config']->set('cloud-tasks.dashboard.enabled', true); + } } protected function setConfigValue($key, $value) { $this->app['config']->set('queue.connections.my-cloudtasks-connection.' . $key, $value); } + + public function dispatch($job) + { + $payload = null; + $task = null; + + Event::listen(TaskCreated::class, function (TaskCreated $event) use (&$payload, &$task) { + $payload = json_decode($event->task->getHttpRequest()->getBody(), true); + $task = $event->task; + + request()->headers->set('X-Cloudtasks-Taskname', $task->getName()); + }); + + dispatch($job); + + return new class($payload, $task) { + public array $payload = []; + public Task $task; + + public function __construct(array $payload, Task $task) + { + $this->payload = $payload; + $this->task = $task; + } + + public function run(): void + { + rescue(function (): void { + app(TaskHandler::class)->handle($this->payload); + }); + + $taskExecutionCount = request()->header('X-CloudTasks-TaskExecutionCount', 0); + request()->headers->set('X-CloudTasks-TaskExecutionCount', $taskExecutionCount + 1); + } + + public function runWithoutExceptionHandler(): void + { + app(TaskHandler::class)->handle($this->payload); + + $taskExecutionCount = request()->header('X-CloudTasks-TaskExecutionCount', 0); + request()->headers->set('X-CloudTasks-TaskExecutionCount', $taskExecutionCount + 1); + } + }; + } + + public function runFromPayload(array $payload): void + { + rescue(function () use ($payload) { + app(TaskHandler::class)->handle($payload); + }); + } + + public function dispatchAndRun($job): void + { + $this->runFromPayload($this->dispatch($job)); + } + + 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' => 'http://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()); + } } diff --git a/views/layout.blade.php b/views/layout.blade.php new file mode 100644 index 0000000..c7f0474 --- /dev/null +++ b/views/layout.blade.php @@ -0,0 +1,28 @@ + + + + + + + + + + Cloud Tasks for Laravel + + + + + @foreach ($manifest['index.html']['css'] as $css) + + @endforeach + + +
+ + + + + +