Build/Test Tools: Migrate Puppeteer tests to Playwright.

As per the migration plan shared last year, this migrates all browser-based tests in WordPress core to use Playwright.
This includes end-to-end, performance, and visual regression tests.

Props swissspidy, mamaduka, kevin940726, bartkalisz, desrosj, adamsilverstein.
Fixes #59517.

git-svn-id: https://develop.svn.wordpress.org/trunk@56926 602fd350-edb4-49c9-b593-d223f7449a82
This commit is contained in:
Pascal Birchler 2023-10-13 08:11:41 +00:00
parent ac0bae2359
commit 5a838d1bb7
40 changed files with 1122 additions and 1006 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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'

View File

@ -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.
#

2
.gitignore vendored
View File

@ -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__

View File

@ -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 );

265
package-lock.json generated
View File

@ -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",

View File

@ -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"
}

View File

@ -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.** =admin and password=password. ``` 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 package: https://github.com/WordPress/gutenberg/blob/trunk/packages/scripts/package.json)
# 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.** 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). ``` 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 which will run the test suite using a headless browser. package: https://github.com/WordPress/gutenberg/blob/trunk/packages/scripts/package.json)

View File

@ -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<string,string>}
*/
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();
} );

View File

@ -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<void>}
*/
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;

View File

@ -1,10 +0,0 @@
const config = require( '@wordpress/scripts/config/jest-e2e.config' );
const jestE2EConfig = {
...config,
setupFilesAfterEnv: [
'<rootDir>/config/bootstrap.js',
],
};
module.exports = jestE2EConfig;

View File

@ -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;

View File

@ -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' }
);

View File

@ -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' );
} );
} );

View File

@ -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' );
} );
} );

View File

@ -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();
} );
} );

View File

@ -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.' );
} );
} );

View File

@ -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' );
} );
} );

View File

@ -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();
} );
} );

View File

@ -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',
} );
}
}

View File

@ -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
);
}

View File

@ -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();
} );

View File

@ -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<void>}
*/
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;

View File

@ -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;

View File

@ -1,14 +0,0 @@
const config = require( '@wordpress/scripts/config/jest-e2e.config' );
const jestE2EConfig = {
...config,
setupFilesAfterEnv: [
'<rootDir>/config/bootstrap.js',
],
globals: {
// Number of requests to run per test.
TEST_RUNS: 20,
}
};
module.exports = jestE2EConfig;

View File

@ -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;

View File

@ -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 = [

View File

@ -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' }
);

View File

@ -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 );
}
} );
} );
}
} );

View File

@ -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 );
}
} );
} );
}
} );

View File

@ -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<number>}
*/
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<number>}
*/
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,
};

View File

@ -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/`

View File

@ -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 } );

View File

@ -1,8 +0,0 @@
const config = require( '@wordpress/scripts/config/jest-e2e.config' );
const jestVisualRegressionConfig = {
...config,
setupFilesAfterEnv: [ '<rootDir>/config/bootstrap.js' ],
};
module.exports = jestVisualRegressionConfig;

View File

@ -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;

View File

@ -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' }
);

View File

@ -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 ) ),
});
} );
} );