diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 0709194d35eab..79ebf67416e34 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -142,8 +142,6 @@ jobs: contents: read timeout-minutes: 20 if: ${{ github.repository == 'WordPress/wordpress-develop' || github.event_name == 'pull_request' }} - env: - PUPPETEER_SKIP_DOWNLOAD: ${{ true }} steps: - name: Checkout repository diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index 42eab6ff447da..64001ad5cafd5 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -42,11 +42,13 @@ jobs: # - Sets up Node.js. # - Logs debug information about the GitHub Action runner. # - Installs npm dependencies. + # - Install Playwright browsers. # - Builds WordPress to run from the `build` directory. # - Starts the WordPress Docker container. # - Logs the running Docker containers. # - Logs Docker debug information (about both the Docker installation within the runner and the WordPress container). # - Install WordPress within the Docker container. + # - Install Gutenberg. # - Run the E2E tests. # - Ensures version-controlled files are not modified or deleted. e2e-tests: @@ -90,6 +92,9 @@ jobs: - name: Install npm Dependencies run: npm ci + - name: Install Playwright browsers + run: npx playwright install --with-deps + - name: Build WordPress run: npm run build @@ -115,6 +120,9 @@ jobs: LOCAL_SCRIPT_DEBUG: ${{ matrix.LOCAL_SCRIPT_DEBUG }} run: npm run env:install + - name: Install Gutenberg + run: npm run env:cli -- plugin install gutenberg --path=/var/www/${{ env.LOCAL_DIR }} + - name: Run E2E tests run: npm run test:e2e @@ -129,6 +137,22 @@ jobs: - name: Ensure version-controlled files are not modified or deleted run: git diff --exit-code + slack-notifications: + name: Slack Notifications + uses: WordPress/wordpress-develop/.github/workflows/slack-notifications.yml@trunk + permissions: + actions: read + contents: read + needs: [ e2e-tests ] + if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} + with: + calling_status: ${{ contains( needs.*.result, 'cancelled' ) && 'cancelled' || contains( needs.*.result, 'failure' ) && 'failure' || 'success' }} + secrets: + SLACK_GHA_SUCCESS_WEBHOOK: ${{ secrets.SLACK_GHA_SUCCESS_WEBHOOK }} + SLACK_GHA_CANCELLED_WEBHOOK: ${{ secrets.SLACK_GHA_CANCELLED_WEBHOOK }} + SLACK_GHA_FIXED_WEBHOOK: ${{ secrets.SLACK_GHA_FIXED_WEBHOOK }} + SLACK_GHA_FAILURE_WEBHOOK: ${{ secrets.SLACK_GHA_FAILURE_WEBHOOK }} + failed-workflow: name: Failed workflow tasks runs-on: ubuntu-latest @@ -141,7 +165,8 @@ jobs: github.event_name != 'pull_request' && github.run_attempt < 2 && ( - needs.e2e-tests.result == 'cancelled' || needs.e2e-tests.result == 'failure' + contains( needs.*.result, 'cancelled' ) || + contains( needs.*.result, 'failure' ) ) steps: - name: Dispatch workflow run diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index 4ec15b95913af..d936c62d19ae4 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -31,10 +31,10 @@ permissions: {} env: # Performance testing should be performed in an environment reflecting a standard production environment. - WP_DEBUG: false - SCRIPT_DEBUG: false - SAVEQUERIES : false - WP_DEVELOPMENT_MODE: '' + LOCAL_WP_DEBUG: false + LOCAL_SCRIPT_DEBUG: false + LOCAL_SAVEQUERIES: false + LOCAL_WP_DEVELOPMENT_MODE: "''" # This workflow takes two sets of measurements — one for the current commit, # and another against a consistent version that is used as a baseline measurement. @@ -56,6 +56,7 @@ jobs: # - Set up Node.js. # - Log debug information. # - Install npm dependencies. + # - Install Playwright browsers. # - Build WordPress. # - Start Docker environment. # - Log running Docker containers. @@ -73,6 +74,7 @@ jobs: # - Run performance tests (previous/target commit). # - Print target performance tests results. # - Reset to original commit. + # - Install npm dependencies. # - Set the environment to the baseline version. # - Run baseline performance tests. # - Print baseline performance tests results. @@ -119,6 +121,9 @@ jobs: - name: Install npm dependencies run: npm ci + - name: Install Playwright browsers + run: npx playwright install --with-deps + - name: Build WordPress run: npm run build @@ -182,24 +187,35 @@ jobs: run: npm run build - name: Run target performance tests (base/previous commit) - run: npm run test:performance -- --prefix=before + env: + TEST_RESULTS_PREFIX: before + run: npm run test:performance - name: Print target performance tests results - run: node ./tests/performance/results.js --prefix=before + env: + TEST_RESULTS_PREFIX: before + run: node ./tests/performance/results.js - name: Reset to original commit run: git reset --hard $GITHUB_SHA + - name: Install npm dependencies + run: npm ci + - name: Set the environment to the baseline version run: | npm run env:cli -- core update --version=${{ env.BASE_TAG }} --force --path=/var/www/${{ env.LOCAL_DIR }} npm run env:cli -- core version --path=/var/www/${{ env.LOCAL_DIR }} - name: Run baseline performance tests - run: npm run test:performance -- --prefix=base + env: + TEST_RESULTS_PREFIX: base + run: npm run test:performance - name: Print baseline performance tests results - run: node ./tests/performance/results.js --prefix=base + env: + TEST_RESULTS_PREFIX: base + run: node ./tests/performance/results.js - name: Compare results with base run: node ./tests/performance/compare-results.js ${{ runner.temp }}/summary.md diff --git a/.github/workflows/phpunit-tests-run.yml b/.github/workflows/phpunit-tests-run.yml index 871585d572d35..eb3eab76e7617 100644 --- a/.github/workflows/phpunit-tests-run.yml +++ b/.github/workflows/phpunit-tests-run.yml @@ -51,7 +51,6 @@ env: LOCAL_DB_VERSION: ${{ inputs.db-version }} LOCAL_PHP_MEMCACHED: ${{ inputs.memcached }} PHPUNIT_CONFIG: ${{ inputs.phpunit-config }} - PUPPETEER_SKIP_DOWNLOAD: ${{ true }} jobs: # Runs the PHPUnit tests for WordPress. diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml index cd00e9b7f0359..5e963900fb08f 100644 --- a/.github/workflows/test-coverage.yml +++ b/.github/workflows/test-coverage.yml @@ -29,7 +29,6 @@ on: permissions: {} env: - PUPPETEER_SKIP_DOWNLOAD: ${{ true }} LOCAL_PHP: '7.4-fpm' LOCAL_PHP_XDEBUG: true LOCAL_PHP_XDEBUG_MODE: 'coverage' diff --git a/.github/workflows/test-npm.yml b/.github/workflows/test-npm.yml index d53c8ec825690..185908f7943db 100644 --- a/.github/workflows/test-npm.yml +++ b/.github/workflows/test-npm.yml @@ -37,9 +37,6 @@ concurrency: # Any needed permissions should be configured at the job level. permissions: {} -env: - PUPPETEER_SKIP_DOWNLOAD: ${{ true }} - jobs: # Verifies that installing npm dependencies and building WordPress works as expected. # diff --git a/.gitignore b/.gitignore index 596abbaa6432d..0a02b30a1548d 100644 --- a/.gitignore +++ b/.gitignore @@ -100,4 +100,4 @@ wp-tests-config.php /docker-compose.override.yml # Visual regression test diffs -tests/visual-regression/specs/__image_snapshots__ +tests/visual-regression/specs/__snapshots__ diff --git a/package-lock.json b/package-lock.json index 35a0bd2bfd64f..eba4628e2b018 100644 --- a/package-lock.json +++ b/package-lock.json @@ -106,10 +106,12 @@ }, "devDependencies": { "@lodder/grunt-postcss": "^3.1.1", + "@playwright/test": "1.32.0", "@pmmmwh/react-refresh-webpack-plugin": "0.5.5", "@wordpress/babel-preset-default": "7.26.6", "@wordpress/dependency-extraction-webpack-plugin": "4.25.6", "@wordpress/e2e-test-utils": "10.13.6", + "@wordpress/e2e-test-utils-playwright": "0.11.0", "@wordpress/scripts": "26.13.6", "autoprefixer": "10.4.16", "chalk": "5.3.0", @@ -3743,6 +3745,25 @@ "node": ">=8" } }, + "node_modules/@playwright/test": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.32.0.tgz", + "integrity": "sha512-zOdGloaF0jeec7hqoLqM5S3L2rR4WxMJs6lgiAeR70JlH7Ml54ZPoIIf3X7cvnKde3Q9jJ/gaxkFh8fYI9s1rg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "playwright-core": "1.32.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.5", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.5.tgz", @@ -7011,14 +7032,14 @@ } }, "node_modules/@wordpress/e2e-test-utils-playwright": { - "version": "0.10.6", - "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-0.10.6.tgz", - "integrity": "sha512-qUIcQTB4lFG6BUVCPtzs4gDeO/9Pzz1Knq3Uvt1QIYojy9Yr6G6c3f3Mudql+HFfiXoj3B3BxGbA4oLSb7bI6w==", + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-0.11.0.tgz", + "integrity": "sha512-UxDkVvm24FJdi4nkn5+n9XirYxdJ1QDZgnHotdrgGRel8NOvlEOlhmT/xpuAPQrVwo+yynxEKeb1Y2AT6jX9og==", "dev": true, "dependencies": { - "@wordpress/api-fetch": "^6.39.6", - "@wordpress/keycodes": "^3.42.6", - "@wordpress/url": "^3.43.6", + "@wordpress/api-fetch": "^6.40.0", + "@wordpress/keycodes": "^3.43.0", + "@wordpress/url": "^3.44.0", "change-case": "^4.1.2", "form-data": "^4.0.0", "get-port": "^5.1.1", @@ -7032,6 +7053,79 @@ "@playwright/test": ">=1" } }, + "node_modules/@wordpress/e2e-test-utils-playwright/node_modules/@wordpress/api-fetch": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-6.40.0.tgz", + "integrity": "sha512-sNk6vZW02ldci1EpNIjmm61323x/0n2Ra/cDHuehZf8avOH/OV0zF0dXxttT8M9Fncz+XZDSIHopm76dU3Phug==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/i18n": "^4.43.0", + "@wordpress/url": "^3.44.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@wordpress/e2e-test-utils-playwright/node_modules/@wordpress/hooks": { + "version": "3.43.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-3.43.0.tgz", + "integrity": "sha512-SHSiyFUEsggihl0pDvY1l72q+fHMDyFHtIR3GCt0uV2ifctvoa/PIYdVwrxpGQaGdNEV25XCZ4kNldqJmfTddw==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.16.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@wordpress/e2e-test-utils-playwright/node_modules/@wordpress/i18n": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-4.43.0.tgz", + "integrity": "sha512-XHU/vGgI+pgjJU9WzWDHke1u948z8i3OPpKUNdxc/gMcTkKaKM4D8DW1+VMSQHyU6pneP8+ph7EF+1RIehP3lQ==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/hooks": "^3.43.0", + "gettext-parser": "^1.3.1", + "memize": "^2.1.0", + "sprintf-js": "^1.1.1", + "tannin": "^1.2.0" + }, + "bin": { + "pot-to-php": "tools/pot-to-php.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@wordpress/e2e-test-utils-playwright/node_modules/@wordpress/keycodes": { + "version": "3.43.0", + "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-3.43.0.tgz", + "integrity": "sha512-B6rYPiKFdQTlnJfm93R+usQnjEODUX/K4+hMvY5ZZOinvxe7KyU/xyFGz7gRrS8WmIEYcJowqSmAlGgVs4XwKQ==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/i18n": "^4.43.0", + "change-case": "^4.1.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@wordpress/e2e-test-utils-playwright/node_modules/@wordpress/url": { + "version": "3.44.0", + "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-3.44.0.tgz", + "integrity": "sha512-QNtTPFg/cGHTJLOvOtQCvCgn5quFQgJml8A88I05o4dyUH/tc92rb8LNXi0qcVz/z4JPrx2g3+Ki8heYellP4A==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.16.0", + "remove-accents": "^0.5.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@wordpress/e2e-test-utils-playwright/node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -7956,6 +8050,28 @@ "react-dom": "^18.0.0" } }, + "node_modules/@wordpress/scripts/node_modules/@wordpress/e2e-test-utils-playwright": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-0.10.6.tgz", + "integrity": "sha512-qUIcQTB4lFG6BUVCPtzs4gDeO/9Pzz1Knq3Uvt1QIYojy9Yr6G6c3f3Mudql+HFfiXoj3B3BxGbA4oLSb7bI6w==", + "dev": true, + "dependencies": { + "@wordpress/api-fetch": "^6.39.6", + "@wordpress/keycodes": "^3.42.6", + "@wordpress/url": "^3.43.6", + "change-case": "^4.1.2", + "form-data": "^4.0.0", + "get-port": "^5.1.1", + "lighthouse": "^10.4.0", + "mime": "^3.0.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "@playwright/test": ">=1" + } + }, "node_modules/@wordpress/scripts/node_modules/ajv": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", @@ -8099,6 +8215,20 @@ "node": ">=8" } }, + "node_modules/@wordpress/scripts/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@wordpress/scripts/node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -8158,6 +8288,18 @@ "node": ">=8" } }, + "node_modules/@wordpress/scripts/node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/@wordpress/scripts/node_modules/p-locate": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", @@ -36729,6 +36871,17 @@ } } }, + "@playwright/test": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.32.0.tgz", + "integrity": "sha512-zOdGloaF0jeec7hqoLqM5S3L2rR4WxMJs6lgiAeR70JlH7Ml54ZPoIIf3X7cvnKde3Q9jJ/gaxkFh8fYI9s1rg==", + "dev": true, + "requires": { + "@types/node": "*", + "fsevents": "2.3.2", + "playwright-core": "1.32.0" + } + }, "@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.5", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.5.tgz", @@ -39263,14 +39416,14 @@ } }, "@wordpress/e2e-test-utils-playwright": { - "version": "0.10.6", - "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-0.10.6.tgz", - "integrity": "sha512-qUIcQTB4lFG6BUVCPtzs4gDeO/9Pzz1Knq3Uvt1QIYojy9Yr6G6c3f3Mudql+HFfiXoj3B3BxGbA4oLSb7bI6w==", + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-0.11.0.tgz", + "integrity": "sha512-UxDkVvm24FJdi4nkn5+n9XirYxdJ1QDZgnHotdrgGRel8NOvlEOlhmT/xpuAPQrVwo+yynxEKeb1Y2AT6jX9og==", "dev": true, "requires": { - "@wordpress/api-fetch": "^6.39.6", - "@wordpress/keycodes": "^3.42.6", - "@wordpress/url": "^3.43.6", + "@wordpress/api-fetch": "^6.40.0", + "@wordpress/keycodes": "^3.43.0", + "@wordpress/url": "^3.44.0", "change-case": "^4.1.2", "form-data": "^4.0.0", "get-port": "^5.1.1", @@ -39278,6 +39431,61 @@ "mime": "^3.0.0" }, "dependencies": { + "@wordpress/api-fetch": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-6.40.0.tgz", + "integrity": "sha512-sNk6vZW02ldci1EpNIjmm61323x/0n2Ra/cDHuehZf8avOH/OV0zF0dXxttT8M9Fncz+XZDSIHopm76dU3Phug==", + "dev": true, + "requires": { + "@babel/runtime": "^7.16.0", + "@wordpress/i18n": "^4.43.0", + "@wordpress/url": "^3.44.0" + } + }, + "@wordpress/hooks": { + "version": "3.43.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-3.43.0.tgz", + "integrity": "sha512-SHSiyFUEsggihl0pDvY1l72q+fHMDyFHtIR3GCt0uV2ifctvoa/PIYdVwrxpGQaGdNEV25XCZ4kNldqJmfTddw==", + "dev": true, + "requires": { + "@babel/runtime": "^7.16.0" + } + }, + "@wordpress/i18n": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-4.43.0.tgz", + "integrity": "sha512-XHU/vGgI+pgjJU9WzWDHke1u948z8i3OPpKUNdxc/gMcTkKaKM4D8DW1+VMSQHyU6pneP8+ph7EF+1RIehP3lQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.16.0", + "@wordpress/hooks": "^3.43.0", + "gettext-parser": "^1.3.1", + "memize": "^2.1.0", + "sprintf-js": "^1.1.1", + "tannin": "^1.2.0" + } + }, + "@wordpress/keycodes": { + "version": "3.43.0", + "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-3.43.0.tgz", + "integrity": "sha512-B6rYPiKFdQTlnJfm93R+usQnjEODUX/K4+hMvY5ZZOinvxe7KyU/xyFGz7gRrS8WmIEYcJowqSmAlGgVs4XwKQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.16.0", + "@wordpress/i18n": "^4.43.0", + "change-case": "^4.1.2" + } + }, + "@wordpress/url": { + "version": "3.44.0", + "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-3.44.0.tgz", + "integrity": "sha512-QNtTPFg/cGHTJLOvOtQCvCgn5quFQgJml8A88I05o4dyUH/tc92rb8LNXi0qcVz/z4JPrx2g3+Ki8heYellP4A==", + "dev": true, + "requires": { + "@babel/runtime": "^7.16.0", + "remove-accents": "^0.5.0" + } + }, "form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -39957,6 +40165,22 @@ "webpack-dev-server": "^4.4.0" }, "dependencies": { + "@wordpress/e2e-test-utils-playwright": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-0.10.6.tgz", + "integrity": "sha512-qUIcQTB4lFG6BUVCPtzs4gDeO/9Pzz1Knq3Uvt1QIYojy9Yr6G6c3f3Mudql+HFfiXoj3B3BxGbA4oLSb7bI6w==", + "dev": true, + "requires": { + "@wordpress/api-fetch": "^6.39.6", + "@wordpress/keycodes": "^3.42.6", + "@wordpress/url": "^3.43.6", + "change-case": "^4.1.2", + "form-data": "^4.0.0", + "get-port": "^5.1.1", + "lighthouse": "^10.4.0", + "mime": "^3.0.0" + } + }, "ajv": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", @@ -40053,6 +40277,17 @@ "path-exists": "^4.0.0" } }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, "glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -40097,6 +40332,12 @@ "p-locate": "^4.1.0" } }, + "mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true + }, "p-locate": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", diff --git a/package.json b/package.json index 5babc9960fda1..4587765dad018 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,12 @@ ], "devDependencies": { "@lodder/grunt-postcss": "^3.1.1", + "@playwright/test": "1.32.0", "@pmmmwh/react-refresh-webpack-plugin": "0.5.5", "@wordpress/babel-preset-default": "7.26.6", "@wordpress/dependency-extraction-webpack-plugin": "4.25.6", "@wordpress/e2e-test-utils": "10.13.6", + "@wordpress/e2e-test-utils-playwright": "0.11.0", "@wordpress/scripts": "26.13.6", "autoprefixer": "10.4.16", "chalk": "5.3.0", @@ -189,10 +191,10 @@ "env:cli": "node ./tools/local-env/scripts/docker.js run cli", "env:logs": "node ./tools/local-env/scripts/docker.js logs", "env:pull": "node ./tools/local-env/scripts/docker.js pull", - "test:performance": "node ./tests/performance/run-tests.js", + "test:performance": "wp-scripts test-playwright --config tests/performance/playwright.config.js", "test:php": "node ./tools/local-env/scripts/docker.js run -T php composer update -W && node ./tools/local-env/scripts/docker.js run php ./vendor/bin/phpunit", - "test:e2e": "node ./tests/e2e/run-tests.js", - "test:visual": "node ./tests/visual-regression/run-tests.js", + "test:e2e": "wp-scripts test-playwright --config tests/e2e/playwright.config.js", + "test:visual": "wp-scripts test-playwright --config tests/visual-regression/playwright.config.js", "sync-gutenberg-packages": "grunt sync-gutenberg-packages", "postsync-gutenberg-packages": "grunt wp-packages:sync-stable-blocks && grunt build --dev && grunt build" } diff --git a/tests/e2e/README.md b/tests/e2e/README.md index fffb5e6fc4a82..1081cfbe57ed4 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -1 +1,49 @@ -# E2E Tests End-To-End (E2E) tests for WordPress. ## Running the tests The e2e tests require a production-like environment to run. By default, they will assume an environment is available at `http://localhost:8889`, with username=admin and password=password. If you don't already have an environment ready, you can set one up by following [these instructions](https://github.com/WordPress/wordpress-develop/blob/master/README.md). Then you can launch the tests by running: ``` npm run test:e2e ``` which will run the test suite using a headless browser. If your environment has a different url, username or password to the default, you can provide the base URL, username and password like this: ``` npm run test:e2e -- --wordpress-base-url=http://mycustomurl --wordpress-username=username --wordpress-password=password ``` **DO NOT run these tests in an actual production environment, as they will delete all your content.** For debugging purposes, you might want to follow the test visually. You can do so by running the tests in an interactive mode. ``` npm run test:e2e -- --puppeteer-interactive ``` You can also run a single test file separately: ``` npm run test:e2e tests/e2e/specs/hello.test.js ``` ## Documentation * Block Editor Handbook end to end testing overview: https://developer.wordpress.org/block-editor/contributors/code/testing-overview/#end-to-end-testing * Gutenberg e2e-test-utils package API docs: https://github.com/WordPress/gutenberg/tree/trunk/packages/e2e-test-utils * Puppeteer API docs: https://github.com/puppeteer/puppeteer#readme (the version we are using is indicated in the @wordpress/scripts package: https://github.com/WordPress/gutenberg/blob/trunk/packages/scripts/package.json) \ No newline at end of file +# E2E Tests + +End-To-End (E2E) tests for WordPress. + + +## Running the tests + +The e2e tests require a production-like environment to run. By default, they will assume an environment is available at `http://localhost:8889`, with username `admin` and password `password`. + +If you don't already have an environment ready, you can set one up by following [these instructions](https://github.com/WordPress/wordpress-develop/blob/master/README.md). + +Then you can launch the tests by running: + +``` +npm run test:e2e +``` + +which will run the test suite using a headless browser. + +If your environment has a different url, username or password to the default, you can provide the base URL, username and password like this: + +``` +WP_BASE_URL=http://mycustomurl WP_USERNAME=username WP_PASSWORD=password npm run test:e2e +``` +**DO NOT run these tests in an actual production environment, as they will delete all your content.** + +For debugging purposes, you might want to follow the test visually. You can do so by running the tests in an interactive mode: + +``` +npm run test:e2e -- --ui +``` + +[UI Mode](https://playwright.dev/docs/test-ui-mode) let's you explore, run and debug tests with a time travel experience complete with watch mode. +All test files are loaded into the testing sidebar where you can expand each file and describe block to individually run, view, watch and debug each test. + +You can also run a single test file separately: + +``` +npm run test:e2e tests/e2e/specs/hello.test.js +``` + + +## Documentation + +* Block Editor Handbook end to end testing overview: https://developer.wordpress.org/block-editor/contributors/code/testing-overview/#end-to-end-testing + +* Gutenberg e2e-test-utils-playwright package API docs: https://github.com/WordPress/gutenberg/tree/trunk/packages/e2e-test-utils-playwright + +* Playwright API docs: https://playwright.dev/docs (the version we are using is indicated in the `@wordpress/scripts` package: https://github.com/WordPress/gutenberg/blob/trunk/packages/scripts/package.json) diff --git a/tests/e2e/config/bootstrap.js b/tests/e2e/config/bootstrap.js deleted file mode 100644 index a9642034f5fad..0000000000000 --- a/tests/e2e/config/bootstrap.js +++ /dev/null @@ -1,145 +0,0 @@ -import { get } from 'lodash'; -import { - clearLocalStorage, - enablePageDialogAccept, - setBrowserViewport, -} from '@wordpress/e2e-test-utils'; - -/** - * Environment variables - */ -const { PUPPETEER_TIMEOUT } = process.env; - -/** - * Set of console logging types observed to protect against unexpected yet - * handled (i.e. not catastrophic) errors or warnings. Each key corresponds - * to the Puppeteer ConsoleMessage type, its value the corresponding function - * on the console global object. - * - * @type {Object} - */ -const OBSERVED_CONSOLE_MESSAGE_TYPES = { - warning: 'warn', - error: 'error', -}; - -/** - * Array of page event tuples of [ eventName, handler ]. - * - * @type {Array} - */ -const pageEvents = []; - -// The Jest timeout is increased because these tests are a bit slow -jest.setTimeout( PUPPETEER_TIMEOUT || 100000 ); - - -/** - * Adds an event listener to the page to handle additions of page event - * handlers, to assure that they are removed at test teardown. - */ -function capturePageEventsForTearDown() { - page.on( 'newListener', ( eventName, listener ) => { - pageEvents.push( [ eventName, listener ] ); - } ); -} - -/** - * Removes all bound page event handlers. - */ -function removePageEvents() { - pageEvents.forEach( ( [ eventName, handler ] ) => { - page.removeListener( eventName, handler ); - } ); -} - -/** - * Adds a page event handler to emit uncaught exception to process if one of - * the observed console logging types is encountered. - */ -function observeConsoleLogging() { - page.on( 'console', ( message ) => { - const type = message.type(); - if ( ! OBSERVED_CONSOLE_MESSAGE_TYPES.hasOwnProperty( type ) ) { - return; - } - - let text = message.text(); - - // An exception is made for _blanket_ deprecation warnings: Those - // which log regardless of whether a deprecated feature is in use. - if ( text.includes( 'This is a global warning' ) ) { - return; - } - - // An exception is made for jQuery migrate console warnings output by - // the unminified script loaded in development environments. - if ( text.includes( 'JQMIGRATE' ) ) { - return; - } - - // Viewing posts on the front end can result in this error, which - // has nothing to do with Gutenberg. - if ( text.includes( 'net::ERR_UNKNOWN_URL_SCHEME' ) ) { - return; - } - - // A bug present in WordPress 5.2 will produce console warnings when - // loading the Dashicons font. These can be safely ignored, as they do - // not otherwise regress on application behavior. This logic should be - // removed once the associated ticket has been closed. - // - // See: https://core.trac.wordpress.org/ticket/47183 - if ( - text.startsWith( 'Failed to decode downloaded font:' ) || - text.startsWith( 'OTS parsing error:' ) - ) { - return; - } - - const logFunction = OBSERVED_CONSOLE_MESSAGE_TYPES[ type ]; - - // As of Puppeteer 1.6.1, `message.text()` wrongly returns an object of - // type JSHandle for error logging, instead of the expected string. - // - // See: https://github.com/GoogleChrome/puppeteer/issues/3397 - // - // The recommendation there to asynchronously resolve the error value - // upon a console event may be prone to a race condition with the test - // completion, leaving a possibility of an error not being surfaced - // correctly. Instead, the logic here synchronously inspects the - // internal object shape of the JSHandle to find the error text. If it - // cannot be found, the default text value is used instead. - text = get( message.args(), [ 0, '_remoteObject', 'description' ], text ); - - // Disable reason: We intentionally bubble up the console message - // which, unless the test explicitly anticipates the logging via - // @wordpress/jest-console matchers, will cause the intended test - // failure. - - // eslint-disable-next-line no-console - console[ logFunction ]( text ); - } ); -} - -// Before every test suite run, delete all content created by the test. This ensures -// other posts/comments/etc. aren't dirtying tests and tests don't depend on -// each other's side-effects. -beforeAll( async () => { - capturePageEventsForTearDown(); - enablePageDialogAccept(); - observeConsoleLogging(); - await page.emulateMediaFeatures( [ - { name: 'prefers-reduced-motion', value: 'reduce' }, - ] ); - await setBrowserViewport( 'large' ); -} ); - -afterEach( async () => { - await clearLocalStorage(); - await setBrowserViewport( 'large' ); -} ); - -afterAll( () => { - removePageEvents(); -} ); diff --git a/tests/e2e/config/global-setup.js b/tests/e2e/config/global-setup.js new file mode 100644 index 0000000000000..0c8063cf1a5a5 --- /dev/null +++ b/tests/e2e/config/global-setup.js @@ -0,0 +1,43 @@ +/** + * External dependencies + */ +import { request } from '@playwright/test'; + +/** + * WordPress dependencies + */ +import { RequestUtils } from '@wordpress/e2e-test-utils-playwright'; + +/** + * + * @param {import('@playwright/test').FullConfig} config + * @returns {Promise} + */ +async function globalSetup( config ) { + const { storageState, baseURL } = config.projects[ 0 ].use; + const storageStatePath = + typeof storageState === 'string' ? storageState : undefined; + + const requestContext = await request.newContext( { + baseURL, + } ); + + const requestUtils = new RequestUtils( requestContext, { + storageStatePath, + } ); + + // Authenticate and save the storageState to disk. + await requestUtils.setupRest(); + + // Reset the test environment before running the tests. + await Promise.all( [ + requestUtils.activateTheme( 'twentytwentyone' ), + requestUtils.deleteAllPosts(), + requestUtils.deleteAllBlocks(), + requestUtils.resetPreferences(), + ] ); + + await requestContext.dispose(); +} + +export default globalSetup; diff --git a/tests/e2e/jest.config.js b/tests/e2e/jest.config.js deleted file mode 100644 index c0b5ca35e1945..0000000000000 --- a/tests/e2e/jest.config.js +++ /dev/null @@ -1,10 +0,0 @@ -const config = require( '@wordpress/scripts/config/jest-e2e.config' ); - -const jestE2EConfig = { - ...config, - setupFilesAfterEnv: [ - '/config/bootstrap.js', - ], -}; - -module.exports = jestE2EConfig; diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js new file mode 100644 index 0000000000000..0de694e324c24 --- /dev/null +++ b/tests/e2e/playwright.config.js @@ -0,0 +1,27 @@ +/** + * External dependencies + */ +import path from 'node:path'; +import { defineConfig } from '@playwright/test'; + +/** + * WordPress dependencies + */ +const baseConfig = require( '@wordpress/scripts/config/playwright.config' ); + +process.env.WP_ARTIFACTS_PATH ??= path.join( process.cwd(), 'artifacts' ); +process.env.STORAGE_STATE_PATH ??= path.join( + process.env.WP_ARTIFACTS_PATH, + 'storage-states/admin.json' +); + +const config = defineConfig( { + ...baseConfig, + globalSetup: require.resolve( './config/global-setup.js' ), + webServer: { + ...baseConfig.webServer, + command: 'npm run env:start', + }, +} ); + +export default config; diff --git a/tests/e2e/run-tests.js b/tests/e2e/run-tests.js deleted file mode 100644 index d52a56f221a56..0000000000000 --- a/tests/e2e/run-tests.js +++ /dev/null @@ -1,13 +0,0 @@ -const dotenv = require( 'dotenv' ); -const dotenv_expand = require( 'dotenv-expand' ); -const { execSync } = require( 'child_process' ); - -// WP_BASE_URL interpolates LOCAL_PORT, so needs to be parsed by dotenv_expand(). -dotenv_expand.expand( dotenv.config() ); - -// Run the tests, passing additional arguments through to the test script. -execSync( - 'wp-scripts test-e2e --config tests/e2e/jest.config.js ' + - process.argv.slice( 2 ).join( ' ' ), - { stdio: 'inherit' } -); diff --git a/tests/e2e/specs/cache-control-headers-directives.test.js b/tests/e2e/specs/cache-control-headers-directives.test.js index f451e251721a6..427188915098a 100644 --- a/tests/e2e/specs/cache-control-headers-directives.test.js +++ b/tests/e2e/specs/cache-control-headers-directives.test.js @@ -1,38 +1,46 @@ -import { - visitAdminPage, - createNewPost, - publishPost, - trashAllPosts, - createURL, - logout, -} from "@wordpress/e2e-test-utils"; - -describe( 'Cache Control header directives', () => { - - beforeEach( async () => { - await trashAllPosts(); - } ); - - it( 'No private directive present in cache control when user not logged in.', async () => { - await createNewPost( { title: 'Hello World' } ); - await publishPost(); - await logout(); - - const response = await page.goto( createURL( '/hello-world/' ) ); +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +test.describe( 'Cache Control header directives', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.deleteAllPosts(); + }); + + test( + 'No private directive present in cache control when user not logged in.', + async ( { browser, admin, editor} + ) => { + await admin.createNewPost( { title: 'Hello World' } ); + await editor.publishPost(); + + await admin.visitAdminPage( '/' ); + + // Create a new incognito browser context to simulate logged-out state. + const context = await browser.newContext(); + const loggedOutPage = await context.newPage(); + + const response = await loggedOutPage.goto( '/hello-world/' ); const responseHeaders = response.headers(); + // Dispose context once it's no longer needed. + await context.close(); + expect( responseHeaders ).toEqual( expect.not.objectContaining( { "cache-control": "no-store" } ) ); expect( responseHeaders ).toEqual( expect.not.objectContaining( { "cache-control": "private" } ) ); } ); - it( 'Private directive header present in cache control when logged in.', async () => { - await visitAdminPage( '/' ); + test( + 'Private directive header present in cache control when logged in.', + async ( { page, admin } + ) => { + await admin.visitAdminPage( '/' ); - const response = await page.goto( createURL( '/wp-admin' ) ); + const response = await page.goto( '/wp-admin' ); const responseHeaders = response.headers(); expect( responseHeaders[ 'cache-control' ] ).toContain( 'no-store' ); expect( responseHeaders[ 'cache-control' ] ).toContain( 'private' ); } ); - } ); diff --git a/tests/e2e/specs/dashboard.test.js b/tests/e2e/specs/dashboard.test.js index 21da4dba0b97c..90459ac83ae6f 100644 --- a/tests/e2e/specs/dashboard.test.js +++ b/tests/e2e/specs/dashboard.test.js @@ -1,25 +1,28 @@ -import { - pressKeyTimes, - trashAllPosts, - visitAdminPage, -} from '@wordpress/e2e-test-utils'; - -describe( 'Quick Draft', () => { - beforeEach( async () => { - await trashAllPosts(); +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +test.describe( 'Quick Draft', () => { + test.beforeEach( async ({ requestUtils }) => { + await requestUtils.deleteAllPosts(); } ); - it( 'Allows draft to be created with Title and Content', async () => { - await visitAdminPage( '/' ); + test( 'Allows draft to be created with Title and Content', async ( { + admin, + page + } ) => { + await admin.visitAdminPage( '/' ); - // Wait for Quick Draft title field to appear and focus it - const draftTitleField = await page.waitForSelector( - '#quick-press #title' - ); - await draftTitleField.focus(); + // Wait for Quick Draft title field to appear. + const draftTitleField = page.locator( + '#quick-press' + ).getByRole( 'textbox', { name: 'Title' } ); - // Type in a title. - await page.keyboard.type( 'Test Draft Title' ); + await expect( draftTitleField ).toBeVisible(); + + // Focus and fill in a title. + await draftTitleField.fill( 'Test Draft Title' ); // Navigate to content field and type in some content await page.keyboard.press( 'Tab' ); @@ -30,47 +33,42 @@ describe( 'Quick Draft', () => { await page.keyboard.press( 'Enter' ); // Check that new draft appears in Your Recent Drafts section - const newDraft = await page.waitForSelector( '.drafts .draft-title' ); - - expect( - await newDraft.evaluate( ( element ) => element.innerText ) - ).toContain( 'Test Draft Title' ); + await expect( + page.locator( '.drafts .draft-title' ).first().getByRole( 'link' ) + ).toHaveText( 'Test Draft Title' ); // Check that new draft appears in Posts page - await visitAdminPage( '/edit.php' ); - const postsListDraft = await page.waitForSelector( - '.type-post.status-draft .title' - ); - - expect( - await postsListDraft.evaluate( ( element ) => element.innerText ) - ).toContain( 'Test Draft Title' ); + await admin.visitAdminPage( '/edit.php' ); + + await expect( + page.locator( '.type-post.status-draft .title' ).first() + ).toContainText( 'Test Draft Title' ); } ); - it( 'Allows draft to be created without Title or Content', async () => { - await visitAdminPage( '/' ); + test( 'Allows draft to be created without Title or Content', async ( { + admin, + page + } ) => { + await admin.visitAdminPage( '/' ); // Wait for Save Draft button to appear and click it - const saveDraftButton = await page.waitForSelector( - '#quick-press #save-post' - ); + const saveDraftButton = page.locator( + '#quick-press' + ).getByRole( 'button', { name: 'Save Draft' } ); + + await expect( saveDraftButton ).toBeVisible(); await saveDraftButton.click(); // Check that new draft appears in Your Recent Drafts section - const newDraft = await page.waitForSelector( '.drafts .draft-title a' ); - - expect( - await newDraft.evaluate( ( element ) => element.innerText ) - ).toContain( '(no title)' ); + await expect( + page.locator( '.drafts .draft-title' ).first().getByRole( 'link' ) + ).toHaveText( 'Untitled' ); // Check that new draft appears in Posts page - await visitAdminPage( '/edit.php' ); - const postsListDraft = await page.waitForSelector( - '.type-post.status-draft .title a' - ); - - expect( - await postsListDraft.evaluate( ( element ) => element.innerText ) - ).toContain( '(no title)' ); + await admin.visitAdminPage( '/edit.php' ); + + await expect( + page.locator( '.type-post.status-draft .title' ).first() + ).toContainText( 'Untitled' ); } ); } ); diff --git a/tests/e2e/specs/edit-posts.test.js b/tests/e2e/specs/edit-posts.test.js index 5c07019f4d1e6..0e2eb3687f778 100644 --- a/tests/e2e/specs/edit-posts.test.js +++ b/tests/e2e/specs/edit-posts.test.js @@ -1,137 +1,135 @@ -import { - createNewPost, - pressKeyTimes, - publishPost, - trashAllPosts, - visitAdminPage, -} from '@wordpress/e2e-test-utils'; - -describe( 'Edit Posts', () => { - beforeEach( async () => { - await trashAllPosts(); +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +test.describe( 'Edit Posts', () => { + test.beforeEach( async ( { requestUtils }) => { + await requestUtils.deleteAllPosts(); } ); - it( 'displays a message in the posts table when no posts are present', async () => { - await visitAdminPage( '/edit.php' ); - const noPostsMessage = await page.$x( - '//td[text()="No posts found."]' - ); - expect( noPostsMessage.length ).toBe( 1 ); + test( 'displays a message in the posts table when no posts are present',async ( { + admin, + page, + } ) => { + await admin.visitAdminPage( '/edit.php' ); + await expect( + page.getByRole( 'cell', { name: 'No posts found.' } ) + ).toBeVisible(); } ); - it( 'shows a single post after one is published with the correct title', async () => { + test( 'shows a single post after one is published with the correct title',async ( { + admin, + editor, + page, + } ) => { const title = 'Test Title'; - await createNewPost( { title } ); - await publishPost(); - await visitAdminPage( '/edit.php' ); + await admin.createNewPost( { title } ); + await editor.publishPost(); + await admin.visitAdminPage( '/edit.php' ); - await page.waitForSelector( '#the-list .type-post' ); + const listTable = page.getByRole( 'table', { name: 'Table ordered by' } ); + await expect( listTable ).toBeVisible(); // Expect there to be one row in the post list. - const posts = await page.$$( '#the-list .type-post' ); - expect( posts.length ).toBe( 1 ); - - const [ firstPost ] = posts; + const posts = listTable.locator( '.row-title' ); + await expect( posts ).toHaveCount( 1 ); // Expect the title of the post to be correct. - const postTitle = await firstPost.$x( - `//a[contains(@class, "row-title")][contains(text(), "${ title }")]` - ); - expect( postTitle.length ).toBe( 1 ); + expect( posts.first() ).toHaveText( title ); } ); - it( 'allows an existing post to be edited using the Edit button', async () => { + test( 'allows an existing post to be edited using the Edit button', async ( { + admin, + editor, + page, + } ) => { const title = 'Test Title'; - await createNewPost( { title } ); - await publishPost(); - await visitAdminPage( '/edit.php' ); + await admin.createNewPost( { title } ); + await editor.publishPost(); + await admin.visitAdminPage( '/edit.php' ); - await page.waitForSelector( '#the-list .type-post' ); + const listTable = page.getByRole( 'table', { name: 'Table ordered by' } ); + await expect( listTable ).toBeVisible(); // Click the post title (edit) link - const [ editLink ] = await page.$x( - `//a[contains(@class, "row-title")][contains(text(), "${ title }")]` - ); - await editLink.click(); + await listTable.getByRole( 'link', { name: `“${ title }” (Edit)` } ).click(); // Wait for the editor iframe to load, and switch to it as the active content frame. - const editorFrame = await page.waitForSelector( 'iframe[name="editor-canvas"]' ); - - const innerFrame = await editorFrame.contentFrame(); + await page + .frameLocator( '[name=editor-canvas]' ) + .locator( 'body > *' ) + .first() + .waitFor(); - // Wait for title field to render onscreen. - await innerFrame.waitForSelector( '.editor-post-title__input' ); + const editorPostTitle = editor.canvas.getByRole( 'textbox', { name: 'Add title' } ); - // Expect to now be in the editor with the correct post title shown. - const editorPostTitleInput = await innerFrame.$x( - `//h1[contains(@class, "editor-post-title__input")][contains(text(), "${ title }")]` - ); - expect( editorPostTitleInput.length ).toBe( 1 ); + // Expect title field to be in the editor with correct title shown. + await expect( editorPostTitle ).toBeVisible(); + await expect( editorPostTitle ).toHaveText( title ); } ); - it( 'allows an existing post to be quick edited using the Quick Edit button', async () => { + test( 'allows an existing post to be quick edited using the Quick Edit button', async ( { + admin, + editor, + page, + pageUtils + } ) => { const title = 'Test Title'; - await createNewPost( { title } ); - await publishPost(); - await visitAdminPage( '/edit.php' ); + await admin.createNewPost( { title } ); + await editor.publishPost(); + await admin.visitAdminPage( '/edit.php' ); - await page.waitForSelector( '#the-list .type-post' ); + const listTable = page.getByRole( 'table', { name: 'Table ordered by' } ); + await expect( listTable ).toBeVisible(); - // Focus on the post title link. - const [ editLink ] = await page.$x( - `//a[contains(@class, "row-title")][contains(text(), "${ title }")]` - ); - await editLink.focus(); + // // Focus on the post title link. + await listTable.getByRole( 'link', { name: `“${ title }” (Edit)` } ).focus(); // Tab to the Quick Edit button and press Enter to quick edit. - await pressKeyTimes( 'Tab', 2 ); + await pageUtils.pressKeys( 'Tab', { times: 2 } ) await page.keyboard.press( 'Enter' ); // Type in the currently focused (title) field to modify the title, testing that focus is moved to the input. await page.keyboard.type( ' Edited' ); // Update the post. - await page.click( '.button.save' ); + await page.getByRole( 'button', { name: 'Update' } ).click(); // Wait for the quick edit button to reappear. - await page.waitForSelector( 'button.editinline', { visible: true } ); + await expect( page.getByRole( 'button', { name: 'Quick Edit' } ) ).toBeVisible(); // Expect there to be one row in the post list. - const posts = await page.$$( '#the-list tr.type-post' ); - expect( posts.length ).toBe( 1 ); - - const [ firstPost ] = posts; + const posts = listTable.locator( '.row-title' ); + await expect( posts ).toHaveCount( 1 ); // Expect the title of the post to be correct. - const postTitle = await firstPost.$x( - `//a[contains(@class, "row-title")][contains(text(), "${ title } Edited")]` - ); - expect( postTitle.length ).toBe( 1 ); + expect( posts.first() ).toHaveText( `${ title } Edited` ); } ); - it( 'allows an existing post to be deleted using the Trash button', async () => { + + test( 'allows an existing post to be deleted using the Trash button', async ( { + admin, + editor, + page, + pageUtils + } ) => { const title = 'Test Title'; - await createNewPost( { title } ); - await publishPost(); - await visitAdminPage( '/edit.php' ); + await admin.createNewPost( { title } ); + await editor.publishPost(); + await admin.visitAdminPage( '/edit.php' ); - await page.waitForSelector( '#the-list .type-post' ); + const listTable = page.getByRole( 'table', { name: 'Table ordered by' } ); + await expect( listTable ).toBeVisible(); // Focus on the post title link. - const [ editLink ] = await page.$x( - `//a[contains(@class, "row-title")][contains(text(), "${ title }")]` - ); - await editLink.focus(); + await listTable.getByRole( 'link', { name: `“${ title }” (Edit)` } ).focus(); // Tab to the Trash button and press Enter to delete the post. - await pressKeyTimes( 'Tab', 3 ); + await pageUtils.pressKeys( 'Tab', { times: 3 } ) await page.keyboard.press( 'Enter' ); - const noPostsMessage = await page.waitForSelector( - '#the-list .no-items td' - ); - - expect( - await noPostsMessage.evaluate( ( element ) => element.innerText ) - ).toBe( 'No posts found.' ); + await expect( + page.getByRole( 'cell', { name: 'No posts found.' } ) + ).toBeVisible(); } ); } ); diff --git a/tests/e2e/specs/empty-trash-restore-trashed-posts.test.js b/tests/e2e/specs/empty-trash-restore-trashed-posts.test.js index e18df9f8fe7b1..d970ca09b1c90 100644 --- a/tests/e2e/specs/empty-trash-restore-trashed-posts.test.js +++ b/tests/e2e/specs/empty-trash-restore-trashed-posts.test.js @@ -1,72 +1,55 @@ -import { - visitAdminPage, - createNewPost, - trashAllPosts, - publishPost, -} from "@wordpress/e2e-test-utils"; +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; -const POST_TITLE = "Test Title"; +const POST_TITLE = 'Test Title'; -describe("Empty Trash", () => { - async function createPost(title) { - // Create a Post - await createNewPost({ title }); - await publishPost(); - } +test.describe( 'Empty Trash', () => { + test.beforeEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllPosts(); + }); - afterEach(async () => { - await trashAllPosts(); - }); + test('Empty Trash', async ({ admin, editor, page }) => { + await admin.createNewPost( { title: POST_TITLE } ); + await editor.publishPost(); - it("Empty Trash", async () => { - await createPost(POST_TITLE); + await admin.visitAdminPage( '/edit.php' ); - await visitAdminPage("/edit.php"); + const listTable = page.getByRole( 'table', { name: 'Table ordered by' } ); + await expect( listTable ).toBeVisible(); - // Move post to trash - await page.hover(`[aria-label^="“${POST_TITLE}”"]`); - await page.click(`[aria-label='Move “${POST_TITLE}” to the Trash']`); + // Move post to trash + await listTable.getByRole( 'link', { name: `“${ POST_TITLE }” (Edit)` } ).hover(); + await listTable.getByRole( 'link', { name: `Move “${POST_TITLE}” to the Trash` } ).click(); - // Empty trash - const trashTab = await page.waitForXPath('//h2[text()="Filter posts list"]/following-sibling::ul//a[contains(text(), "Trash")]'); - await Promise.all([ - trashTab.click(), - page.waitForNavigation(), - ]); - const deleteAllButton = await page.waitForSelector('input[value="Empty Trash"]'); - await Promise.all([ - deleteAllButton.click(), - page.waitForNavigation(), - ]); + // Empty trash + await page.getByRole( 'link', { name: 'Trash' } ).click(); + await page.getByRole( 'button', { name: 'Empty Trash' } ).first().click(); - const messageElement = await page.waitForSelector("#message"); - const message = await messageElement.evaluate((node) => node.innerText); - // Until we have `deleteAllPosts`, the number of posts being deleted could be dynamic. - expect(message).toMatch(/\d+ posts? permanently deleted\./); - }); + await expect( page.locator( '#message' ) ).toContainText( '1 post permanently deleted.' ); + } ); - it("Restore trash post", async () => { - await createPost(POST_TITLE); + test('Restore trash post', async ( { admin, editor, page }) => { + await admin.createNewPost( { title: POST_TITLE } ); + await editor.publishPost(); - await visitAdminPage("/edit.php"); + await admin.visitAdminPage( '/edit.php' ); - // Move one post to trash. - await page.hover(`[aria-label^="“${POST_TITLE}”"]`); - await page.click(`[aria-label='Move “${POST_TITLE}” to the Trash']`); + const listTable = page.getByRole( 'table', { name: 'Table ordered by' } ); + await expect( listTable ).toBeVisible(); - // Remove post from trash. - const trashTab = await page.waitForXPath('//h2[text()="Filter posts list"]/following-sibling::ul//a[contains(text(), "Trash")]'); - await Promise.all([ - trashTab.click(), - page.waitForNavigation(), - ]); - const [postTitle] = await page.$x(`//*[text()="${POST_TITLE}"]`); - await postTitle.hover(); - await page.click(`[aria-label="Restore “${POST_TITLE}” from the Trash"]`); + // Move post to trash + await listTable.getByRole( 'link', { name: `“${ POST_TITLE }” (Edit)` } ).hover(); + await listTable.getByRole( 'link', { name: `Move “${POST_TITLE}” to the Trash` } ).click(); - // Expect for success message for trashed post. - const messageElement = await page.waitForSelector("#message"); - const message = await messageElement.evaluate((element) => element.innerText); - expect(message).toContain("1 post restored from the Trash."); - }); -}); + await page.getByRole( 'link', { name: 'Trash' } ).click(); + + // Remove post from trash. + await listTable.getByRole( 'cell' ).filter( { hasText: POST_TITLE } ).hover(); + await listTable.getByRole( 'link', { name: `Restore “${POST_TITLE}” from the Trash` } ).click(); + + // Expect for success message for restored post. + await expect( page.locator( '#message' ) ).toContainText( '1 post restored from the Trash.' ); + } ); +} ); diff --git a/tests/e2e/specs/gutenberg-plugin.test.js b/tests/e2e/specs/gutenberg-plugin.test.js index 21b6e9737d0f1..8f3ff20acc1c1 100644 --- a/tests/e2e/specs/gutenberg-plugin.test.js +++ b/tests/e2e/specs/gutenberg-plugin.test.js @@ -1,26 +1,48 @@ -import { - activatePlugin, - deactivatePlugin, - installPlugin, - uninstallPlugin, -} from '@wordpress/e2e-test-utils'; - -describe( 'Gutenberg plugin', () => { - beforeAll( async () => { - await installPlugin( 'gutenberg' ); - } ); +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +test.describe( 'Gutenberg plugin', () => { + // Increasing timeout to 5 minutes because potential plugin install could take longer. + test.setTimeout( 300_000 ); + + test.beforeAll( async ( { requestUtils } ) => { + // Install Gutenberg plugin if it's not yet installed. + const pluginsMap = await requestUtils.getPluginsMap(); + if ( ! pluginsMap.gutenberg ) { + await requestUtils.rest( { + method: 'POST', + path: 'wp/v2/plugins?slug=gutenberg', + } ); + } - afterAll( async () => { - await uninstallPlugin( 'gutenberg' ); + // Refetch installed plugin details. It avoids stale values when the test installs the plugin. + await requestUtils.getPluginsMap( /* forceRefetch */ true ); + await requestUtils.deactivatePlugin( 'gutenberg' ); } ); - it( 'should activate', async () => { - await activatePlugin( 'gutenberg' ); - /* - * If plugin activation fails, it will time out and throw an error, - * since the activatePlugin helper is looking for a `.deactivate` link - * which is only there if activation succeeds. - */ - await deactivatePlugin( 'gutenberg' ); + test( 'should activate', async ( { requestUtils }) => { + let plugin = await requestUtils.rest( { + path: 'wp/v2/plugins/gutenberg/gutenberg', + } ); + + expect( plugin.status ).toBe( 'inactive' ); + + await requestUtils.activatePlugin( 'gutenberg' ); + + plugin = await requestUtils.rest( { + path: 'wp/v2/plugins/gutenberg/gutenberg', + } ); + + expect( plugin.status ).toBe( 'active' ); + + await requestUtils.deactivatePlugin( 'gutenberg' ); + + plugin = await requestUtils.rest( { + path: 'wp/v2/plugins/gutenberg/gutenberg', + } ); + + expect( plugin.status ).toBe( 'inactive' ); } ); } ); diff --git a/tests/e2e/specs/hello.test.js b/tests/e2e/specs/hello.test.js index 038957883be09..cfe018bbd6260 100644 --- a/tests/e2e/specs/hello.test.js +++ b/tests/e2e/specs/hello.test.js @@ -1,11 +1,13 @@ -import { visitAdminPage } from '@wordpress/e2e-test-utils'; +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; -describe( 'Hello World', () => { - it( 'Should load properly', async () => { - await visitAdminPage( '/' ); - const nodes = await page.$x( - '//h2[contains(text(), "Welcome to WordPress!")]' - ); - expect( nodes.length ).not.toEqual( 0 ); +test.describe( 'Hello World', () => { + test( 'Should load properly', async ( { admin, page }) => { + await admin.visitAdminPage( '/' ); + await expect( + page.getByRole('heading', { name: 'Welcome to WordPress', level: 2 }) + ).toBeVisible(); } ); } ); diff --git a/tests/e2e/specs/profile/applications-passwords.test.js b/tests/e2e/specs/profile/applications-passwords.test.js index 1b53a76811d10..38aed7372eace 100644 --- a/tests/e2e/specs/profile/applications-passwords.test.js +++ b/tests/e2e/specs/profile/applications-passwords.test.js @@ -1,138 +1,133 @@ -import { - visitAdminPage, - __experimentalRest as rest, -} from "@wordpress/e2e-test-utils"; - -async function getResponseForApplicationPassword() { - return await rest({ - method: "GET", - path: "/wp/v2/users/me/application-passwords", - }); -} - -async function createApplicationPassword(applicationName) { - await visitAdminPage("profile.php"); - await page.waitForSelector("#new_application_password_name"); - await page.type("#new_application_password_name", applicationName); - await page.click("#do_new_application_password"); +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; - await page.waitForSelector("#application-passwords-section .notice"); -} +const TEST_APPLICATION_NAME = 'Test Application'; -async function createApplicationPasswordWithApi(applicationName) { - await rest({ - method: "POST", - path: "/wp/v2/users/me/application-passwords", - data: { - name: applicationName, +test.describe( 'Manage applications passwords', () => { + test.use( { + applicationPasswords: async ( { requestUtils, admin, page }, use ) => { + await use( new ApplicationPasswords( { requestUtils, admin, page } ) ); }, - }); -} + } ); -async function revokeAllApplicationPasswordsWithApi() { - await rest({ - method: "DELETE", - path: `/wp/v2/users/me/application-passwords`, - }); -} + test.beforeEach(async ( { applicationPasswords } ) => { + await applicationPasswords.delete(); + } ); -describe("Manage applications passwords", () => { - const TEST_APPLICATION_NAME = "Test Application"; + test('should correctly create a new application password', async ( { + page, + applicationPasswords + } ) => { + await applicationPasswords.create(); - beforeEach(async () => { - await revokeAllApplicationPasswordsWithApi(); - }); + const [ app ] = await applicationPasswords.get(); + expect( app['name']).toBe( TEST_APPLICATION_NAME ); - it("should correctly create a new application password", async () => { - await createApplicationPassword(TEST_APPLICATION_NAME); + const successMessage = page.getByRole( 'alert' ); - const response = await getResponseForApplicationPassword(); - expect(response[0]["name"]).toBe(TEST_APPLICATION_NAME); - - const successMessage = await page.waitForSelector( - "#application-passwords-section .notice-success" - ); - expect( - await successMessage.evaluate((element) => element.innerText) - ).toContain( + await expect( successMessage ).toHaveClass( /notice-success/ ); + await expect( + successMessage + ).toContainText( `Your new password for ${TEST_APPLICATION_NAME} is: \n\nBe sure to save this in a safe location. You will not be able to retrieve it.` ); - }); - - it("should not allow to create two applications passwords with the same name", async () => { - await createApplicationPassword(TEST_APPLICATION_NAME); - await createApplicationPassword(TEST_APPLICATION_NAME); - - const errorMessage = await page.waitForSelector( - "#application-passwords-section .notice-error" + } ); + + test('should not allow to create two applications passwords with the same name', async ( { + page, + applicationPasswords + } ) => { + await applicationPasswords.create(); + await applicationPasswords.create(); + + const errorMessage = page.getByRole( 'alert' ); + + await expect( errorMessage ).toHaveClass( /notice-error/ ); + await expect( + errorMessage + ).toContainText( + 'Each application name should be unique.' ); - - expect( - await errorMessage.evaluate((element) => element.textContent) - ).toContain("Each application name should be unique."); }); - it("should correctly revoke a single application password", async () => { - await createApplicationPassword(TEST_APPLICATION_NAME); + test( 'should correctly revoke a single application password', async ( { + page, + applicationPasswords + } ) => { + await applicationPasswords.create(); - const revokeApplicationButton = await page.waitForSelector( - ".application-passwords-user tr button.delete" - ); - - const revocationDialogPromise = new Promise((resolve) => { - page.once("dialog", resolve); - }); - - await Promise.all([ - revocationDialogPromise, - revokeApplicationButton.click(), - ]); - - const successMessage = await page.waitForSelector( - "#application-passwords-section .notice-success" - ); - expect( - await successMessage.evaluate((element) => element.textContent) - ).toContain("Application password revoked."); + const revokeButton = page.getByRole( 'button', { name: `Revoke "${ TEST_APPLICATION_NAME }"` } ); + await expect( revokeButton ).toBeVisible(); - const response = await getResponseForApplicationPassword(); - expect(response).toEqual([]); - }); - - it("should correctly revoke all the application passwords", async () => { - await createApplicationPassword(TEST_APPLICATION_NAME); + // Revoke password. + page.on( 'dialog', ( dialog ) => dialog.accept() ); + await revokeButton.click(); - const revokeAllApplicationPasswordsButton = await page.waitForSelector( - "#revoke-all-application-passwords" + await expect( + page.getByRole( 'alert' ) + ).toContainText( + 'Application password revoked.' ); - const revocationDialogPromise = new Promise((resolve) => { - page.once("dialog", resolve); - }); + const response = await applicationPasswords.get(); + expect( response ).toEqual([]); + } ); - await Promise.all([ - revocationDialogPromise, - revokeAllApplicationPasswordsButton.click(), - ]); + test( 'should correctly revoke all the application passwords', async ( { + page, + applicationPasswords + } ) => { + await applicationPasswords.create(); - /** - * This is commented out because we're using enablePageDialogAccept - * which is overly aggressive and no way to temporary disable it either. - */ - // await dialog.accept(); + const revokeAllButton = page.getByRole( 'button', { name: 'Revoke all application passwords' } ); + await expect( revokeAllButton ).toBeVisible(); - await page.waitForSelector( - "#application-passwords-section .notice-success" - ); + // Confirms revoking action. + page.on( 'dialog', ( dialog ) => dialog.accept() ); + await revokeAllButton.click(); - const successMessage = await page.waitForSelector( - "#application-passwords-section .notice-success" + await expect( + page.getByRole( 'alert' ) + ).toContainText( + 'All application passwords revoked.' ); - expect( - await successMessage.evaluate((element) => element.textContent) - ).toContain("All application passwords revoked."); - const response = await getResponseForApplicationPassword(); - expect(response).toEqual([]); - }); -}); + const response = await applicationPasswords.get(); + expect( response ).toEqual([]); + } ); +} ); + +class ApplicationPasswords { + constructor( { requestUtils, page, admin }) { + this.requestUtils = requestUtils; + this.page = page; + this.admin = admin; + } + + async create(applicationName = TEST_APPLICATION_NAME) { + await this.admin.visitAdminPage( '/profile.php' ); + + const newPasswordField = this.page.getByRole( 'textbox', { name: 'New Application Password Name' } ); + await expect( newPasswordField ).toBeVisible(); + await newPasswordField.fill( applicationName ); + + await this.page.getByRole( 'button', { name: 'Add New Application Password' } ).click(); + await expect( this.page.getByRole( 'alert' ) ).toBeVisible(); + } + + async get() { + return this.requestUtils.rest( { + method: 'GET', + path: '/wp/v2/users/me/application-passwords', + } ); + } + + async delete() { + await this.requestUtils.rest( { + method: 'DELETE', + path: '/wp/v2/users/me/application-passwords', + } ); + } +} diff --git a/tests/performance/compare-results.js b/tests/performance/compare-results.js index e722552aeda04..aea827b51130a 100644 --- a/tests/performance/compare-results.js +++ b/tests/performance/compare-results.js @@ -3,8 +3,12 @@ /** * External dependencies. */ -const fs = require( 'fs' ); -const path = require( 'path' ); +const fs = require( 'node:fs' ); +const path = require( 'node:path' ); + +/** + * Internal dependencies + */ const { median } = require( './utils' ); /** @@ -23,18 +27,16 @@ const testSuites = [ 'home-block-theme', 'home-classic-theme' ]; // The current commit's results. const testResults = Object.fromEntries( - testSuites.map( ( key ) => [ - key, - parseFile( `${ key }.test.results.json` ), - ] ) + testSuites + .filter( ( key ) => fs.existsSync( `${ key }.test.results.json` ) ) + .map( ( key ) => [ key, parseFile( `${ key }.test.results.json` ) ] ) ); // The previous commit's results. const prevResults = Object.fromEntries( - testSuites.map( ( key ) => [ - key, - parseFile( `before-${ key }.test.results.json` ), - ] ) + testSuites + .filter( ( key ) => fs.existsSync( `before-${ key }.test.results.json` ) ) + .map( ( key ) => [ key, parseFile( `before-${ key }.test.results.json` ) ] ) ); const args = process.argv.slice( 2 ); @@ -127,8 +129,8 @@ console.log( 'Performance Test Results\n' ); console.log( 'Note: Due to the nature of how GitHub Actions work, some variance in the results is expected.\n' ); for ( const key of testSuites ) { - const current = testResults[ key ]; - const prev = prevResults[ key ]; + const current = testResults[ key ] || {}; + const prev = prevResults[ key ] || {}; const title = ( key.charAt( 0 ).toUpperCase() + key.slice( 1 ) ).replace( /-+/g, @@ -152,14 +154,18 @@ for ( const key of testSuites ) { } ); } - summaryMarkdown += `## ${ title }\n\n`; - summaryMarkdown += `${ formatAsMarkdownTable( rows ) }\n`; + if ( rows.length > 0 ) { + summaryMarkdown += `## ${ title }\n\n`; + summaryMarkdown += `${ formatAsMarkdownTable( rows ) }\n`; - console.log( title ); - console.table( rows ); + console.log( title ); + console.table( rows ); + } } -fs.writeFileSync( - summaryFile, - summaryMarkdown -); +if ( summaryFile ) { + fs.writeFileSync( + summaryFile, + summaryMarkdown + ); +} diff --git a/tests/performance/config/bootstrap.js b/tests/performance/config/bootstrap.js deleted file mode 100644 index 773d2c1d74f87..0000000000000 --- a/tests/performance/config/bootstrap.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * WordPress dependencies. - */ -import { - clearLocalStorage, - enablePageDialogAccept, - setBrowserViewport, -} from '@wordpress/e2e-test-utils'; - -/** - * Timeout, in seconds, that the test should be allowed to run. - * - * @type {string|undefined} - */ -const PUPPETEER_TIMEOUT = process.env.PUPPETEER_TIMEOUT; - -// The Jest timeout is increased because these tests are a bit slow. -jest.setTimeout( PUPPETEER_TIMEOUT || 100000 ); - -async function setupBrowser() { - await clearLocalStorage(); - await setBrowserViewport( 'large' ); -} - -/* - * Before every test suite run, delete all content created by the test. This ensures - * other posts/comments/etc. aren't dirtying tests and tests don't depend on - * each other's side-effects. - */ -beforeAll( async () => { - enablePageDialogAccept(); - - await setBrowserViewport( 'large' ); - await page.emulateMediaFeatures( [ - { name: 'prefers-reduced-motion', value: 'reduce' }, - ] ); -} ); - -afterEach( async () => { - await setupBrowser(); -} ); diff --git a/tests/performance/config/global-setup.js b/tests/performance/config/global-setup.js new file mode 100644 index 0000000000000..f3a0a4f26a691 --- /dev/null +++ b/tests/performance/config/global-setup.js @@ -0,0 +1,40 @@ +/** + * External dependencies + */ +import { request } from '@playwright/test'; + +/** + * WordPress dependencies + */ +import { RequestUtils } from '@wordpress/e2e-test-utils-playwright'; + +/** + * + * @param {import('@playwright/test').FullConfig} config + * @returns {Promise} + */ +async function globalSetup( config ) { + const { storageState, baseURL } = config.projects[ 0 ].use; + const storageStatePath = + typeof storageState === 'string' ? storageState : undefined; + + const requestContext = await request.newContext( { + baseURL, + } ); + + const requestUtils = new RequestUtils( requestContext, { + storageStatePath, + } ); + + // Authenticate and save the storageState to disk. + await requestUtils.setupRest(); + + // Reset the test environment before running the tests. + await Promise.all( [ + requestUtils.activateTheme( 'twentytwentyone' ), + ] ); + + await requestContext.dispose(); +} + +export default globalSetup; diff --git a/tests/performance/config/performance-reporter.js b/tests/performance/config/performance-reporter.js new file mode 100644 index 0000000000000..e557faa135cbd --- /dev/null +++ b/tests/performance/config/performance-reporter.js @@ -0,0 +1,38 @@ +/** + * External dependencies + */ +import { join, dirname, basename } from 'node:path'; +import { writeFileSync } from 'node:fs'; + +/** + * Internal dependencies + */ +import { getResultsFilename } from '../utils'; + +/** + * @implements {import('@playwright/test/reporter').Reporter} + */ +class PerformanceReporter { + /** + * + * @param {import('@playwright/test/reporter').TestCase} test + * @param {import('@playwright/test/reporter').TestResult} result + */ + onTestEnd( test, result ) { + const performanceResults = result.attachments.find( + ( attachment ) => attachment.name === 'results' + ); + + if ( performanceResults?.body ) { + writeFileSync( + join( + dirname( test.location.file ), + getResultsFilename( basename( test.location.file, '.js' ) ) + ), + performanceResults.body.toString( 'utf-8' ) + ); + } + } +} + +export default PerformanceReporter; diff --git a/tests/performance/jest.config.js b/tests/performance/jest.config.js deleted file mode 100644 index b62bb016c3b03..0000000000000 --- a/tests/performance/jest.config.js +++ /dev/null @@ -1,14 +0,0 @@ -const config = require( '@wordpress/scripts/config/jest-e2e.config' ); - -const jestE2EConfig = { - ...config, - setupFilesAfterEnv: [ - '/config/bootstrap.js', - ], - globals: { - // Number of requests to run per test. - TEST_RUNS: 20, - } -}; - -module.exports = jestE2EConfig; diff --git a/tests/performance/playwright.config.js b/tests/performance/playwright.config.js new file mode 100644 index 0000000000000..6c2ff454725ed --- /dev/null +++ b/tests/performance/playwright.config.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import path from 'node:path'; +import { defineConfig } from '@playwright/test'; + +/** + * WordPress dependencies + */ +import baseConfig from '@wordpress/scripts/config/playwright.config'; + +process.env.WP_ARTIFACTS_PATH ??= path.join( process.cwd(), 'artifacts' ); +process.env.STORAGE_STATE_PATH ??= path.join( + process.env.WP_ARTIFACTS_PATH, + 'storage-states/admin.json' +); +process.env.TEST_RUNS ??= '20'; + +const config = defineConfig( { + ...baseConfig, + globalSetup: require.resolve( './config/global-setup.js' ), + reporter: process.env.CI + ? './config/performance-reporter.js' + : [ [ 'list' ], [ './config/performance-reporter.js' ] ], + forbidOnly: !! process.env.CI, + workers: 1, + retries: 0, + timeout: parseInt( process.env.TIMEOUT || '', 10 ) || 600_000, // Defaults to 10 minutes. + // Don't report slow test "files", as we will be running our tests in serial. + reportSlowTests: null, + webServer: { + ...baseConfig.webServer, + command: 'npm run env:start', + }, + use: { + ...baseConfig.use, + video: 'off', + }, +} ); + +export default config; + diff --git a/tests/performance/results.js b/tests/performance/results.js index 3ebf47edaabd0..c7a977181da6a 100644 --- a/tests/performance/results.js +++ b/tests/performance/results.js @@ -3,8 +3,8 @@ /** * External dependencies. */ -const fs = require( 'fs' ); -const { join } = require( 'path' ); +const fs = require( 'node:fs' ); +const { join } = require( 'node:path' ); const { median, getResultsFilename } = require( './utils' ); const testSuites = [ diff --git a/tests/performance/run-tests.js b/tests/performance/run-tests.js deleted file mode 100644 index 84e3c847842c2..0000000000000 --- a/tests/performance/run-tests.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * External dependencies. - */ -const dotenv = require( 'dotenv' ); -const dotenv_expand = require( 'dotenv-expand' ); -const { execSync } = require( 'child_process' ); - -// WP_BASE_URL interpolates LOCAL_PORT, so needs to be parsed by dotenv_expand(). -dotenv_expand.expand( dotenv.config() ); - -// Run the tests, passing additional arguments through to the test script. -execSync( - 'wp-scripts test-e2e --config tests/performance/jest.config.js ' + - process.argv.slice( 2 ).join( ' ' ), - { stdio: 'inherit' } -); diff --git a/tests/performance/specs/home-block-theme.test.js b/tests/performance/specs/home-block-theme.test.js index f9a93824a6077..496445ad0d0c8 100644 --- a/tests/performance/specs/home-block-theme.test.js +++ b/tests/performance/specs/home-block-theme.test.js @@ -1,67 +1,57 @@ /** - * External dependencies. + * WordPress dependencies */ -const { basename, join } = require( 'path' ); -const { writeFileSync } = require( 'fs' ); -const { - getResultsFilename, - getTimeToFirstByte, - getLargestContentfulPaint, -} = require( './../utils' ); +import { test } from '@wordpress/e2e-test-utils-playwright'; /** - * WordPress dependencies. + * Internal dependencies */ -import { activateTheme, createURL } from '@wordpress/e2e-test-utils'; +import { camelCaseDashes } from '../utils'; -describe( 'Server Timing - Twenty Twenty Three', () => { - const results = { - wpBeforeTemplate: [], - wpTemplate: [], - wpTotal: [], - timeToFirstByte: [], - largestContentfulPaint: [], - lcpMinusTtfb: [], - }; +const results = { + timeToFirstByte: [], + largestContentfulPaint: [], + lcpMinusTtfb: [], +}; - beforeAll( async () => { - await activateTheme( 'twentytwentythree' ); +test.describe( 'Front End - Twenty Twenty Three', () => { + test.use( { + storageState: {}, // User will be logged out. } ); - afterAll( async () => { - const resultsFilename = getResultsFilename( - basename( __filename, '.js' ) - ); - writeFileSync( - join( __dirname, resultsFilename ), - JSON.stringify( results, null, 2 ) - ); + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentythree' ); } ); - it( 'Server Timing Metrics', async () => { - let i = TEST_RUNS; - while ( i-- ) { - await page.goto( createURL( '/' ) ); - const navigationTimingJson = await page.evaluate( () => - JSON.stringify( performance.getEntriesByType( 'navigation' ) ) - ); + test.afterAll( async ( { requestUtils }, testInfo ) => { + await testInfo.attach( 'results', { + body: JSON.stringify( results, null, 2 ), + contentType: 'application/json', + } ); + await requestUtils.activateTheme( 'twentytwentyone' ); + } ); - const [ navigationTiming ] = JSON.parse( navigationTimingJson ); + const iterations = Number( process.env.TEST_RUNS ); + for ( let i = 1; i <= iterations; i++ ) { + test( `Measure load time metrics (${ i } of ${ iterations })`, async ( { + page, + metrics, + } ) => { + await page.goto( '/' ); - results.wpBeforeTemplate.push( - navigationTiming.serverTiming[ 0 ].duration - ); - results.wpTemplate.push( - navigationTiming.serverTiming[ 1 ].duration - ); - results.wpTotal.push( navigationTiming.serverTiming[ 2 ].duration ); + const serverTiming = await metrics.getServerTiming(); - const ttfb = await getTimeToFirstByte(); - const lcp = await getLargestContentfulPaint(); + for ( const [key, value] of Object.entries( serverTiming ) ) { + results[ camelCaseDashes( key ) ] ??= []; + results[ camelCaseDashes( key ) ].push( value ); + } + + const ttfb = await metrics.getTimeToFirstByte(); + const lcp = await metrics.getLargestContentfulPaint(); - results.timeToFirstByte.push( ttfb ); results.largestContentfulPaint.push( lcp ); + results.timeToFirstByte.push( ttfb ); results.lcpMinusTtfb.push( lcp - ttfb ); - } - } ); + } ); + } } ); diff --git a/tests/performance/specs/home-classic-theme.test.js b/tests/performance/specs/home-classic-theme.test.js index 7ae9282ddc3cc..32125c37a42a1 100644 --- a/tests/performance/specs/home-classic-theme.test.js +++ b/tests/performance/specs/home-classic-theme.test.js @@ -1,71 +1,56 @@ /** - * External dependencies. + * WordPress dependencies */ -const { basename, join } = require( 'path' ); -const { writeFileSync } = require( 'fs' ); -const { exec } = require( 'child_process' ); -const { - getResultsFilename, - getTimeToFirstByte, - getLargestContentfulPaint, -} = require( './../utils' ); +import { test } from '@wordpress/e2e-test-utils-playwright'; /** - * WordPress dependencies. + * Internal dependencies */ -import { activateTheme, createURL } from '@wordpress/e2e-test-utils'; +import { camelCaseDashes } from '../utils'; -describe( 'Server Timing - Twenty Twenty One', () => { - const results = { - wpBeforeTemplate: [], - wpTemplate: [], - wpTotal: [], - timeToFirstByte: [], - largestContentfulPaint: [], - lcpMinusTtfb: [], - }; +const results = { + timeToFirstByte: [], + largestContentfulPaint: [], + lcpMinusTtfb: [], +}; - beforeAll( async () => { - await activateTheme( 'twentytwentyone' ); - await exec( - 'npm run env:cli -- menu location assign all-pages primary' - ); +test.describe( 'Front End - Twenty Twenty One', () => { + test.use( { + storageState: {}, // User will be logged out. } ); - afterAll( async () => { - const resultsFilename = getResultsFilename( - basename( __filename, '.js' ) - ); - writeFileSync( - join( __dirname, resultsFilename ), - JSON.stringify( results, null, 2 ) - ); + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentyone' ); } ); - it( 'Server Timing Metrics', async () => { - let i = TEST_RUNS; - while ( i-- ) { - await page.goto( createURL( '/' ) ); - const navigationTimingJson = await page.evaluate( () => - JSON.stringify( performance.getEntriesByType( 'navigation' ) ) - ); + test.afterAll( async ( {}, testInfo ) => { + await testInfo.attach( 'results', { + body: JSON.stringify( results, null, 2 ), + contentType: 'application/json', + } ); + } ); - const [ navigationTiming ] = JSON.parse( navigationTimingJson ); + const iterations = Number( process.env.TEST_RUNS ); + for ( let i = 1; i <= iterations; i++ ) { + test( `Measure load time metrics (${ i } of ${ iterations })`, async ( { + page, + metrics, + } ) => { + await page.goto( '/' ); - results.wpBeforeTemplate.push( - navigationTiming.serverTiming[ 0 ].duration - ); - results.wpTemplate.push( - navigationTiming.serverTiming[ 1 ].duration - ); - results.wpTotal.push( navigationTiming.serverTiming[ 2 ].duration ); + const serverTiming = await metrics.getServerTiming(); - const ttfb = await getTimeToFirstByte(); - const lcp = await getLargestContentfulPaint(); + for (const [key, value] of Object.entries( serverTiming ) ) { + results[ camelCaseDashes( key ) ] ??= []; + results[ camelCaseDashes( key ) ].push( value ); + } + + const ttfb = await metrics.getTimeToFirstByte(); + const lcp = await metrics.getLargestContentfulPaint(); - results.timeToFirstByte.push( ttfb ); results.largestContentfulPaint.push( lcp ); + results.timeToFirstByte.push( ttfb ); results.lcpMinusTtfb.push( lcp - ttfb ); - } - } ); + } ); + } } ); diff --git a/tests/performance/utils.js b/tests/performance/utils.js index 732ab66d447fa..9d6502e8e688e 100644 --- a/tests/performance/utils.js +++ b/tests/performance/utils.js @@ -16,63 +16,24 @@ function median( array ) { /** * Gets the result file name. * - * @param {string} File name. + * @param {string} fileName File name. * * @return {string} Result file name. */ function getResultsFilename( fileName ) { - const prefixArg = process.argv.find( ( arg ) => - arg.startsWith( '--prefix' ) - ); - const fileNamePrefix = prefixArg ? `${ prefixArg.split( '=' )[ 1 ] }-` : ''; - const resultsFilename = fileNamePrefix + fileName + '.results.json'; - return resultsFilename; + const prefix = process.env.TEST_RESULTS_PREFIX; + const fileNamePrefix = prefix ? `${ prefix.split( '=' )[ 1 ] }-` : ''; + return `${fileNamePrefix + fileName}.results.json`; } -/** - * Returns time to first byte (TTFB) using the Navigation Timing API. - * - * @see https://web.dev/ttfb/#measure-ttfb-in-javascript - * - * @return {Promise} - */ -async function getTimeToFirstByte() { - return page.evaluate( () => { - const { responseStart, startTime } = - performance.getEntriesByType( 'navigation' )[ 0 ]; - return responseStart - startTime; +function camelCaseDashes( str ) { + return str.replace( /-([a-z])/g, function( g ) { + return g[ 1 ].toUpperCase(); } ); } -/** - * Returns the Largest Contentful Paint (LCP) value using the dedicated API. - * - * @see https://w3c.github.io/largest-contentful-paint/ - * @see https://web.dev/lcp/#measure-lcp-in-javascript - * - * @return {Promise} - */ -async function getLargestContentfulPaint() { - return page.evaluate( - () => - new Promise( ( resolve ) => { - new PerformanceObserver( ( entryList ) => { - const entries = entryList.getEntries(); - // The last entry is the largest contentful paint. - const largestPaintEntry = entries.at( -1 ); - - resolve( largestPaintEntry?.startTime || 0 ); - } ).observe( { - type: 'largest-contentful-paint', - buffered: true, - } ); - } ) - ); -} - module.exports = { median, getResultsFilename, - getTimeToFirstByte, - getLargestContentfulPaint, + camelCaseDashes, }; diff --git a/tests/visual-regression/README.md b/tests/visual-regression/README.md index fe32fc0688b13..d7ef71e64324b 100644 --- a/tests/visual-regression/README.md +++ b/tests/visual-regression/README.md @@ -1,11 +1,11 @@ # Visual Regression Tests in WordPress Core -These tests make use of Jest and Puppeteer, with a setup very similar to that of the e2e tests, together with [jest-image-snapshot](https://github.com/americanexpress/jest-image-snapshot) for generating the visual diffs. +These tests make use of Playwright, with a setup very similar to that of the e2e tests. ## How to Run the Tests Locally 1. Check out trunk. 2. Run `npm run test:visual` to generate some base snapshots. 3. Check out the feature branch to be tested. -4. Run `npm run test:visual` again. If any tests fail, the diff images can be found in `tests/visual-regression/specs/__image_snapshots__/__diff_output__`. +4. Run `npm run test:visual` again. If any tests fail, the diff images can be found in `artifacts/` diff --git a/tests/visual-regression/config/bootstrap.js b/tests/visual-regression/config/bootstrap.js deleted file mode 100644 index 6130909e0c6fe..0000000000000 --- a/tests/visual-regression/config/bootstrap.js +++ /dev/null @@ -1,10 +0,0 @@ -import { configureToMatchImageSnapshot } from 'jest-image-snapshot'; - -// All available options: https://github.com/americanexpress/jest-image-snapshot#%EF%B8%8F-api -const toMatchImageSnapshot = configureToMatchImageSnapshot( { - // Maximum diff to allow in px. - failureThreshold: 1, -} ); - -// Extend Jest's "expect" with image snapshot functionality. -expect.extend( { toMatchImageSnapshot } ); diff --git a/tests/visual-regression/jest.config.js b/tests/visual-regression/jest.config.js deleted file mode 100644 index aa5d3d1fbd0fb..0000000000000 --- a/tests/visual-regression/jest.config.js +++ /dev/null @@ -1,8 +0,0 @@ -const config = require( '@wordpress/scripts/config/jest-e2e.config' ); - -const jestVisualRegressionConfig = { - ...config, - setupFilesAfterEnv: [ '/config/bootstrap.js' ], -}; - -module.exports = jestVisualRegressionConfig; diff --git a/tests/visual-regression/playwright.config.js b/tests/visual-regression/playwright.config.js new file mode 100644 index 0000000000000..759d887bf71c2 --- /dev/null +++ b/tests/visual-regression/playwright.config.js @@ -0,0 +1,27 @@ +/** + * External dependencies + */ +import path from 'node:path'; +import { defineConfig } from '@playwright/test'; + +/** + * WordPress dependencies + */ +const baseConfig = require( '@wordpress/scripts/config/playwright.config' ); + +process.env.WP_ARTIFACTS_PATH ??= path.join( process.cwd(), 'artifacts' ); +process.env.STORAGE_STATE_PATH ??= path.join( + process.env.WP_ARTIFACTS_PATH, + 'storage-states/admin.json' +); + +const config = defineConfig( { + ...baseConfig, + globalSetup: undefined, + webServer: { + ...baseConfig.webServer, + command: 'npm run env:start', + }, +} ); + +export default config; diff --git a/tests/visual-regression/run-tests.js b/tests/visual-regression/run-tests.js deleted file mode 100644 index a94c914d72d22..0000000000000 --- a/tests/visual-regression/run-tests.js +++ /dev/null @@ -1,13 +0,0 @@ -const dotenv = require( 'dotenv' ); -const dotenv_expand = require( 'dotenv-expand' ); -const { execSync } = require( 'child_process' ); - -// WP_BASE_URL interpolates LOCAL_PORT, so needs to be parsed by dotenv_expand(). -dotenv_expand.expand( dotenv.config() ); - -// Run the tests, passing additional arguments through to the test script. -execSync( - 'wp-scripts test-e2e --config tests/visual-regression/jest.config.js ' + - process.argv.slice( 2 ).join( ' ' ), - { stdio: 'inherit' } -); diff --git a/tests/visual-regression/specs/visual-snapshots.test.js b/tests/visual-regression/specs/visual-snapshots.test.js index 458c40f86e0b7..d2f1eb9e7ebe0 100644 --- a/tests/visual-regression/specs/visual-snapshots.test.js +++ b/tests/visual-regression/specs/visual-snapshots.test.js @@ -1,222 +1,166 @@ -import { visitAdminPage } from '@wordpress/e2e-test-utils'; - -// See https://github.com/puppeteer/puppeteer/blob/main/docs/api.md#pagescreenshotoptions for more available options. -const screenshotOptions = { - fullPage: true, -}; - -async function hideElementVisibility( elements ) { - for ( let i = 0; i < elements.length; i++ ) { - const elementOnPage = await page.$( elements[ i ] ); - if ( elementOnPage ) { - await elementOnPage.evaluate( ( el ) => { - el.style.visibility = 'hidden'; - } ); - } - } - await page.waitFor( 1000 ); -} - -async function removeElementFromLayout( elements ) { - for ( let i = 0; i < elements.length; i++ ) { - const elementOnPage = await page.$( elements[ i ] ); - if ( elementOnPage ) { - await elementOnPage.evaluate( ( el ) => { - el.style.visibility = 'hidden'; - } ); - } - } - await page.waitFor( 1000 ); -} - -const elementsToHide = [ '#footer-upgrade', '#wp-admin-bar-root-default' ]; - -const elementsToRemove = [ '#toplevel_page_gutenberg' ]; - -describe( 'Admin Visual Snapshots', () => { - beforeAll( async () => { - await page.setViewport( { - width: 1000, - height: 750, - } ); - } ); - - it( 'All Posts', async () => { - await visitAdminPage( '/edit.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); - - it( 'Categories', async () => { - await visitAdminPage( '/edit-tags.php', 'taxonomy=category' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); - - it( 'Tags', async () => { - await visitAdminPage( '/edit-tags.php', 'taxonomy=post_tag' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); - - it( 'Media Library', async () => { - await visitAdminPage( '/upload.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); - - it( 'Add New Media', async () => { - await visitAdminPage( '/media-new.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); - - it( 'All Pages', async () => { - await visitAdminPage( '/edit.php', 'post_type=page' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); - - it( 'Comments', async () => { - await visitAdminPage( '/edit-comments.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); - - it( 'Widgets', async () => { - await visitAdminPage( '/widgets.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); - - it( 'Menus', async () => { - await visitAdminPage( '/nav-menus.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); - - it( 'Plugins', async () => { - await visitAdminPage( '/plugins.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); - - it( 'All Users', async () => { - await visitAdminPage( '/users.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +const elementsToHide = [ + '#footer-upgrade', + '#wp-admin-bar-root-default', + '#toplevel_page_gutenberg' +]; + +test.describe( 'Admin Visual Snapshots', () => { + test( 'All Posts', async ({ admin, page }) => { + await admin.visitAdminPage( '/edit.php' ); + await expect( page ).toHaveScreenshot( 'All Posts.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); + + test( 'Categories', async ({ admin, page }) => { + await admin.visitAdminPage( '/edit-tags.php', 'taxonomy=category' ); + await expect( page ).toHaveScreenshot( 'Categories.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); + + test( 'Tags', async ({ admin, page }) => { + await admin.visitAdminPage( '/edit-tags.php', 'taxonomy=post_tag' ); + await expect( page ).toHaveScreenshot( 'Tags.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); + + test( 'Media Library', async ({ admin, page }) => { + await admin.visitAdminPage( '/upload.php' ); + await expect( page ).toHaveScreenshot( 'Media Library.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); + + test( 'Add New Media', async ({ admin, page }) => { + await admin.visitAdminPage( '/media-new.php' ); + await expect( page ).toHaveScreenshot( 'Add New Media.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); + + test( 'All Pages', async ({ admin, page }) => { + await admin.visitAdminPage( '/edit.php', 'post_type=page' ); + await expect( page ).toHaveScreenshot( 'All Pages.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); + + test( 'Comments', async ({ admin, page }) => { + await admin.visitAdminPage( '/edit-comments.php' ); + await expect( page ).toHaveScreenshot( 'Comments.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); + + test( 'Widgets', async ({ admin, page }) => { + await admin.visitAdminPage( '/widgets.php' ); + await expect( page ).toHaveScreenshot( 'Widgets.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); } ); - it( 'Add New User', async () => { - await visitAdminPage( '/user-new.php' ); - await hideElementVisibility( [ - ...elementsToHide, - '.password-input-wrapper', - ] ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); + test( 'Menus', async ({ admin, page }) => { + await admin.visitAdminPage( '/nav-menus.php' ); + await expect( page ).toHaveScreenshot( 'Menus.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); + + test( 'Plugins', async ({ admin, page }) => { + await admin.visitAdminPage( '/plugins.php' ); + await expect( page ).toHaveScreenshot( 'Plugins.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); + + test( 'All Users', async ({ admin, page }) => { + await admin.visitAdminPage( '/users.php' ); + await expect( page ).toHaveScreenshot( 'All Users.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); + + test( 'Add New User', async ({ admin, page }) => { + await admin.visitAdminPage( '/user-new.php' ); + await expect( page ).toHaveScreenshot( 'Add New User.png', { + mask: [ + ...elementsToHide, + '.password-input-wrapper' + ].map( ( selector ) => page.locator( selector ) ), + }); + } ); - it( 'Your Profile', async () => { - await visitAdminPage( '/profile.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); + test( 'Your Profile', async ({ admin, page }) => { + await admin.visitAdminPage( '/profile.php' ); + await expect( page ).toHaveScreenshot( 'Your Profile.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); - it( 'Available Tools', async () => { - await visitAdminPage( '/tools.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); + test( 'Available Tools', async ({ admin, page }) => { + await admin.visitAdminPage( '/tools.php' ); + await expect( page ).toHaveScreenshot( 'Available Tools.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); - it( 'Import', async () => { - await visitAdminPage( '/import.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); + test( 'Import', async ({ admin, page }) => { + await admin.visitAdminPage( '/import.php' ); + await expect( page ).toHaveScreenshot( 'Import.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); - it( 'Export', async () => { - await visitAdminPage( '/export.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); + test( 'Export', async ({ admin, page }) => { + await admin.visitAdminPage( '/export.php' ); + await expect( page ).toHaveScreenshot( 'Export.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); - it( 'Export Personal Data', async () => { - await visitAdminPage( '/export-personal-data.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); + test( 'Export Personal Data', async ({ admin, page }) => { + await admin.visitAdminPage( '/export-personal-data.php' ); + await expect( page ).toHaveScreenshot( 'Export Personal Data.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); - it( 'Erase Personal Data', async () => { - await visitAdminPage( '/erase-personal-data.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); + test( 'Erase Personal Data', async ({ admin, page }) => { + await admin.visitAdminPage( '/erase-personal-data.php' ); + await expect( page ).toHaveScreenshot( 'Erase Personal Data.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); - it( 'Reading Settings', async () => { - await visitAdminPage( '/options-reading.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); + test( 'Reading Settings', async ({ admin, page }) => { + await admin.visitAdminPage( '/options-reading.php' ); + await expect( page ).toHaveScreenshot( 'Reading Settings.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); - it( 'Discussion Settings', async () => { - await visitAdminPage( '/options-discussion.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); + test( 'Discussion Settings', async ({ admin, page }) => { + await admin.visitAdminPage( '/options-discussion.php' ); + await expect( page ).toHaveScreenshot( 'Discussion Settings.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); - it( 'Media Settings', async () => { - await visitAdminPage( '/options-media.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); + test( 'Media Settings', async ({ admin, page }) => { + await admin.visitAdminPage( '/options-media.php' ); + await expect( page ).toHaveScreenshot( 'Media Settings.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); - it( 'Privacy Settings', async () => { - await visitAdminPage( '/options-privacy.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); + test( 'Privacy Settings', async ({ admin, page }) => { + await admin.visitAdminPage( '/options-privacy.php' ); + await expect( page ).toHaveScreenshot( 'Privacy Settings.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); } ); } );