diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 0709194d35..79ebf67416 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 42eab6ff44..64001ad5ca 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 4ec15b9591..d936c62d19 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 871585d572..eb3eab76e7 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 cd00e9b7f0..5e963900fb 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 d53c8ec825..185908f794 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 596abbaa64..0a02b30a15 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/Gruntfile.js b/Gruntfile.js index 92feec5638..e66f2c74d6 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -80,7 +80,7 @@ module.exports = function(grunt) { } // First do `npm install` if package.json has changed. - installChanged.watchPackage(); +// installChanged.watchPackage(); // Load tasks. require('matchdep').filterDev(['grunt-*', '!grunt-legacy-util']).forEach( grunt.loadNpmTasks ); diff --git a/package-lock.json b/package-lock.json index 35a0bd2bfd..eba4628e2b 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 5babc9960f..4587765dad 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 fffb5e6fc4..23eb801671 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -1 +1 @@ -# 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) \ No newline at end of file diff --git a/tests/e2e/config/bootstrap.js b/tests/e2e/config/bootstrap.js deleted file mode 100644 index a9642034f5..0000000000 --- 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 0000000000..0c8063cf1a --- /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 c0b5ca35e1..0000000000 --- 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 0000000000..0de694e324 --- /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 d52a56f221..0000000000 --- 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 f451e25172..4271889150 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"; +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; -describe( 'Cache Control header directives', () => { +test.describe( 'Cache Control header directives', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.deleteAllPosts(); + }); - beforeEach( async () => { - await trashAllPosts(); - } ); + 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(); - it( 'No private directive present in cache control when user not logged in.', async () => { - await createNewPost( { title: 'Hello World' } ); - await publishPost(); - await logout(); + await admin.visitAdminPage( '/' ); - const response = await page.goto( createURL( '/hello-world/' ) ); + // 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 21da4dba0b..90459ac83a 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'; +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; -describe( 'Quick Draft', () => { - beforeEach( async () => { - await trashAllPosts(); +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' - ); + await admin.visitAdminPage( '/edit.php' ); - expect( - await postsListDraft.evaluate( ( element ) => element.innerText ) - ).toContain( 'Test Draft Title' ); + 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' - ); + await admin.visitAdminPage( '/edit.php' ); - expect( - await postsListDraft.evaluate( ( element ) => element.innerText ) - ).toContain( '(no title)' ); + 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 5c07019f4d..0e2eb3687f 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'; +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; -describe( 'Edit Posts', () => { - beforeEach( async () => { - await trashAllPosts(); +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"]' ); + await page + .frameLocator( '[name=editor-canvas]' ) + .locator( 'body > *' ) + .first() + .waitFor(); - const innerFrame = await editorFrame.contentFrame(); + const editorPostTitle = editor.canvas.getByRole( 'textbox', { name: 'Add title' } ); - // Wait for title field to render onscreen. - await innerFrame.waitForSelector( '.editor-post-title__input' ); - - // 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 () => { - const title = 'Test Title'; - await createNewPost( { title } ); - await publishPost(); - await visitAdminPage( '/edit.php' ); - await page.waitForSelector( '#the-list .type-post' ); + test( 'allows an existing post to be deleted using the Trash button', async ( { + admin, + editor, + page, + pageUtils + } ) => { + const title = 'Test Title'; + await admin.createNewPost( { title } ); + await editor.publishPost(); + await admin.visitAdminPage( '/edit.php' ); + + 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 e18df9f8fe..d970ca09b1 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 21b6e9737d..8f3ff20acc 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'; +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; -describe( 'Gutenberg plugin', () => { - beforeAll( async () => { - await installPlugin( 'gutenberg' ); +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', + } ); + } + + // Refetch installed plugin details. It avoids stale values when the test installs the plugin. + await requestUtils.getPluginsMap( /* forceRefetch */ true ); + await requestUtils.deactivatePlugin( 'gutenberg' ); } ); - afterAll( async () => { - await uninstallPlugin( 'gutenberg' ); - } ); + test( 'should activate', async ( { requestUtils }) => { + let plugin = await requestUtils.rest( { + path: 'wp/v2/plugins/gutenberg/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' ); + 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 038957883b..cfe018bbd6 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 1b53a76811..38aed7372e 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"; +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; -async function getResponseForApplicationPassword() { - return await rest({ - method: "GET", - path: "/wp/v2/users/me/application-passwords", - }); -} +const TEST_APPLICATION_NAME = 'Test Application'; -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"); - - await page.waitForSelector("#application-passwords-section .notice"); -} - -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.` ); + } ); + + 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.' + ); }); - it("should not allow to create two applications passwords with the same name", async () => { - await createApplicationPassword(TEST_APPLICATION_NAME); - await createApplicationPassword(TEST_APPLICATION_NAME); + test( 'should correctly revoke a single application password', async ( { + page, + applicationPasswords + } ) => { + await applicationPasswords.create(); - const errorMessage = await page.waitForSelector( - "#application-passwords-section .notice-error" + const revokeButton = page.getByRole( 'button', { name: `Revoke "${ TEST_APPLICATION_NAME }"` } ); + await expect( revokeButton ).toBeVisible(); + + // Revoke password. + page.on( 'dialog', ( dialog ) => dialog.accept() ); + await revokeButton.click(); + + await expect( + page.getByRole( 'alert' ) + ).toContainText( + 'Application password revoked.' ); - expect( - await errorMessage.evaluate((element) => element.textContent) - ).toContain("Each application name should be unique."); - }); + const response = await applicationPasswords.get(); + expect( response ).toEqual([]); + } ); - it("should correctly revoke a single application password", async () => { - await createApplicationPassword(TEST_APPLICATION_NAME); + test( 'should correctly revoke all the application passwords', 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); - }); + const revokeAllButton = page.getByRole( 'button', { name: 'Revoke all application passwords' } ); + await expect( revokeAllButton ).toBeVisible(); - await Promise.all([ - revocationDialogPromise, - revokeApplicationButton.click(), - ]); + // Confirms revoking action. + page.on( 'dialog', ( dialog ) => dialog.accept() ); + await revokeAllButton.click(); - const successMessage = await page.waitForSelector( - "#application-passwords-section .notice-success" - ); - expect( - await successMessage.evaluate((element) => element.textContent) - ).toContain("Application password revoked."); - - const response = await getResponseForApplicationPassword(); - expect(response).toEqual([]); - }); - - it("should correctly revoke all the application passwords", async () => { - await createApplicationPassword(TEST_APPLICATION_NAME); - - const revokeAllApplicationPasswordsButton = await page.waitForSelector( - "#revoke-all-application-passwords" + await expect( + page.getByRole( 'alert' ) + ).toContainText( + 'All application passwords revoked.' ); - const revocationDialogPromise = new Promise((resolve) => { - page.once("dialog", resolve); - }); + const response = await applicationPasswords.get(); + expect( response ).toEqual([]); + } ); +} ); - await Promise.all([ - revocationDialogPromise, - revokeAllApplicationPasswordsButton.click(), - ]); +class ApplicationPasswords { + constructor( { requestUtils, page, admin }) { + this.requestUtils = requestUtils; + this.page = page; + this.admin = admin; + } - /** - * This is commented out because we're using enablePageDialogAccept - * which is overly aggressive and no way to temporary disable it either. - */ - // await dialog.accept(); + async create(applicationName = TEST_APPLICATION_NAME) { + await this.admin.visitAdminPage( '/profile.php' ); - await page.waitForSelector( - "#application-passwords-section .notice-success" - ); + const newPasswordField = this.page.getByRole( 'textbox', { name: 'New Application Password Name' } ); + await expect( newPasswordField ).toBeVisible(); + await newPasswordField.fill( applicationName ); - const successMessage = await page.waitForSelector( - "#application-passwords-section .notice-success" - ); - expect( - await successMessage.evaluate((element) => element.textContent) - ).toContain("All application passwords revoked."); + await this.page.getByRole( 'button', { name: 'Add New Application Password' } ).click(); + await expect( this.page.getByRole( 'alert' ) ).toBeVisible(); + } - const response = await getResponseForApplicationPassword(); - expect(response).toEqual([]); - }); -}); + 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 e722552aed..aea827b511 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 773d2c1d74..0000000000 --- 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 0000000000..f3a0a4f26a --- /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 0000000000..e557faa135 --- /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 b62bb016c3..0000000000 --- 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 0000000000..6c2ff45472 --- /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 3ebf47edaa..c7a977181d 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 84e3c84784..0000000000 --- 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 f9a93824a6..496445ad0d 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 7ae9282ddc..32125c37a4 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 732ab66d44..9d6502e8e6 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 fe32fc0688..d7ef71e643 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 6130909e0c..0000000000 --- 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 aa5d3d1fbd..0000000000 --- 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 0000000000..759d887bf7 --- /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 a94c914d72..0000000000 --- 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 458c40f86e..d2f1eb9e7e 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'; +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; -// See https://github.com/puppeteer/puppeteer/blob/main/docs/api.md#pagescreenshotoptions for more available options. -const screenshotOptions = { - fullPage: true, -}; +const elementsToHide = [ + '#footer-upgrade', + '#wp-admin-bar-root-default', + '#toplevel_page_gutenberg' +]; -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, - } ); +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 ) ), + }); } ); - it( 'All Posts', async () => { - await visitAdminPage( '/edit.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); + 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 ) ), + }); } ); - 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(); + 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 ) ), + }); } ); - 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(); + 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 ) ), + }); } ); - it( 'Media Library', async () => { - await visitAdminPage( '/upload.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); + 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 ) ), + }); } ); - 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(); + 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 ) ), + }); } ); - 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(); + test( 'Comments', async ({ admin, page }) => { + await admin.visitAdminPage( '/edit-comments.php' ); + await expect( page ).toHaveScreenshot( 'Comments.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); } ); - it( 'Comments', async () => { - await visitAdminPage( '/edit-comments.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); + test( 'Widgets', async ({ admin, page }) => { + await admin.visitAdminPage( '/widgets.php' ); + await expect( page ).toHaveScreenshot( 'Widgets.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); } ); - it( 'Widgets', async () => { - await visitAdminPage( '/widgets.php' ); - await hideElementVisibility( elementsToHide ); - 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 ) ), + }); } ); - it( 'Menus', async () => { - await visitAdminPage( '/nav-menus.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); + test( 'Plugins', async ({ admin, page }) => { + await admin.visitAdminPage( '/plugins.php' ); + await expect( page ).toHaveScreenshot( 'Plugins.png', { + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); } ); - it( 'Plugins', async () => { - await visitAdminPage( '/plugins.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); + 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 ) ), + }); } ); - it( 'All Users', async () => { - await visitAdminPage( '/users.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); + 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( '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( '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( 'Your Profile', async () => { - await visitAdminPage( '/profile.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( 'Available Tools', async () => { - await visitAdminPage( '/tools.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( 'Import', async () => { - await visitAdminPage( '/import.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', async () => { - await visitAdminPage( '/export.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( '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( '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( '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( '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( 'Reading Settings', async () => { - await visitAdminPage( '/options-reading.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( 'Discussion Settings', async () => { - await visitAdminPage( '/options-discussion.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( 'Media Settings', async () => { - await visitAdminPage( '/options-media.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); - - 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 ) ), + }); } ); } );