diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 00000000..583b7c34 --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,76 @@ +name: E2E Tests + +on: + push: + branches: + - "release/**" + pull_request: + branches: + - "release/**" + workflow_dispatch: + inputs: + wp-rc-version: + description: 'WordPress version for Release Candidate (ex. 6.3-RC3)' + wc-rc-version: + description: 'WooCommerce version for Release Candidate (ex. 8.0.0-rc.1)' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + E2ETests: + name: E2E Tests + runs-on: ubuntu-latest + env: + FORCE_COLOR: 2 + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Prepare PHP + uses: woocommerce/grow/prepare-php@actions-v1 + with: + install-deps: "no" + + - name: Prepare node + uses: woocommerce/grow/prepare-node@actions-v1 + with: + node-version-file: ".nvmrc" + ignore-scripts: "no" + + - name: Build production bundle + run: | + echo "::group::Build log" + npm run build + echo "::endgroup::" + + - name: Start wp-env container + run: npm run wp-env:up + + - name: Install WP release candidate (optional) + if: github.event.inputs.wp-rc-version != '' + run: | + npm run -- wp-env run tests-cli -- wp core update --version=${{ github.event.inputs.wp-rc-version }} + npm run -- wp-env run tests-cli -- wp core update-db + + - name: Install WC release candidate (optional) + if: github.event.inputs.wc-rc-version != '' + run: | + npm run -- wp-env run tests-cli -- wp plugin update woocommerce --version=${{ github.event.inputs.wc-rc-version }} + npm run -- wp-env run tests-cli -- wp wc update + + - name: Download and install Chromium browser. + run: npx playwright install chromium + + - name: Run tests + run: npm run test:e2e + + - name: Archive e2e failure screenshots + if: ${{ always() }} + uses: actions/upload-artifact@v3 + with: + name: e2e-screenshots + path: tests/e2e/test-results/report/**/*.png + if-no-files-found: ignore + retention-days: 5 diff --git a/.gitignore b/.gitignore index c208501d..13529d8f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,7 @@ build /logs/* !/logs/.htaccess -tests/e2e/config/local-* +tests/e2e/test-results .phpunit.result.cache .eslintcache diff --git a/.wp-env.json b/.wp-env.json new file mode 100644 index 00000000..f3809f3b --- /dev/null +++ b/.wp-env.json @@ -0,0 +1,16 @@ +{ + "phpVersion": "8.0", + "plugins": [ + "https://github.com/WP-API/Basic-Auth/archive/master.zip", + "./tests/e2e/test-data", + "./tests/e2e/test-snippets", + "." + ], + "mappings": { + "wp-cli.yml": "./tests/e2e/config/wp-cli.yml" + }, + "lifecycleScripts": { + "afterStart": "./tests/e2e/bin/test-env-setup.sh", + "afterClean": "./tests/e2e/bin/test-env-setup.sh" + } +} \ No newline at end of file diff --git a/README.md b/README.md index bfa3c101..5c85b316 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,28 @@ Google Analytics for WooCommerce utilizes npm scripts for task management utilit _For more info see: [WordPress.org > Plugin Unit Tests](https://make.wordpress.org/cli/handbook/misc/plugin-unit-tests/#running-tests-locally)._ +## E2E Testing + +E2E testing uses [wp-env](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/) which requires [Docker](https://www.docker.com/). + +Make sure Docker is running in your machine, and run the following: + +`npm run wp-env:up` - This will automatically download and run WordPress in a Docker container. You can access it at http://localhost:8889 (Username: admin, Password: password). + +To install the PlayWright browser locally you can run: +`npx playwright install chromium` + +Run E2E testing: + +- `npm run test:e2e` to run the test in headless mode. +- `npm run test:e2e-dev` to run the tests in Chromium browser. + +To remove the Docker container and images (this will **delete everything** in the WordPress Docker container): + +`npm run wp-env destroy` + +:warning: Currently, the E2E testing on GitHub Actions is only run automatically after opening a PR with `release/*` branches or pushing changes to `release/*` branches. To run it manually, please visit [here](../../actions/workflows/e2e-tests.yml) and follow [this instruction](https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow?tool=webui) to do so. + ## Coding standards checks 1. Run `composer install` (_if you haven't done so already_) diff --git a/package-lock.json b/package-lock.json index e766454d..dad0d1fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,15 +6,18 @@ "packages": { "": { "name": "woocommerce-google-analytics-integration", - "version": "1.8.14", + "version": "2.0.1", "license": "GPL-2.0", "dependencies": { "@wordpress/hooks": "^3.22.0", "@wordpress/i18n": "3.15.0" }, "devDependencies": { + "@playwright/test": "^1.42.1", + "@wordpress/env": "^9.5.0", "@wordpress/eslint-plugin": "^17.5.0", "@wordpress/scripts": "^26.19.0", + "axios": "^1.6.7", "eslint": "^8.0.0", "node-wp-i18n": "~1.2.3", "prettier": "npm:wp-prettier@^3.0.3" @@ -2691,6 +2694,21 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "dev": true + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", @@ -2754,13 +2772,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.40.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.1.tgz", - "integrity": "sha512-EaaawMTOeEItCRvfmkI9v6rBkF1svM8wjl/YPRrg2N2Wmp+4qJYkWtJsbew1szfKKDm6fPLy4YAanBhIlf9dWw==", + "version": "1.42.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.42.1.tgz", + "integrity": "sha512-Gq9rmS54mjBL/7/MvBaNOBwbfnh7beHvS6oS4srqXFcQHpQCV1+c8JXWE8VLPyRDhgS3H8x8A7hztqI9VnwrAQ==", "dev": true, - "peer": true, "dependencies": { - "playwright": "1.40.1" + "playwright": "1.42.1" }, "bin": { "playwright": "cli.js" @@ -3018,6 +3035,18 @@ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "dev": true }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", @@ -3381,6 +3410,18 @@ "url": "https://github.com/sponsors/gregberge" } }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@tannin/compile": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@tannin/compile/-/compile-1.1.0.tgz", @@ -3492,6 +3533,18 @@ "@types/node": "*" } }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -3580,6 +3633,12 @@ "@types/node": "*" } }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "dev": true + }, "node_modules/@types/http-errors": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", @@ -3642,6 +3701,15 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -3699,6 +3767,15 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "dev": true }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -4526,6 +4603,74 @@ "@playwright/test": ">=1" } }, + "node_modules/@wordpress/env": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-9.5.0.tgz", + "integrity": "sha512-dxIosImyvkqUqPgCcOUCTgnLQRG3dZhnnNomEnsY0z5stbN+2IS6EJq3mZ/oJDitcJjrqB0dPopdUTVS/9bWMA==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "copy-dir": "^1.3.0", + "docker-compose": "^0.24.3", + "extract-zip": "^1.6.7", + "got": "^11.8.5", + "inquirer": "^7.1.0", + "js-yaml": "^3.13.1", + "ora": "^4.0.2", + "rimraf": "^3.0.2", + "simple-git": "^3.5.0", + "terminal-link": "^2.0.0", + "yargs": "^17.3.0" + }, + "bin": { + "wp-env": "bin/wp-env" + } + }, + "node_modules/@wordpress/env/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@wordpress/env/node_modules/extract-zip": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", + "integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==", + "dev": true, + "dependencies": { + "concat-stream": "^1.6.2", + "debug": "^2.6.9", + "mkdirp": "^0.5.4", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + } + }, + "node_modules/@wordpress/env/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/@wordpress/env/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@wordpress/eslint-plugin": { "version": "17.5.0", "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-17.5.0.tgz", @@ -5368,12 +5513,12 @@ } }, "node_modules/axios": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.3.tgz", - "integrity": "sha512-fWyNdeawGam70jXSVlKl+SUNVcL6j6W79CuSIPfi6HnDUmSCH6gyUys/HrqHeA/wU0Az41rRgean494d0Jb+ww==", + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", + "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", "dev": true, "dependencies": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.4", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -5867,6 +6012,48 @@ "node": ">= 0.8" } }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-request/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -6079,6 +6266,12 @@ "node": ">=10" } }, + "node_modules/chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true + }, "node_modules/check-node-version": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/check-node-version/-/check-node-version-4.2.1.tgz", @@ -6287,6 +6480,39 @@ "webpack": "*" } }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -6301,6 +6527,15 @@ "node": ">=12" } }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "engines": { + "node": ">=0.8" + } + }, "node_modules/clone-deep": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz", @@ -6329,6 +6564,18 @@ "node": ">=0.10.0" } }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -6465,6 +6712,42 @@ "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "engines": [ + "node >= 0.8" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/configstore": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz", @@ -6576,6 +6859,12 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "dev": true }, + "node_modules/copy-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/copy-dir/-/copy-dir-1.3.0.tgz", + "integrity": "sha512-Q4+qBFnN4bwGwvtXXzbp4P/4iNk0MaiGAzvQ8OiMtlLjkIKjmNN689uVzShSM0908q7GoFHXIPx4zi75ocoaHw==", + "dev": true + }, "node_modules/copy-webpack-plugin": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-10.2.4.tgz", @@ -7189,6 +7478,33 @@ "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", "dev": true }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", @@ -7267,6 +7583,27 @@ "node": ">= 10" } }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -7449,6 +7786,30 @@ "node": ">=6" } }, + "node_modules/docker-compose": { + "version": "0.24.6", + "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-0.24.6.tgz", + "integrity": "sha512-VidlUyNzXMaVsuM79sjSvwC4nfojkP2VneL+Zfs538M2XFnffZDhx6veqnz/evCNIYGyz5O+1fgL6+g0NLWTBA==", + "dev": true, + "dependencies": { + "yaml": "^2.2.2" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/docker-compose/node_modules/yaml": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.1.tgz", + "integrity": "sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==", + "dev": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -8929,6 +9290,20 @@ } ] }, + "node_modules/external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dev": true, + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -9058,6 +9433,21 @@ "pend": "~1.2.0" } }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -9704,6 +10094,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -9970,6 +10385,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "dev": true + }, "node_modules/http-deceiver": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", @@ -10059,6 +10480,31 @@ } } }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/http2-wrapper/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/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -10241,6 +10687,48 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true }, + "node_modules/inquirer": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz", + "integrity": "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==", + "dev": true, + "dependencies": { + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.19", + "mute-stream": "0.0.8", + "run-async": "^2.4.0", + "rxjs": "^6.6.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/inquirer/node_modules/rxjs": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", + "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", + "dev": true, + "dependencies": { + "tslib": "^1.9.0" + }, + "engines": { + "npm": ">=2.0.0" + } + }, + "node_modules/inquirer/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, "node_modules/internal-slot": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz", @@ -10503,6 +10991,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-map": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", @@ -11782,6 +12279,12 @@ "node": ">=4" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "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", @@ -11846,6 +12349,15 @@ "node": ">=4.0" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -12291,6 +12803,15 @@ "tslib": "^2.0.3" } }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/lru_map": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.3.3.tgz", @@ -12666,6 +13187,15 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -12880,6 +13410,12 @@ "multicast-dns": "cli.js" } }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -13067,6 +13603,18 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/npm-bundled": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.2.tgz", @@ -13479,6 +14027,169 @@ "node": ">= 0.8.0" } }, + "node_modules/ora": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-4.1.1.tgz", + "integrity": "sha512-sjYP8QyVWBpBZWD6Vr1M/KwknSw6kJOz41tvGMlwWeClHBtYKTbHMki1PsLZnxKpXMPbTKv9b3pjQu3REib96A==", + "dev": true, + "dependencies": { + "chalk": "^3.0.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.2.0", + "is-interactive": "^1.0.0", + "log-symbols": "^3.0.0", + "mute-stream": "0.0.8", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/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/ora/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/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/ora/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/ora/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/ora/node_modules/log-symbols": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", + "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==", + "dev": true, + "dependencies": { + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/log-symbols/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/ora/node_modules/log-symbols/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/ora/node_modules/log-symbols/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/ora/node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/ora/node_modules/log-symbols/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/log-symbols/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/ora/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/os-homedir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", @@ -13497,6 +14208,15 @@ "node": ">=0.10.0" } }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -13907,13 +14627,12 @@ } }, "node_modules/playwright": { - "version": "1.40.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.1.tgz", - "integrity": "sha512-2eHI7IioIpQ0bS1Ovg/HszsN/XKNwEG1kbzSDDmADpclKc7CyqkHw7Mg2JCz/bbCxg25QUPcjksoMW7JcIFQmw==", + "version": "1.42.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.1.tgz", + "integrity": "sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg==", "dev": true, - "peer": true, "dependencies": { - "playwright-core": "1.40.1" + "playwright-core": "1.42.1" }, "bin": { "playwright": "cli.js" @@ -13938,11 +14657,10 @@ } }, "node_modules/playwright/node_modules/playwright-core": { - "version": "1.40.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.1.tgz", - "integrity": "sha512-+hkOycxPiV534c4HhpfX6yrlawqVUzITRKwHAmYfmsVreltEl6fAZJ3DPfLMOODw0H3s1Itd6MDCWmP1fl/QvQ==", + "version": "1.42.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.1.tgz", + "integrity": "sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA==", "dev": true, - "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -15387,6 +16105,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true + }, "node_modules/resolve-bin": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/resolve-bin/-/resolve-bin-0.4.3.tgz", @@ -15448,6 +16172,31 @@ "node": ">=10" } }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -15488,6 +16237,15 @@ "node": ">=10.0.0" } }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/run-con": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/run-con/-/run-con-1.2.11.tgz", @@ -15953,6 +16711,21 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/simple-git": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.22.0.tgz", + "integrity": "sha512-6JujwSs0ac82jkGjMHiCnTifvf1crOiY/+tfs/Pqih6iow7VrpNKRRNdWm6RtaXpvvv/JGNYhlUtLhGFqHF+Yw==", + "dev": true, + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.3.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, "node_modules/sirv": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", @@ -16891,6 +17664,22 @@ "node": ">=6" } }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "dev": true, + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/terser": { "version": "5.26.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.26.0.tgz", @@ -17243,6 +18032,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "dev": true + }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", @@ -17604,6 +18399,15 @@ "minimalistic-assert": "^1.0.0" } }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "dependencies": { + "defaults": "^1.0.3" + } + }, "node_modules/web-vitals": { "version": "3.5.1", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-3.5.1.tgz", diff --git a/package.json b/package.json index 22d21089..40835fbf 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,11 @@ "@wordpress/i18n": "3.15.0" }, "devDependencies": { + "@playwright/test": "^1.42.1", + "@wordpress/env": "^9.5.0", "@wordpress/eslint-plugin": "^17.5.0", "@wordpress/scripts": "^26.19.0", + "axios": "^1.6.7", "eslint": "^8.0.0", "node-wp-i18n": "~1.2.3", "prettier": "npm:wp-prettier@^3.0.3" @@ -35,7 +38,12 @@ "archive": "composer archive --file=$npm_package_name --format=zip", "postarchive": "rm -rf $npm_package_name && unzip $npm_package_name.zip -d $npm_package_name && rm $npm_package_name.zip && zip -r $npm_package_name.zip $npm_package_name && rm -rf $npm_package_name", "build": "NODE_ENV=production wp-scripts build && npm run makepot && npm run archive", - "prebuild": "rm -rf ./vendor" + "prebuild": "rm -rf ./vendor", + "test:e2e": "npx playwright test --config=tests/e2e/config/playwright.config.js", + "test:e2e-dev": "npx playwright test --config=tests/e2e/config/playwright.config.js --debug", + "wp-env": "wp-env", + "wp-env:up": "npm run -- wp-env start --update", + "wp-env:down": "npm run wp-env stop" }, "engines": { "node": ">=18", diff --git a/phpcs.xml.dist b/phpcs.xml.dist index ba7e58ed..9f31e36f 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -61,6 +61,15 @@ + + + + + + + + + diff --git a/tests/e2e/bin/test-env-setup.sh b/tests/e2e/bin/test-env-setup.sh new file mode 100755 index 00000000..92e92f9a --- /dev/null +++ b/tests/e2e/bin/test-env-setup.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +echo -e 'Activate twentytwentytwo theme \n' +wp-env run tests-cli wp theme activate twentytwentytwo + +echo -e 'Install WooCommerce \n' +wp-env run tests-cli -- wp plugin install woocommerce --activate + +echo -e 'Update URL structure \n' +wp-env run tests-cli -- wp rewrite structure '/%postname%/' --hard + +echo -e 'Add Customer user \n' +wp-env run tests-cli wp user create customer customer@e2etestsuite.test \ + --user_pass=password \ + --role=subscriber \ + --first_name='Jane' \ + --last_name='Smith' \ + --user_registered='2024-01-01 12:23:45' + +echo -e 'Update Blog Name \n' +wp-env run tests-cli wp option update blogname 'WooCommerce E2E Test Suite' + +echo -e 'Adding basic WooCommerce settings... \n' +wp-env run tests-cli wp wc payment_gateway update cod --enabled=1 --user=admin diff --git a/tests/e2e/config/default.json b/tests/e2e/config/default.json new file mode 100644 index 00000000..ae102ae5 --- /dev/null +++ b/tests/e2e/config/default.json @@ -0,0 +1,185 @@ +{ + "url": "http://localhost:8889/", + "users": { + "admin": { + "username": "admin", + "password": "password" + }, + "customer": { + "username": "customer", + "password": "password" + } + }, + "products": { + "simple": { + "name": "Simple product", + "type": "simple", + "regular_price": "9.99" + }, + "variable": { + "name": "Variable product", + "type": "variable", + "default_attributes": [ + { + "name": "Size", + "option": "Medium" + }, + { + "name": "Colour", + "option": "Green" + } + ], + "attributes": [ + { + "name": "Colour", + "visible": true, + "variation": true, + "options": [ "Red", "Green", "Blue" ] + }, + { + "name": "Size", + "visible": true, + "variation": true, + "options": [ "Small", "Medium", "Large" ] + } + ] + }, + "variations": [ + { + "regular_price": "19.99", + "attributes": [ + { + "name": "Size", + "option": "Large" + }, + { + "name": "Colour", + "option": "Red" + } + ] + }, + { + "regular_price": "18.99", + "attributes": [ + { + "name": "Size", + "option": "Medium" + }, + { + "name": "Colour", + "option": "Green" + } + ] + }, + { + "regular_price": "17.99", + "attributes": [ + { + "name": "Size", + "option": "Small" + }, + { + "name": "Colour", + "option": "Blue" + } + ] + } + ], + "grouped": { + "name": "Grouped Product with Three Children", + "groupedProducts": [ + { + "name": "Base Unit", + "regularPrice": "29.99" + }, + { + "name": "Add-on A", + "regularPrice": "11.95" + }, + { + "name": "Add-on B", + "regularPrice": "18.97" + } + ] + }, + "external": { + "name": "External product", + "regularPrice": "24.99", + "buttonText": "Buy now", + "externalUrl": "https://wordpress.org/plugins/woocommerce" + } + }, + "coupons": { + "percentage": { + "code": "20percent", + "discountType": "percent", + "amount": "20.00" + } + }, + "addresses": { + "admin": { + "store": { + "firstname": "John", + "lastname": "Doe", + "company": "Automattic", + "country": "United States (US)", + "addressfirstline": "addr 1", + "addresssecondline": "addr 2", + "countryandstate": "United States (US) — California", + "city": "San Francisco", + "state": "CA", + "postcode": "94107" + } + }, + "customer": { + "billing": { + "firstname": "John", + "lastname": "Doe", + "company": "Automattic", + "country": "United States (US)", + "addressfirstline": "addr 1", + "addresssecondline": "addr 2", + "city": "San Francisco", + "state": "CA", + "statename": "California", + "postcode": "94107", + "phone": "123456789", + "email": "john.doe@example.com" + }, + "shipping": { + "firstname": "John", + "lastname": "Doe", + "company": "Automattic", + "country": "United States (US)", + "addressfirstline": "addr 1", + "addresssecondline": "addr 2", + "city": "San Francisco", + "state": "CA", + "postcode": "94107" + } + } + }, + "orders": { + "basicPaidOrder": { + "paymentMethod": "cod", + "status": "processing", + "billing": { + "firstName": "John", + "lastName": "Doe", + "email": "john.doe@example.com" + } + } + }, + "onboardingwizard": { + "industry": "Test industry", + "numberofproducts": "1 - 10", + "sellingelsewhere": "No" + }, + "settings": { + "shipping": { + "zonename": "United States", + "zoneregions": "United States (US)", + "shippingmethod": "Free shipping" + } + } +} \ No newline at end of file diff --git a/tests/e2e/config/playwright.config.js b/tests/e2e/config/playwright.config.js new file mode 100644 index 00000000..af07f4b8 --- /dev/null +++ b/tests/e2e/config/playwright.config.js @@ -0,0 +1,69 @@ +const { defineConfig, devices } = require( '@playwright/test' ); +const { url } = require( './default.json' ); + +module.exports = defineConfig( { + testDir: '../specs', + + /* Maximum time in milliseconds one test can run for. */ + timeout: 120 * 1000, + + expect: { + /** + * Maximum time in milliseconds, expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 20 * 1000, + }, + + /* Number of workers */ + workers: 1, + + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + [ 'list' ], + [ + 'html', + { + outputFolder: '../test-results/playwright-report', + open: 'never', + }, + ], + ], + + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time in milliseconds, each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: url, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'retain-on-failure', + + screenshot: 'only-on-failure', + stateDir: 'tests/e2e/test-results/storage/', + video: 'on-first-retry', + viewport: { width: 1280, height: 720 }, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices[ 'Desktop Chrome' ] }, + }, + ], + + /* Folder for test artifacts such as screenshots, videos, traces, etc. */ + outputDir: '../test-results/report', + + /* Global setup and teardown scripts. */ + globalSetup: require.resolve( '../global-setup' ), + + /* Maximum number of tests to run in parallel. */ + maxFailures: 10, +} ); diff --git a/tests/e2e/config/wp-cli.yml b/tests/e2e/config/wp-cli.yml new file mode 100644 index 00000000..3cf7565b --- /dev/null +++ b/tests/e2e/config/wp-cli.yml @@ -0,0 +1,2 @@ +apache_modules: + - mod_rewrite diff --git a/tests/e2e/global-setup.js b/tests/e2e/global-setup.js new file mode 100644 index 00000000..1ddcb249 --- /dev/null +++ b/tests/e2e/global-setup.js @@ -0,0 +1,150 @@ +/** + * Internal dependencies + */ +const { admin, customer } = require( './config/default.json' ).users; +const { LOAD_STATE } = require( './utils/constants' ); + +/** + * External dependencies + */ +const { chromium, expect } = require( '@playwright/test' ); +const fs = require( 'fs' ); + +/* eslint-disable no-console */ +module.exports = async ( config ) => { + const { stateDir, baseURL, userAgent } = config.projects[ 0 ].use; + + console.log( `State Dir: ${ stateDir }` ); + console.log( `Base URL: ${ baseURL }` ); + + // used throughout tests for authentication + process.env.ADMINSTATE = `${ stateDir }adminState.json`; + process.env.CUSTOMERSTATE = `${ stateDir }customerState.json`; + + // Clear out the previous save states + try { + fs.unlinkSync( process.env.ADMINSTATE ); + console.log( 'Admin state file deleted successfully.' ); + } catch ( err ) { + if ( err.code === 'ENOENT' ) { + console.log( 'Admin state file does not exist.' ); + } else { + console.error( 'Admin state file could not be deleted: ' + err ); + } + } + try { + fs.unlinkSync( process.env.CUSTOMERSTATE ); + console.log( 'Customer state file deleted successfully.' ); + } catch ( err ) { + if ( err.code === 'ENOENT' ) { + console.log( 'Customer state file does not exist.' ); + } else { + console.log( 'Customer state file could not be deleted: ' + err ); + } + } + + // Pre-requisites + let adminLoggedIn = false; + let customerLoggedIn = false; + + // Specify user agent when running against an external test site to avoid getting HTTP 406 NOT ACCEPTABLE errors. + const contextOptions = { baseURL, userAgent }; + + // Create browser, browserContext, and page for customer and admin users + const browser = await chromium.launch(); + const adminContext = await browser.newContext( contextOptions ); + const customerContext = await browser.newContext( contextOptions ); + const adminPage = await adminContext.newPage(); + const customerPage = await customerContext.newPage(); + + // Sign in as admin user and save state + const adminRetries = 5; + for ( let i = 0; i < adminRetries; i++ ) { + try { + console.log( 'Trying to log-in as admin...' ); + await adminPage.goto( `/wp-admin` ); + await adminPage + .locator( 'input[name="log"]' ) + .fill( admin.username ); + await adminPage + .locator( 'input[name="pwd"]' ) + .fill( admin.password ); + await adminPage.locator( 'text=Log In' ).click(); + await adminPage.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); + await adminPage.goto( `/wp-admin` ); + await adminPage.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); + + await expect( adminPage.locator( 'div.wrap > h1' ) ).toHaveText( + 'Dashboard' + ); + await adminPage + .context() + .storageState( { path: process.env.ADMINSTATE } ); + console.log( 'Logged-in as admin successfully.' ); + adminLoggedIn = true; + break; + } catch ( e ) { + console.warn( + `Admin log-in failed, Retrying... ${ i }/${ adminRetries }` + ); + console.warn( e ); + } + } + + if ( ! adminLoggedIn ) { + console.error( + 'Cannot proceed e2e test, as admin login failed. Please check if the test site has been setup correctly.' + ); + process.exit( 1 ); + } + + // Sign in as customer user and save state + const customerRetries = 5; + for ( let i = 0; i < customerRetries; i++ ) { + try { + console.log( 'Trying to log-in as customer...' ); + await customerPage.goto( `/wp-admin` ); + await customerPage + .locator( 'input[name="log"]' ) + .fill( customer.username ); + await customerPage + .locator( 'input[name="pwd"]' ) + .fill( customer.password ); + await customerPage.locator( 'text=Log In' ).click(); + + await customerPage.goto( `/my-account` ); + await expect( + customerPage.locator( + '.woocommerce-MyAccount-navigation-link--customer-logout' + ) + ).toBeVisible(); + await expect( + customerPage.locator( + 'div.woocommerce-MyAccount-content > p >> nth=0' + ) + ).toContainText( 'Hello' ); + + await customerPage + .context() + .storageState( { path: process.env.CUSTOMERSTATE } ); + console.log( 'Logged-in as customer successfully.' ); + customerLoggedIn = true; + break; + } catch ( e ) { + console.log( + `Customer log-in failed. Retrying... ${ i }/${ customerRetries }` + ); + console.log( e ); + } + } + + if ( ! customerLoggedIn ) { + console.error( + 'Cannot proceed e2e test, as customer login failed. Please check if the test site has been setup correctly.' + ); + process.exit( 1 ); + } + + await adminContext.close(); + await browser.close(); +}; diff --git a/tests/e2e/specs/gtag-events/blocks-pages.test.js b/tests/e2e/specs/gtag-events/blocks-pages.test.js new file mode 100644 index 00000000..f1dbb0ac --- /dev/null +++ b/tests/e2e/specs/gtag-events/blocks-pages.test.js @@ -0,0 +1,271 @@ +/** + * External dependencies + */ +const { test, expect } = require( '@playwright/test' ); + +/** + * Internal dependencies + */ +import { + createSimpleProduct, + setSettings, + clearSettings, +} from '../../utils/api'; +import { + blockProductAddToCart, + relatedProductAddToCart, + simpleProductAddToCart, +} from '../../utils/customer'; +import { + createProductCollectionBlockShopPage, + createProductsBlockShopPage, +} from '../../utils/create-page'; +import { getEventData, trackGtagEvent } from '../../utils/track-event'; + +const config = require( '../../config/default' ); +const simpleProductPrice = parseFloat( config.products.simple.regular_price ); + +test.describe( 'GTag events on block pages', () => { + let simpleProductID; + + test.beforeAll( async () => { + await setSettings(); + simpleProductID = await createSimpleProduct(); + } ); + + test.afterAll( async () => { + await clearSettings(); + } ); + + // WooCommerce shop page is built with blocks. + test( 'Add to cart event is sent from the shop page', async ( { + page, + } ) => { + const event = trackGtagEvent( page, 'add_to_cart' ); + + // Go to shop page (newest first) + await page.goto( 'shop?orderby=date' ); + await blockProductAddToCart( page, simpleProductID ); + + await event.then( ( request ) => { + const data = getEventData( request, 'add_to_cart' ); + expect( data.product1 ).toEqual( { + id: simpleProductID.toString(), + nm: 'Simple product', + ca: 'Uncategorized', + qt: '1', + pr: simpleProductPrice.toString(), + } ); + } ); + } ); + + test( 'View item list event is sent from the shop page', async ( { + page, + } ) => { + const event = trackGtagEvent( page, 'view_item_list' ); + + // Go to shop page (newest first) + await page.goto( 'shop?orderby=date' ); + + await event.then( ( request ) => { + const data = getEventData( request, 'view_item_list' ); + expect( data.product1 ).toEqual( { + id: simpleProductID.toString(), + nm: 'Simple product', + ln: 'Product List', + ca: 'Uncategorized', + pr: simpleProductPrice.toString(), + lp: '1', + } ); + expect( data[ 'ep.item_list_id' ] ).toEqual( 'engagement' ); + expect( data[ 'ep.item_list_name' ] ).toEqual( 'Viewing products' ); + } ); + } ); + + test( 'Remove from cart event is sent from the cart page', async ( { + page, + } ) => { + await simpleProductAddToCart( page, simpleProductID ); + + const event = trackGtagEvent( page, 'remove_from_cart' ); + await page.goto( 'cart' ); + + await page + .locator( '.wc-block-cart-item__remove-link' ) + .first() + .click(); + + await event.then( ( request ) => { + const data = getEventData( request, 'remove_from_cart' ); + expect( data.product1 ).toEqual( { + id: simpleProductID.toString(), + nm: 'Simple product', + qt: '1', + pr: simpleProductPrice.toString(), + va: '', + } ); + } ); + } ); + + test( 'Remove from cart event is sent from the mini cart', async ( { + page, + } ) => { + await simpleProductAddToCart( page, simpleProductID ); + + const event = trackGtagEvent( page, 'remove_from_cart' ); + await page.goto( 'shop' ); + + await page.locator( '.wc-block-mini-cart' ).click(); + await page + .locator( '.wc-block-cart-item__remove-link' ) + .first() + .click(); + + await event.then( ( request ) => { + const data = getEventData( request, 'remove_from_cart' ); + expect( data.product1 ).toEqual( { + id: simpleProductID.toString(), + nm: 'Simple product', + qt: '1', + pr: simpleProductPrice.toString(), + va: '', + } ); + } ); + } ); + + test( 'Begin checkout event is sent from a checkout page', async ( { + page, + } ) => { + await simpleProductAddToCart( page, simpleProductID ); + + const event = trackGtagEvent( page, 'begin_checkout' ); + await page.goto( 'checkout' ); + + await event.then( ( request ) => { + const data = getEventData( request, 'begin_checkout' ); + expect( data.product1 ).toEqual( { + id: simpleProductID.toString(), + nm: 'Simple product', + qt: '1', + pr: simpleProductPrice.toString(), + va: '', + } ); + expect( data.cu ).toEqual( 'USD' ); + expect( data[ 'epn.value' ] ).toEqual( + simpleProductPrice.toString() + ); + } ); + } ); + + test( 'Add to cart event is sent from a product collection block shop page', async ( { + page, + } ) => { + await createProductCollectionBlockShopPage(); + + const event = trackGtagEvent( page, 'add_to_cart' ); + + await page.goto( 'product-collection-block-shop' ); + await blockProductAddToCart( page, simpleProductID ); + + await event.then( ( request ) => { + const data = getEventData( request, 'add_to_cart' ); + expect( data.product1 ).toEqual( { + id: simpleProductID.toString(), + nm: 'Simple product', + ca: 'Uncategorized', + qt: '1', + pr: simpleProductPrice.toString(), + } ); + } ); + } ); + + test( 'View item list event is sent from the product collection block shop page', async ( { + page, + } ) => { + await createProductCollectionBlockShopPage(); + + const event = trackGtagEvent( page, 'view_item_list' ); + await page.goto( 'product-collection-block-shop' ); + + await event.then( ( request ) => { + const data = getEventData( request, 'view_item_list' ); + expect( data.product1 ).toEqual( { + id: simpleProductID.toString(), + nm: 'Simple product', + ln: 'Product List', + ca: 'Uncategorized', + pr: simpleProductPrice.toString(), + lp: '1', + } ); + expect( data[ 'ep.item_list_id' ] ).toEqual( 'engagement' ); + expect( data[ 'ep.item_list_name' ] ).toEqual( 'Viewing products' ); + } ); + } ); + + test( 'Add to cart event is sent from a products block shop page', async ( { + page, + } ) => { + await createProductsBlockShopPage(); + + const event = trackGtagEvent( page, 'add_to_cart' ); + + await page.goto( 'products-block-shop' ); + await blockProductAddToCart( page, simpleProductID ); + + await event.then( ( request ) => { + const data = getEventData( request, 'add_to_cart' ); + expect( data.product1 ).toEqual( { + id: simpleProductID.toString(), + nm: 'Simple product', + ca: 'Uncategorized', + qt: '1', + pr: simpleProductPrice.toString(), + } ); + } ); + } ); + + test( 'View item list event is sent from the products block shop page', async ( { + page, + } ) => { + await createProductsBlockShopPage(); + + const event = trackGtagEvent( page, 'view_item_list' ); + await page.goto( 'products-block-shop' ); + + await event.then( ( request ) => { + const data = getEventData( request, 'view_item_list' ); + expect( data.product1 ).toEqual( { + id: simpleProductID.toString(), + nm: 'Simple product', + ln: 'Product List', + ca: 'Uncategorized', + pr: simpleProductPrice.toString(), + lp: '1', + } ); + expect( data[ 'ep.item_list_id' ] ).toEqual( 'engagement' ); + expect( data[ 'ep.item_list_name' ] ).toEqual( 'Viewing products' ); + } ); + } ); + + // Related products are blocks even though they are on a regular single product page. + test( 'Add to cart event is sent from related product on single product page', async ( { + page, + } ) => { + await createSimpleProduct(); // Create an additional product for related to show up. + const event = trackGtagEvent( page, 'add_to_cart' ); + + await page.goto( `?p=${ simpleProductID }` ); + const relatedProductID = await relatedProductAddToCart( page ); + + await event.then( ( request ) => { + const data = getEventData( request, 'add_to_cart' ); + expect( data.product1 ).toEqual( { + id: relatedProductID.toString(), + nm: 'Simple product', + ca: 'Uncategorized', + qt: '1', + pr: simpleProductPrice.toString(), + } ); + } ); + } ); +} ); diff --git a/tests/e2e/specs/gtag-events/classic-pages.test.js b/tests/e2e/specs/gtag-events/classic-pages.test.js new file mode 100644 index 00000000..572fdb48 --- /dev/null +++ b/tests/e2e/specs/gtag-events/classic-pages.test.js @@ -0,0 +1,311 @@ +/** + * External dependencies + */ +const { test, expect } = require( '@playwright/test' ); + +/** + * Internal dependencies + */ +import { + createSimpleProduct, + createVariableProduct, + setSettings, + clearSettings, +} from '../../utils/api'; +import { + createClassicCartPage, + createClassicCheckoutPage, + createClassicShopPage, +} from '../../utils/create-page'; +import { + checkout, + simpleProductAddToCart, + variableProductAddToCart, +} from '../../utils/customer'; +import { getEventData, trackGtagEvent } from '../../utils/track-event'; + +const config = require( '../../config/default' ); +const simpleProductPrice = parseFloat( config.products.simple.regular_price ); + +test.describe( 'GTag events on classic pages', () => { + let simpleProductID, variableProductID; + + test.beforeAll( async () => { + await setSettings(); + variableProductID = await createVariableProduct(); + simpleProductID = await createSimpleProduct(); + } ); + + test.afterAll( async () => { + await clearSettings(); + } ); + + test( 'Page view event is sent on a frontend page for a guest user', async ( { + page, + } ) => { + const event = trackGtagEvent( page, 'page_view' ); + + await page.goto( 'shop' ); + + await event.then( ( request ) => { + const data = getEventData( request, 'page_view' ); + + // Confirm we are tracking a guest user. + expect( data[ 'ep.logged_in' ] ).toEqual( 'false' ); + } ); + } ); + + test( 'View item event is sent on a single product page', async ( { + page, + } ) => { + const event = trackGtagEvent( page, 'view_item' ); + + await page.goto( `?p=${ simpleProductID }` ); + + await event.then( ( request ) => { + const data = getEventData( request, 'view_item' ); + expect( data.product1 ).toEqual( { + id: simpleProductID.toString(), + nm: 'Simple product', + ln: 'Product List', + ca: 'Uncategorized', + pr: simpleProductPrice.toString(), + } ); + } ); + } ); + + test( 'Add to cart event is sent on the home page when adding product through URL', async ( { + page, + } ) => { + const event = trackGtagEvent( page, 'add_to_cart' ); + + // Load home page without products and add product to cart by ID. + await page.goto( `/?add-to-cart=${ simpleProductID }` ); + + await event.then( ( request ) => { + const data = getEventData( request, 'add_to_cart' ); + expect( data.product1 ).toEqual( { + id: simpleProductID.toString(), + nm: 'Simple product', + ca: 'Uncategorized', + qt: '1', + pr: simpleProductPrice.toString(), + } ); + } ); + } ); + + test( 'Add to cart event is sent on a single product page', async ( { + page, + } ) => { + const event = trackGtagEvent( page, 'add_to_cart' ); + + await simpleProductAddToCart( page, simpleProductID ); + await event.then( ( request ) => { + const data = getEventData( request, 'add_to_cart' ); + expect( data.product1 ).toEqual( { + id: simpleProductID.toString(), + nm: 'Simple product', + ca: 'Uncategorized', + qt: '1', + pr: simpleProductPrice.toString(), + } ); + } ); + } ); + + test( 'Add to cart event is sent on a variable product page', async ( { + page, + } ) => { + const event = trackGtagEvent( page, 'add_to_cart' ); + + await variableProductAddToCart( page, variableProductID ); + + await event.then( ( request ) => { + const data = getEventData( request, 'add_to_cart' ); + expect( data.product1 ).toEqual( { + id: variableProductID.toString(), + nm: 'Variable product', + ca: 'Uncategorized', + qt: '1', + pr: '18.99', + va: 'colour: Green, size: Medium', + } ); + } ); + } ); + + test( 'Add to cart event is sent from a classic shop page', async ( { + page, + } ) => { + await createClassicShopPage(); + + const event = trackGtagEvent( page, 'add_to_cart' ); + + // Go to shop page (newest first) + await page.goto( 'classic-shop?orderby=date' ); + const addToCart = `[data-product_id="${ simpleProductID }"]`; + const addToCartButton = await page.locator( addToCart ).first(); + await addToCartButton.click(); + await expect( addToCartButton ).toHaveClass( /added/ ); + + await event.then( ( request ) => { + const data = getEventData( request, 'add_to_cart' ); + expect( data.product1 ).toEqual( { + id: simpleProductID.toString(), + nm: 'Simple product', + ca: 'Uncategorized', + qt: '1', + pr: simpleProductPrice.toString(), + } ); + } ); + } ); + + test( 'View item list event is sent from a classic shop page', async ( { + page, + } ) => { + await createClassicShopPage(); + + const event = trackGtagEvent( page, 'view_item_list' ); + + // Go to shop page (newest first) + await page.goto( 'classic-shop?orderby=date' ); + + await event.then( ( request ) => { + const data = getEventData( request, 'view_item_list' ); + expect( data.product1 ).toEqual( { + id: simpleProductID.toString(), + nm: 'Simple product', + ln: 'Product List', + ca: 'Uncategorized', + pr: simpleProductPrice.toString(), + lp: '1', + } ); + expect( data.product2 ).toEqual( { + id: variableProductID.toString(), + nm: 'Variable product', + ln: 'Product List', + ca: 'Uncategorized', + pr: '17.99', // Lowest price for variable products. + lp: '2', + } ); + expect( data[ 'ep.item_list_id' ] ).toEqual( 'engagement' ); + expect( data[ 'ep.item_list_name' ] ).toEqual( 'Viewing products' ); + } ); + } ); + + test( 'Remove from cart event is sent from a classic cart page', async ( { + page, + } ) => { + await createClassicCartPage(); + await simpleProductAddToCart( page, simpleProductID ); + + const event = trackGtagEvent( page, 'remove_from_cart' ); + await page.goto( 'classic-cart' ); + + await page.locator( '.cart_item .remove' ).first().click(); + + await event.then( ( request ) => { + const data = getEventData( request, 'remove_from_cart' ); + expect( data.product1 ).toEqual( { + id: simpleProductID.toString(), + nm: 'Simple product', + ca: 'Uncategorized', + qt: '1', + pr: simpleProductPrice.toString(), + } ); + } ); + } ); + + test( 'Remove from cart event for a variable product', async ( { + page, + } ) => { + await createClassicCartPage(); + await variableProductAddToCart( page, variableProductID ); + + const event = trackGtagEvent( page, 'remove_from_cart' ); + await page.goto( 'classic-cart' ); + + await page.locator( '.cart_item .remove' ).first().click(); + + await event.then( ( request ) => { + const data = getEventData( request, 'remove_from_cart' ); + expect( data.product1 ).toEqual( { + id: variableProductID.toString(), + nm: 'Variable product', + ca: 'Uncategorized', + qt: '1', + pr: '18.99', + va: 'colour: Green, size: Medium', + } ); + } ); + } ); + + test( 'Begin checkout event is sent from a classic checkout page', async ( { + page, + } ) => { + await createClassicCheckoutPage(); + await simpleProductAddToCart( page, simpleProductID ); + await variableProductAddToCart( page, variableProductID ); + + const event = trackGtagEvent( page, 'begin_checkout' ); + await page.goto( 'classic-checkout' ); + + await event.then( ( request ) => { + const data = getEventData( request, 'begin_checkout' ); + expect( data.product1 ).toEqual( { + id: simpleProductID.toString(), + nm: 'Simple product', + ca: 'Uncategorized', + qt: '1', + pr: simpleProductPrice.toString(), + } ); + expect( data.product2 ).toEqual( { + id: variableProductID.toString(), + nm: 'Variable product', + ca: 'Uncategorized', + qt: '1', + pr: '18.99', + va: 'colour: Green, size: Medium', + } ); + expect( data.cu ).toEqual( 'USD' ); + expect( data[ 'epn.value' ] ).toEqual( + ( simpleProductPrice + 18.99 ).toFixed( 2 ).toString() + ); + } ); + } ); + + test( 'Purchase event is sent on order complete page', async ( { + page, + } ) => { + // Add simple product twice, and one variable product. + await simpleProductAddToCart( page, simpleProductID ); + await simpleProductAddToCart( page, simpleProductID ); + await variableProductAddToCart( page, variableProductID ); + + const event = trackGtagEvent( page, 'purchase', 'checkout' ); + await checkout( page ); + + await event.then( ( request ) => { + const data = getEventData( request, 'purchase' ); + expect( data.product1 ).toEqual( { + id: simpleProductID.toString(), + nm: 'Simple product', + ca: 'Uncategorized', + qt: '2', + pr: simpleProductPrice.toString(), + } ); + expect( data.product2 ).toEqual( { + id: variableProductID.toString(), + nm: 'Variable product', + ca: 'Uncategorized', + qt: '1', + pr: '18.99', + va: 'colour: Green, size: Medium', + } ); + + const total = simpleProductPrice + simpleProductPrice + 18.99; + expect( data.cu ).toEqual( 'USD' ); + expect( data[ 'epn.value' ] ).toEqual( + total.toFixed( 2 ).toString() + ); + } ); + } ); +} ); diff --git a/tests/e2e/specs/integration-settings.test.js b/tests/e2e/specs/integration-settings.test.js new file mode 100644 index 00000000..7c76a907 --- /dev/null +++ b/tests/e2e/specs/integration-settings.test.js @@ -0,0 +1,18 @@ +/** + * External dependencies + */ +const { test, expect } = require( '@playwright/test' ); + +test.use( { storageState: process.env.ADMINSTATE } ); + +test( 'Able to setup in WooCommerce > Settings > Integration', async ( { + page, +} ) => { + await page.goto( + '/wp-admin/admin.php?page=wc-settings&tab=integration§ion=google_analytics' + ); + + await expect( + page.getByRole( 'heading', { name: 'Google Analytics' } ) + ).toBeVisible(); +} ); diff --git a/tests/e2e/specs/js-scripts/admin-user.test.js b/tests/e2e/specs/js-scripts/admin-user.test.js new file mode 100644 index 00000000..659e3134 --- /dev/null +++ b/tests/e2e/specs/js-scripts/admin-user.test.js @@ -0,0 +1,41 @@ +/** + * External dependencies + */ +const { test, expect } = require( '@playwright/test' ); + +/** + * Internal dependencies + */ +import { setSettings, clearSettings } from '../../utils/api'; + +test.use( { storageState: process.env.ADMINSTATE } ); + +test.describe( 'JavaScript loaded', () => { + test.beforeAll( async () => { + await setSettings(); + } ); + + test.afterAll( async () => { + await clearSettings(); + } ); + + test( 'No tracking for logged in admin user', async ( { page } ) => { + await page.goto( 'shop' ); + + await expect( + page.locator( + '#woocommerce-google-analytics-integration-js-before' + ) + ).not.toBeAttached(); + + await expect( + page.locator( '#woocommerce-google-analytics-integration-js' ) + ).not.toBeAttached(); + + await expect( + page.locator( + '#woocommerce-google-analytics-integration-data-js-after' + ) + ).not.toBeAttached(); + } ); +} ); diff --git a/tests/e2e/specs/js-scripts/customer-logged-in.test.js b/tests/e2e/specs/js-scripts/customer-logged-in.test.js new file mode 100644 index 00000000..63ed0e24 --- /dev/null +++ b/tests/e2e/specs/js-scripts/customer-logged-in.test.js @@ -0,0 +1,57 @@ +/** + * External dependencies + */ +const { test, expect } = require( '@playwright/test' ); + +/** + * Internal dependencies + */ +import { setSettings, clearSettings } from '../../utils/api'; +import { getEventData, trackGtagEvent } from '../../utils/track-event'; + +test.use( { storageState: process.env.CUSTOMERSTATE } ); + +test.describe( 'JavaScript loaded', () => { + test.beforeAll( async () => { + await setSettings(); + } ); + + test.afterAll( async () => { + await clearSettings(); + } ); + + test( 'Tracking loaded for logged in customer', async ( { page } ) => { + await page.goto( 'shop' ); + + await expect( + page.locator( + '#woocommerce-google-analytics-integration-js-before' + ) + ).toBeAttached(); + + await expect( + page.locator( '#woocommerce-google-analytics-integration-js' ) + ).toBeAttached(); + + await expect( + page.locator( + '#woocommerce-google-analytics-integration-data-js-after' + ) + ).toBeAttached(); + } ); + + test( 'Page view event is sent for logged in customer', async ( { + page, + } ) => { + const event = trackGtagEvent( page, 'page_view' ); + + await page.goto( 'shop' ); + + await event.then( ( request ) => { + const data = getEventData( request, 'page_view' ); + + // Confirm we are tracking a logged in user. + expect( data[ 'ep.logged_in' ] ).toEqual( 'true' ); + } ); + } ); +} ); diff --git a/tests/e2e/specs/js-scripts/guest.test.js b/tests/e2e/specs/js-scripts/guest.test.js new file mode 100644 index 00000000..0eebdd1c --- /dev/null +++ b/tests/e2e/specs/js-scripts/guest.test.js @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +const { test, expect } = require( '@playwright/test' ); + +/** + * Internal dependencies + */ +import { setSettings, clearSettings } from '../../utils/api'; + +test.describe( 'JavaScript loaded', () => { + test.beforeAll( async () => { + await setSettings(); + } ); + + test.afterAll( async () => { + await clearSettings(); + } ); + + test( 'Tracking loaded for guest customer', async ( { page } ) => { + await page.goto( 'shop' ); + + await expect( + page.locator( + '#woocommerce-google-analytics-integration-js-before' + ) + ).toBeAttached(); + + await expect( + page.locator( '#woocommerce-google-analytics-integration-js' ) + ).toBeAttached(); + + await expect( + page.locator( + '#woocommerce-google-analytics-integration-data-js-after' + ) + ).toBeAttached(); + } ); +} ); diff --git a/tests/e2e/test-data/test-data.php b/tests/e2e/test-data/test-data.php new file mode 100644 index 00000000..56fa8daf --- /dev/null +++ b/tests/e2e/test-data/test-data.php @@ -0,0 +1,73 @@ + 'POST', + 'callback' => __NAMESPACE__ . '\set_settings', + 'permission_callback' => __NAMESPACE__ . '\permissions', + ], + [ + 'methods' => 'DELETE', + 'callback' => __NAMESPACE__ . '\clear_settings', + 'permission_callback' => __NAMESPACE__ . '\permissions', + ], + ], + ); +} + +/** + * Set the settings to enable tracking. + */ +function set_settings() { + update_option( + 'woocommerce_google_analytics_settings', + [ + 'ga_product_identifier' => 'product_id', + 'ga_id' => 'G-ABCD123', + 'ga_support_display_advertising' => 'no', + 'ga_404_tracking_enabled' => 'yes', + 'ga_linker_allow_incoming_enabled' => 'no', + 'ga_ecommerce_tracking_enabled' => 'yes', + 'ga_event_tracking_enabled' => 'yes', + 'ga_enhanced_ecommerce_tracking_enabled' => 'yes', + 'ga_enhanced_remove_from_cart_enabled' => 'yes', + 'ga_enhanced_product_impression_enabled' => 'yes', + 'ga_enhanced_product_click_enabled' => 'yes', + 'ga_enhanced_product_detail_view_enabled' => 'yes', + 'ga_enhanced_checkout_process_enabled' => 'yes', + 'ga_linker_cross_domains' => '', + ] + ); +} + +/** + * Clear the previously set settings. + */ +function clear_settings() { + delete_option( 'woocommerce_google_analytics_settings' ); +} + +/** + * Check permissions for API requests. + */ +function permissions() { + return current_user_can( 'manage_woocommerce' ); +} diff --git a/tests/e2e/test-snippets/test-snippets.php b/tests/e2e/test-snippets/test-snippets.php new file mode 100644 index 00000000..6c5f6aef --- /dev/null +++ b/tests/e2e/test-snippets/test-snippets.php @@ -0,0 +1,25 @@ + response.data.id ); +} + +/** + * Creates a variable product. + * + * @return {number} Product ID of the created product. + */ +export async function createVariableProduct() { + const parentID = await api() + .post( 'products', config.products.variable ) + .then( ( response ) => response.data.id ); + + config.products.variations.map( async ( variation ) => { + await api().post( `products/${ parentID }/variations`, variation ); + } ); + + return parentID; +} + +/** + * Set test settings. + */ +export async function setSettings() { + await api().post( 'ga4w-test/settings' ); +} + +/** + * Clear test settings. + */ +export async function clearSettings() { + await api().delete( 'ga4w-test/settings' ); +} diff --git a/tests/e2e/utils/constants.js b/tests/e2e/utils/constants.js new file mode 100644 index 00000000..ea8e39ab --- /dev/null +++ b/tests/e2e/utils/constants.js @@ -0,0 +1,3 @@ +export const LOAD_STATE = { + DOM_CONTENT_LOADED: 'domcontentloaded', +}; diff --git a/tests/e2e/utils/create-page.js b/tests/e2e/utils/create-page.js new file mode 100644 index 00000000..4822ebbb --- /dev/null +++ b/tests/e2e/utils/create-page.js @@ -0,0 +1,120 @@ +/** + * External dependencies + */ +import { cleanForSlug } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import { apiWP } from './api'; + +/** + * Check if a page exists from a title. + * + * @param {string} title + * @return {Promise} Existing page ID. + */ +export async function pageExistsByTitle( title ) { + const slug = cleanForSlug( title ); + + return await apiWP() + .get( `pages?slug=${ slug }` ) + .then( ( response ) => response.data[ 0 ]?.id ); +} + +/** + * Creates a WP page with content and title. + * + * @param {string} title + * @param {string} content + * + * @return {Promise} Created page ID. + */ +export async function createPage( title, content ) { + return await apiWP() + .post( 'pages', { + title, + content, + status: 'publish', + } ) + .then( ( response ) => response.data.id ); +} + +/** + * Creates a classic cart page using shortcodes. + * + * @return {number} Created page ID. + */ +export async function createClassicCartPage() { + const title = 'Classic Cart'; + const content = '[woocommerce_cart]'; + + return ( + ( await pageExistsByTitle( title ) ) || + ( await createPage( title, content ) ) + ); +} + +/** + * Creates a classic checkout page using shortcodes. + * + * @return {number} Created page ID. + */ +export async function createClassicCheckoutPage() { + const title = 'Classic Checkout'; + const content = '[woocommerce_checkout]'; + + return ( + ( await pageExistsByTitle( title ) ) || + ( await createPage( title, content ) ) + ); +} + +/** + * Creates a classic shop page using shortcodes. + * + * @return {number} Created page ID. + */ +export async function createClassicShopPage() { + const title = 'Classic Shop'; + const content = '[products]'; + + return ( + ( await pageExistsByTitle( title ) ) || + ( await createPage( title, content ) ) + ); +} + +/** + * Creates a shop page using the Product Collection block. + * + * @return {number} Created page ID. + */ +export async function createProductCollectionBlockShopPage() { + const { + title, + pageContent, + } = require( './fixtures/product-collection.fixture.json' ); + + return ( + ( await pageExistsByTitle( title ) ) || + ( await createPage( title, pageContent ) ) + ); +} + +/** + * Creates a shop page using the Products block. + * + * @return {number} Created page ID. + */ +export async function createProductsBlockShopPage() { + const { + title, + pageContent, + } = require( './fixtures/products.fixture.json' ); + + return ( + ( await pageExistsByTitle( title ) ) || + ( await createPage( title, pageContent ) ) + ); +} diff --git a/tests/e2e/utils/customer.js b/tests/e2e/utils/customer.js new file mode 100644 index 00000000..1c3da019 --- /dev/null +++ b/tests/e2e/utils/customer.js @@ -0,0 +1,138 @@ +/** + * Helper functions for handling the cart. + * + * @typedef { import( '@playwright/test' ).Page } Page + */ + +/** + * External dependencies + */ +const { expect } = require( '@playwright/test' ); + +/** + * Internal dependencies + */ +import { LOAD_STATE } from './constants'; +const config = require( '../config/default.json' ); + +/** + * Adds a simple product to the cart. + * + * @param {Page} page + * @param {number} productID + */ +export async function simpleProductAddToCart( page, productID ) { + await page.goto( `?p=${ productID }` ); + + const addToCart = '.single_add_to_cart_button'; + await page.locator( addToCart ).first().click(); + await expect( + page.getByText( 'has been added to your cart' ) + ).toBeVisible(); + + // Wait till all tracking event request have been sent after page reloaded. + await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); +} + +/** + * Adds a variable product to the cart. + * + * @param {Page} page + * @param {number} productID + */ +export async function variableProductAddToCart( page, productID ) { + await page.goto( `?p=${ productID }` ); + + // Default attributes are set, so we just need to wait for the add to cart button to be enabled. + await page.waitForTimeout( 3000 ); + + const addToCart = '.single_add_to_cart_button:not(.disabled)'; + await page.locator( addToCart ).click(); + + await expect( + page.getByText( 'has been added to your cart' ) + ).toBeVisible(); + + // Wait till all tracking event request have been sent after page reloaded. + await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED ); +} + +/** + * Adds a related product to the cart. + * + * @param {Page} page + * + * @return {number} Product ID of the added product. + */ +export async function relatedProductAddToCart( page ) { + const addToCart = ( await page.locator( '.related.products' ).isVisible() ) + ? '.related.products .add_to_cart_button' + : '.wp-block-woocommerce-related-products .add_to_cart_button'; + + const addToCartButton = await page.locator( addToCart ).first(); + await addToCartButton.click(); + await expect( addToCartButton.getByText( '1 in cart' ) ).toBeVisible(); + return await page.$eval( addToCart, ( el ) => el.dataset.product_id ); +} + +/** + * Add a product to the cart from a block shop page. + * + * Note: This function will match any product type, so it should not be used for + * products that can not be added directly from the shop page. + * + * @param {Page} page + * @param {number} productID + */ +export async function blockProductAddToCart( page, productID ) { + const addToCart = `[data-product_id="${ productID }"]`; + const addToCartButton = await page.locator( addToCart ).first(); + await addToCartButton.click(); + await expect( addToCartButton.getByText( '1 in cart' ) ).toBeVisible(); +} + +/** + * Perform checkout steps to purchase a product. + * + * @param {Page} page + */ +export async function checkout( page ) { + const user = config.addresses.customer.billing; + + await page.goto( 'checkout' ); + + if ( await page.locator( '#billing_first_name' ).isVisible() ) { + await page.locator( '#billing_first_name' ).fill( user.firstname ); + await page.locator( '#billing_last_name' ).fill( user.lastname ); + await page + .locator( '#billing_address_1' ) + .fill( user.addressfirstline ); + await page.locator( '#billing_city' ).fill( user.city ); + await page.locator( '#billing_state' ).selectOption( user.state ); + await page.locator( '#billing_postcode' ).fill( user.postcode ); + await page.locator( '#billing_phone' ).fill( user.phone ); + await page.locator( '#billing_email' ).fill( user.email ); + + await page.locator( 'text=Cash on delivery' ).click(); + await expect( page.locator( 'div.payment_method_cod' ) ).toBeVisible(); + } else { + await page.getByLabel( 'Email address' ).fill( user.email ); + await page.getByLabel( 'First name' ).fill( user.firstname ); + await page.getByLabel( 'Last name' ).fill( user.lastname ); + await page + .getByLabel( 'Address', { exact: true } ) + .fill( user.addressfirstline ); + await page.getByLabel( 'City' ).fill( user.city ); + await page.getByLabel( 'ZIP Code' ).fill( user.postcode ); + await page.locator( '#billing-state input' ).fill( user.statename ); + } + + //TODO: See if there's an alternative method to click the button without relying on waitForTimeout. + await page.waitForTimeout( 3000 ); + + await page.locator( 'text=Place order' ).click(); + + await expect( + page.locator( '.wc-block-order-confirmation-status' ) + ).toContainText( 'order has been received' ); +} diff --git a/tests/e2e/utils/fixtures/product-collection.fixture.json b/tests/e2e/utils/fixtures/product-collection.fixture.json new file mode 100644 index 00000000..32e386ef --- /dev/null +++ b/tests/e2e/utils/fixtures/product-collection.fixture.json @@ -0,0 +1 @@ +{"title":"Product Collection Block Shop","pageContent":"\n
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
\n

No results found

\n\n\n\n

You can try clearing any filters or head to our store's home

\n
\n\n
\n"} \ No newline at end of file diff --git a/tests/e2e/utils/fixtures/products.fixture.json b/tests/e2e/utils/fixtures/products.fixture.json new file mode 100644 index 00000000..a8abcf3a --- /dev/null +++ b/tests/e2e/utils/fixtures/products.fixture.json @@ -0,0 +1 @@ +{"title":"Products Block Shop","pageContent":"\n
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n

\n\n
\n"} \ No newline at end of file diff --git a/tests/e2e/utils/track-event.js b/tests/e2e/utils/track-event.js new file mode 100644 index 00000000..1167b7f5 --- /dev/null +++ b/tests/e2e/utils/track-event.js @@ -0,0 +1,111 @@ +/** + * Tracking of Gtag events. + * + * @typedef { import( '@playwright/test' ).Request } Request + * @typedef { import( '@playwright/test' ).Page } Page + */ + +/** + * Returns the data for a specific event from a set of multiple events seperated by newlines. + * + * @param {string} data All event data combined. + * @param {string} eventName Event name to match. + * + * @return {Object} Single event data. + */ +function parseMultipleEvents( data, eventName ) { + const events = data.split( '\r\n' ).map( ( eventData ) => { + return Object.fromEntries( new URLSearchParams( eventData ).entries() ); + } ); + + return events.find( ( e ) => e.en === eventName ); +} + +/** + * Splits the product data using the ~ seperator and uses the first 2 characters as the key. + * + * @param {string} data + * @return {Object} Product data split into key value pairs. + */ +function splitProductData( data ) { + return Object.fromEntries( + data.split( '~' ).map( ( pair ) => { + return [ pair.slice( 0, 2 ), pair.slice( 2 ) ]; + } ) + ); +} + +/** + * Tracks when the Gtag Event request matching a specific name is sent. + * + * @param {Page} page + * @param {string} eventName Event name to match. + * @param {string|null} urlPath The starting path to match where the event should be triggered. + * + * @return {Promise} Matching request. + */ +export function trackGtagEvent( page, eventName, urlPath = null ) { + const eventPath = 'google-analytics.com/g/collect'; + return page.waitForRequest( ( request ) => { + const url = request.url(); + const pathMatches = url.includes( eventPath ); + + // Return early if the path doesn't match the request we expect. + if ( ! pathMatches ) { + return false; + } + + const params = new URL( url ).searchParams; + const pageUrl = new URL( page.url() ); + const urlPathMatches = urlPath + ? params + .get( 'dl' ) + ?.includes( + `${ pageUrl.protocol }//${ pageUrl.hostname }/${ urlPath }` + ) + : true; + + // Match a single event sent in query parameters. + if ( params.get( 'en' ) ) { + return params.get( 'en' ) === eventName && urlPathMatches; + } + + // Match multiple events sent in the body. + const event = parseMultipleEvents( request.postData(), eventName ); + return event && urlPathMatches; + } ); +} + +/** + * Retrieve data from a Gtag event. + * + * @param {Request} request + * @param {string} eventName Event name to match. + * + * @return {Object} Data sent with the event. + */ +export function getEventData( request, eventName ) { + const url = new URL( request.url() ); + const params = new URLSearchParams( url.search ); + let data = Object.fromEntries( params.entries() ); + + // If event name is not present then find matching event in body. + if ( ! data.en ) { + data = { + ...data, + ...parseMultipleEvents( request.postData(), eventName ), + }; + } + + // Split data for first product. + if ( data.pr1 ) { + data.product1 = splitProductData( data.pr1 ); + } + + // Split data for second product. + if ( data.pr2 ) { + data.product2 = splitProductData( data.pr2 ); + } + + return data; +}