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/README.md b/README.md index 1796458..9f9d9c7 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,6 @@ 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 @@ -96,6 +93,27 @@ Please check the table below on what the values mean and what their value should |`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` +## Dashboard + +The package comes with a dashboard that can be used to monitor all queued jobs. + +To make use of it, publish its assets: + +``` +php artisan vendor:publish --tag=cloud-tasks-assets +``` + +We expose a dashboard at the /cloud-tasks URI. By default, you will only be able to access this dashboard in the local environment. However, within your app/Providers/AppServiceProvider.php file, there is an authorization gate definition. This authorization gate controls access to Cloud Tasks in non-local environments. You are free to modify this gate as needed to restrict access to your Cloud Tasks installation: + + +```php +Gate::define('viewCloudTasks', function ($user) { + return in_array($user->email, [ + 'me@example.com', + ]); +}); +``` + # Authentication Set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable with a path to the credentials file. 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..dd26f16 --- /dev/null +++ b/config/cloud-tasks.php @@ -0,0 +1,9 @@ + [ + 'enabled' => env('CLOUD_TASKS_MONITOR_ENABLED', false), + ], +]; 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/.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/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..2955bc7 --- /dev/null +++ b/dashboard/dist/manifest.json @@ -0,0 +1,16 @@ +{ + "index.html": { + "file": "assets/index.643ccf47.js", + "src": "index.html", + "isEntry": true, + "imports": [ + "_vendor.f52c9be3.js" + ], + "css": [ + "assets/index.1ecbaa60.css" + ] + }, + "_vendor.f52c9be3.js": { + "file": "assets/vendor.f52c9be3.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/crossword.png b/dashboard/public/crossword.png new file mode 100644 index 0000000..2f9f1ad Binary files /dev/null and b/dashboard/public/crossword.png differ diff --git a/dashboard/public/dot-grid.png b/dashboard/public/dot-grid.png new file mode 100644 index 0000000..ebcefe9 Binary files /dev/null and b/dashboard/public/dot-grid.png differ 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/public/pw_maze_white.png b/dashboard/public/pw_maze_white.png new file mode 100644 index 0000000..6646483 Binary files /dev/null and b/dashboard/public/pw_maze_white.png differ diff --git a/dashboard/src/App.vue b/dashboard/src/App.vue new file mode 100644 index 0000000..7c862e5 --- /dev/null +++ b/dashboard/src/App.vue @@ -0,0 +1,32 @@ + + + + diff --git a/dashboard/src/api.js b/dashboard/src/api.js new file mode 100644 index 0000000..50097f1 --- /dev/null +++ b/dashboard/src/api.js @@ -0,0 +1,55 @@ +import { onUnmounted, watch } from 'vue' +import { onBeforeRouteUpdate } from 'vue-router' + +export async function fetchTasks(into, query = {}) { + 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 + fetch( + `${import.meta.env.VITE_API_URL || ''}/cloud-tasks-api/tasks?${queryParams.toString()}` + ) + .then((response) => response.json()) + .then((response) => { + into.value = response + 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..1b18376 --- /dev/null +++ b/dashboard/src/components/Dashboard.vue @@ -0,0 +1,103 @@ + + + diff --git a/dashboard/src/components/Failed.vue b/dashboard/src/components/Failed.vue new file mode 100644 index 0000000..e9ef924 --- /dev/null +++ b/dashboard/src/components/Failed.vue @@ -0,0 +1,23 @@ + + + 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/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..5a021a3 --- /dev/null +++ b/dashboard/src/components/Queued.vue @@ -0,0 +1,23 @@ + + + diff --git a/dashboard/src/components/Recent.vue b/dashboard/src/components/Recent.vue new file mode 100644 index 0000000..259c3f1 --- /dev/null +++ b/dashboard/src/components/Recent.vue @@ -0,0 +1,23 @@ + + + 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..209c2ee --- /dev/null +++ b/dashboard/src/components/Task.vue @@ -0,0 +1,112 @@ + + + + + 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..63cba74 --- /dev/null +++ b/dashboard/src/main.js @@ -0,0 +1,88 @@ +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 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: '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`, +}) + +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..dd5197f 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/CloudTasksMonitoringTest.php @@ -21,14 +23,8 @@ - - + + - - - - src/ - - diff --git a/src/Authenticate.php b/src/Authenticate.php new file mode 100644 index 0000000..c463970 --- /dev/null +++ b/src/Authenticate.php @@ -0,0 +1,18 @@ +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 @@ + [ + '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/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..ef1b8be 100644 --- a/src/CloudTasksQueue.php +++ b/src/CloudTasksQueue.php @@ -10,48 +10,90 @@ 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); @@ -60,7 +102,12 @@ protected function pushToCloudTasks($queue, $payload, $delay = 0, $attempts = 0) $httpRequest = $this->createHttpRequest(); $httpRequest->setUrl($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..6490a60 100644 --- a/src/CloudTasksServiceProvider.php +++ b/src/CloudTasksServiceProvider.php @@ -3,35 +3,209 @@ namespace Stackkit\LaravelGoogleCloudTasksQueue; use Google\Cloud\Tasks\V2\CloudTasksClient; +use Illuminate\Queue\Events\JobExceptionOccurred; +use Illuminate\Queue\Events\JobFailed; +use Illuminate\Queue\Events\JobProcessed; +use Illuminate\Queue\Events\JobProcessing; use Illuminate\Queue\QueueManager; use Illuminate\Routing\Router; +use Illuminate\Support\Facades\Gate; 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(QueueManager $queue, Router $router): void { + $this->authorization(); + $this->registerClient(); $this->registerConnector($queue); + $this->registerConfig(); + $this->registerViews(); + $this->registerAssets(); + $this->registerMigrations(); $this->registerRoutes($router); + $this->registerMonitoring(); } - private function registerClient() + /** + * Configure the Cloud Tasks authorization services. + * + * @return void + */ + protected function authorization() + { + $this->gate(); + + CloudTasks::auth(function ($request) { + return app()->environment('local', 'testing') || + Gate::check('viewCloudTasks', [$request->user()]); + }); + } + + /** + * Register the Cloud Tasks gate. + * + * This gate determines who can access Cloud Tasks in non-local environments. + * + * @return void + */ + protected function gate() + { + Gate::define('viewCloudTasks', function ($user) { + return in_array($user->email, [ + // + ]); + }); + } + + 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(QueueManager $queue): void { $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::monitorDisabled()) { + // Larastan needs this view registered to check the service provider correctly. + // return; + } + + $this->loadViewsFrom(__DIR__ . '/../views', 'cloud-tasks'); + } + + private function registerAssets(): void + { + if (CloudTasks::monitorDisabled()) { + return; + } + + $this->publishes([ + __DIR__ . '/../dashboard/dist' => public_path('vendor/cloud-tasks'), + ], ['cloud-tasks']); + } + + private function registerMigrations(): void + { + if (CloudTasks::monitorDisabled()) { + return; + } + + $this->loadMigrationsFrom([ + __DIR__ . '/../migrations', + ]); + } + + private function registerRoutes(Router $router): void + { + $router->post('handle-task', [TaskHandler::class, 'handle'])->name('cloud-tasks.handle-task'); + + if (config('cloud-tasks.monitor.enabled') === false) { + return; + } + + $router->middleware(Authenticate::class)->group(function () use ($router) { + $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->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 registerMonitoring(): void { - $router->post('handle-task', [TaskHandler::class, 'handle']); + app('events')->listen(TaskCreated::class, function (TaskCreated $event) { + if (CloudTasks::monitorDisabled()) { + return; + } + + MonitoringService::make()->addToMonitor($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::monitorEnabled()) { + return; + } + + if ($event->job instanceof CloudTasksJob) { + MonitoringService::make()->markAsRunning($event->job->uuid()); + } + }); + + app('events')->listen(JobProcessed::class, function (JobProcessed $event) { + if (!CloudTasks::monitorEnabled()) { + return; + } + + if ($event->job instanceof CloudTasksJob) { + MonitoringService::make()->markAsSuccessful($event->job->uuid()); + } + }); + + app('events')->listen(JobExceptionOccurred::class, function (JobExceptionOccurred $event) { + if (!CloudTasks::monitorEnabled()) { + return; + } + + MonitoringService::make()->markAsError($event); + }); + + app('events')->listen(JobFailed::class, function ($event) { + if (!CloudTasks::monitorEnabled()) { + return; + } + + MonitoringService::make()->markAsFailed($event); + }); } } diff --git a/src/Config.php b/src/Config.php index ab82603..1bd6cd3 100644 --- a/src/Config.php +++ b/src/Config.php @@ -6,7 +6,7 @@ class Config { - public static function validate(array $config) + public static function validate(array $config): void { if (empty($config['project'])) { throw new Error(Errors::invalidProject()); 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..92b5dd0 100644 --- a/src/Errors.php +++ b/src/Errors.php @@ -4,22 +4,22 @@ 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() + public static function invalidHandler(): string { 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/MonitoringService.php b/src/MonitoringService.php new file mode 100644 index 0000000..561727f --- /dev/null +++ b/src/MonitoringService.php @@ -0,0 +1,145 @@ +getHttpRequest(); + + if (! $httpRequest instanceof HttpRequest) { + throw new Exception('Task does not have a HTTP request.'); + } + + return $httpRequest->getBody(); + } + + public function addToMonitor(string $queue, Task $task): void + { + $metadata = new TaskMetadata(); + $metadata->payload = $this->getTaskBody($task); + + $data = [ + 'queue' => $queue, + ]; + + if ($task->hasScheduleTime()) { + $status = 'scheduled'; + $data['scheduled_at'] = $task->getScheduleTime()->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/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..b326c4f 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'); + $connection = $command->command ?? 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/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..d6c90e9 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1 @@ +laravel/ 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/CloudTasksMonitoringTest.php b/tests/CloudTasksMonitoringTest.php new file mode 100644 index 0000000..e21c975 --- /dev/null +++ b/tests/CloudTasksMonitoringTest.php @@ -0,0 +1,484 @@ +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_monitor() + { + // 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_monitoring_is_disabled_jobs_will_not_be_added_to_the_monitor() + { + // Arrange + CloudTasksApi::fake(); + config()->set('cloud-tasks.monitor.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_monitor() + { + // 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_monitor() + { + // 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_monitor() + { + // 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_monitor() + { + // 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.monitor.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_monitoring_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_monitoring_is_enabled_it_adds_the_necessary_migrations() + { + $this->assertTrue(in_array(dirname(__DIR__) . '/src/../migrations', app('migrator')->paths())); + } + + /** + * @test + */ + public function when_monitoring_is_disabled_it_adds_the_necessary_migrations() + { + $this->assertEmpty(app('migrator')->paths()); + } + + /** + * @test + */ + public function when_monitoring_is_disabled_it_does_not_add_the_monitor_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')); + + } +} 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..77c1426 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/handle-task'; + }); } - /** @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..29231b8 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,31 +2,34 @@ 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'); - - static::$migrated = true; - } + $this->withFactories(__DIR__ . '/../factories'); } /** @@ -46,6 +49,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 +72,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/handle-task'), 'service_account_email' => 'info@stackkit.io', ]); + $app['config']->set('queue.failed.driver', 'database-uuids'); + $app['config']->set('queue.failed.database', 'testbench'); + + $disableMonitorPrefix = 'when_monitoring_is_disabled'; + + if (substr($this->getName(), 0, strlen($disableMonitorPrefix)) === $disableMonitorPrefix) { + $app['config']->set('cloud-tasks.monitor.enabled', false); + } else { + $app['config']->set('cloud-tasks.monitor.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/handle-task', + '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 + + +
+ + + + + +