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
# E2E Tests

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