From 69ed6a2c070c6c349f4c34f718bcc006061a442b Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 27 Sep 2023 17:37:00 +0200 Subject: [PATCH 01/59] WIP Playwright migration --- package-lock.json | 87 ++++++++++- package.json | 7 +- src/wp-includes/blocks.php | 2 +- tests/e2e/config/bootstrap.js | 145 ------------------ tests/e2e/config/global-setup.js | 43 ++++++ tests/e2e/jest.config.js | 10 -- tests/e2e/playwright.config.js | 64 ++++++++ tests/e2e/run-tests.js | 13 -- .../cache-control-headers-directives.test.js | 58 ++++--- tests/e2e/specs/dashboard.test.js | 1 - .../empty-trash-restore-trashed-posts.test.js | 101 +++++------- tests/e2e/specs/gutenberg-plugin.test.js | 40 +++-- tests/e2e/specs/hello.test.js | 18 ++- tests/performance/compare-results.js | 8 +- tests/performance/config/bootstrap.js | 41 ----- tests/performance/config/global-setup.js | 40 +++++ .../config/performance-reporter.js | 38 +++++ tests/performance/jest.config.js | 14 -- tests/performance/playwright.config.js | 67 ++++++++ tests/performance/results.js | 4 +- tests/performance/run-tests.js | 16 -- .../specs/home-block-theme.test.js | 88 +++++------ .../specs/home-classic-theme.test.js | 91 +++++------ tests/performance/utils.js | 49 +----- 24 files changed, 534 insertions(+), 511 deletions(-) delete mode 100644 tests/e2e/config/bootstrap.js create mode 100644 tests/e2e/config/global-setup.js delete mode 100644 tests/e2e/jest.config.js create mode 100644 tests/e2e/playwright.config.js delete mode 100644 tests/e2e/run-tests.js delete mode 100644 tests/performance/config/bootstrap.js create mode 100644 tests/performance/config/global-setup.js create mode 100644 tests/performance/config/performance-reporter.js delete mode 100644 tests/performance/jest.config.js create mode 100644 tests/performance/playwright.config.js delete mode 100644 tests/performance/run-tests.js diff --git a/package-lock.json b/package-lock.json index 2021e13dd3da5..370b8ef8d6d59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -106,10 +106,13 @@ }, "devDependencies": { "@lodder/grunt-postcss": "^3.1.1", + "@playwright/test": "1.38.1", "@pmmmwh/react-refresh-webpack-plugin": "0.5.5", + "@types/node": "16.18.54", "@wordpress/babel-preset-default": "7.26.2", "@wordpress/dependency-extraction-webpack-plugin": "4.25.2", "@wordpress/e2e-test-utils": "10.13.2", + "@wordpress/e2e-test-utils-playwright": "0.10.2", "@wordpress/scripts": "26.13.2", "autoprefixer": "10.4.16", "chalk": "5.3.0", @@ -3743,6 +3746,21 @@ "node": ">=8" } }, + "node_modules/@playwright/test": { + "version": "1.38.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.38.1.tgz", + "integrity": "sha512-NqRp8XMwj3AK+zKLbZShl0r/9wKgzqI/527bkptKXomtuo+dOjU9NdMASQ8DNC9z9zLOMbG53T4eihYr3XR+BQ==", + "dev": true, + "dependencies": { + "playwright": "1.38.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "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", @@ -5727,9 +5745,9 @@ "integrity": "sha512-F0oAily9Q9QQpv9JKxKn0zMKfOo36KHCW7myYsmUyf2t0g+sBTbG3UleTPoguHdE1z3GLFr3p7/wiOio52QFjQ==" }, "node_modules/@types/node": { - "version": "14.14.20", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.20.tgz", - "integrity": "sha512-Y93R97Ouif9JEOWPIUyU+eyIdyRqQR0I8Ez1dzku4hDx34NWh4HbtIc3WNzwB1Y9ULvNGeu5B8h8bVL5cAk4/A==", + "version": "16.18.54", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.54.tgz", + "integrity": "sha512-oTmGy68gxZZ21FhTJVVvZBYpQHEBZxHKTsGshobMqm9qWpbqdZsA5jvsuPZcHu0KwpmLrOHWPdEfg7XDpNT9UA==", "dev": true }, "node_modules/@types/normalize-package-data": { @@ -26574,6 +26592,24 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.38.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.38.1.tgz", + "integrity": "sha512-oRMSJmZrOu1FP5iu3UrCx8JEFRIMxLDM0c/3o4bpzU5Tz97BypefWf7TuTNPWeCe279TPal5RtPPZ+9lW/Qkow==", + "dev": true, + "dependencies": { + "playwright-core": "1.38.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, "node_modules/playwright-core": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.32.0.tgz", @@ -26586,6 +26622,18 @@ "node": ">=14" } }, + "node_modules/playwright/node_modules/playwright-core": { + "version": "1.38.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.38.1.tgz", + "integrity": "sha512-tQqNFUKa3OfMf4b2jQ7aGLB8o9bS3bOY0yMEtldtC2+spf8QXG9zvXLTXUeRsoNuxEYMgLYR+NXfAa1rjKRcrg==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/plur": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/plur/-/plur-4.0.0.tgz", @@ -36730,6 +36778,15 @@ } } }, + "@playwright/test": { + "version": "1.38.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.38.1.tgz", + "integrity": "sha512-NqRp8XMwj3AK+zKLbZShl0r/9wKgzqI/527bkptKXomtuo+dOjU9NdMASQ8DNC9z9zLOMbG53T4eihYr3XR+BQ==", + "dev": true, + "requires": { + "playwright": "1.38.1" + } + }, "@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", @@ -38219,9 +38276,9 @@ "integrity": "sha512-F0oAily9Q9QQpv9JKxKn0zMKfOo36KHCW7myYsmUyf2t0g+sBTbG3UleTPoguHdE1z3GLFr3p7/wiOio52QFjQ==" }, "@types/node": { - "version": "14.14.20", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.20.tgz", - "integrity": "sha512-Y93R97Ouif9JEOWPIUyU+eyIdyRqQR0I8Ez1dzku4hDx34NWh4HbtIc3WNzwB1Y9ULvNGeu5B8h8bVL5cAk4/A==", + "version": "16.18.54", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.54.tgz", + "integrity": "sha512-oTmGy68gxZZ21FhTJVVvZBYpQHEBZxHKTsGshobMqm9qWpbqdZsA5jvsuPZcHu0KwpmLrOHWPdEfg7XDpNT9UA==", "dev": true }, "@types/normalize-package-data": { @@ -54043,6 +54100,24 @@ } } }, + "playwright": { + "version": "1.38.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.38.1.tgz", + "integrity": "sha512-oRMSJmZrOu1FP5iu3UrCx8JEFRIMxLDM0c/3o4bpzU5Tz97BypefWf7TuTNPWeCe279TPal5RtPPZ+9lW/Qkow==", + "dev": true, + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.38.1" + }, + "dependencies": { + "playwright-core": { + "version": "1.38.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.38.1.tgz", + "integrity": "sha512-tQqNFUKa3OfMf4b2jQ7aGLB8o9bS3bOY0yMEtldtC2+spf8QXG9zvXLTXUeRsoNuxEYMgLYR+NXfAa1rjKRcrg==", + "dev": true + } + } + }, "playwright-core": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.32.0.tgz", diff --git a/package.json b/package.json index bb564bd95a064..0017d41e4fff4 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,13 @@ ], "devDependencies": { "@lodder/grunt-postcss": "^3.1.1", + "@playwright/test": "1.38.1", "@pmmmwh/react-refresh-webpack-plugin": "0.5.5", + "@types/node": "16.18.54", "@wordpress/babel-preset-default": "7.26.2", "@wordpress/dependency-extraction-webpack-plugin": "4.25.2", "@wordpress/e2e-test-utils": "10.13.2", + "@wordpress/e2e-test-utils-playwright": "0.10.2", "@wordpress/scripts": "26.13.2", "autoprefixer": "10.4.16", "chalk": "5.3.0", @@ -189,9 +192,9 @@ "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:e2e": "wp-scripts test-playwright --config tests/e2e/playwright.config.js", "test:visual": "node ./tests/visual-regression/run-tests.js", "sync-gutenberg-packages": "grunt sync-gutenberg-packages", "postsync-gutenberg-packages": "grunt wp-packages:sync-stable-blocks && grunt build --dev && grunt build" diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php index 482955ea21d53..d48ee3fa57c9e 100644 --- a/src/wp-includes/blocks.php +++ b/src/wp-includes/blocks.php @@ -181,7 +181,7 @@ function register_block_script_handle( $metadata, $field_name, $index = 0 ) { $script_uri = get_block_asset_url( $script_path_norm ); $script_args = array(); - if ( 'viewScript' === $field_name ) { + if ( 'viewScript' === $field_name && $script_uri ) { $script_args['strategy'] = 'defer'; } diff --git a/tests/e2e/config/bootstrap.js b/tests/e2e/config/bootstrap.js deleted file mode 100644 index a9642034f5fad..0000000000000 --- a/tests/e2e/config/bootstrap.js +++ /dev/null @@ -1,145 +0,0 @@ -import { get } from 'lodash'; -import { - clearLocalStorage, - enablePageDialogAccept, - setBrowserViewport, -} from '@wordpress/e2e-test-utils'; - -/** - * Environment variables - */ -const { PUPPETEER_TIMEOUT } = process.env; - -/** - * Set of console logging types observed to protect against unexpected yet - * handled (i.e. not catastrophic) errors or warnings. Each key corresponds - * to the Puppeteer ConsoleMessage type, its value the corresponding function - * on the console global object. - * - * @type {Object} - */ -const OBSERVED_CONSOLE_MESSAGE_TYPES = { - warning: 'warn', - error: 'error', -}; - -/** - * Array of page event tuples of [ eventName, handler ]. - * - * @type {Array} - */ -const pageEvents = []; - -// The Jest timeout is increased because these tests are a bit slow -jest.setTimeout( PUPPETEER_TIMEOUT || 100000 ); - - -/** - * Adds an event listener to the page to handle additions of page event - * handlers, to assure that they are removed at test teardown. - */ -function capturePageEventsForTearDown() { - page.on( 'newListener', ( eventName, listener ) => { - pageEvents.push( [ eventName, listener ] ); - } ); -} - -/** - * Removes all bound page event handlers. - */ -function removePageEvents() { - pageEvents.forEach( ( [ eventName, handler ] ) => { - page.removeListener( eventName, handler ); - } ); -} - -/** - * Adds a page event handler to emit uncaught exception to process if one of - * the observed console logging types is encountered. - */ -function observeConsoleLogging() { - page.on( 'console', ( message ) => { - const type = message.type(); - if ( ! OBSERVED_CONSOLE_MESSAGE_TYPES.hasOwnProperty( type ) ) { - return; - } - - let text = message.text(); - - // An exception is made for _blanket_ deprecation warnings: Those - // which log regardless of whether a deprecated feature is in use. - if ( text.includes( 'This is a global warning' ) ) { - return; - } - - // An exception is made for jQuery migrate console warnings output by - // the unminified script loaded in development environments. - if ( text.includes( 'JQMIGRATE' ) ) { - return; - } - - // Viewing posts on the front end can result in this error, which - // has nothing to do with Gutenberg. - if ( text.includes( 'net::ERR_UNKNOWN_URL_SCHEME' ) ) { - return; - } - - // A bug present in WordPress 5.2 will produce console warnings when - // loading the Dashicons font. These can be safely ignored, as they do - // not otherwise regress on application behavior. This logic should be - // removed once the associated ticket has been closed. - // - // See: https://core.trac.wordpress.org/ticket/47183 - if ( - text.startsWith( 'Failed to decode downloaded font:' ) || - text.startsWith( 'OTS parsing error:' ) - ) { - return; - } - - const logFunction = OBSERVED_CONSOLE_MESSAGE_TYPES[ type ]; - - // As of Puppeteer 1.6.1, `message.text()` wrongly returns an object of - // type JSHandle for error logging, instead of the expected string. - // - // See: https://github.com/GoogleChrome/puppeteer/issues/3397 - // - // The recommendation there to asynchronously resolve the error value - // upon a console event may be prone to a race condition with the test - // completion, leaving a possibility of an error not being surfaced - // correctly. Instead, the logic here synchronously inspects the - // internal object shape of the JSHandle to find the error text. If it - // cannot be found, the default text value is used instead. - text = get( message.args(), [ 0, '_remoteObject', 'description' ], text ); - - // Disable reason: We intentionally bubble up the console message - // which, unless the test explicitly anticipates the logging via - // @wordpress/jest-console matchers, will cause the intended test - // failure. - - // eslint-disable-next-line no-console - console[ logFunction ]( text ); - } ); -} - -// Before every test suite run, delete all content created by the test. This ensures -// other posts/comments/etc. aren't dirtying tests and tests don't depend on -// each other's side-effects. -beforeAll( async () => { - capturePageEventsForTearDown(); - enablePageDialogAccept(); - observeConsoleLogging(); - await page.emulateMediaFeatures( [ - { name: 'prefers-reduced-motion', value: 'reduce' }, - ] ); - await setBrowserViewport( 'large' ); -} ); - -afterEach( async () => { - await clearLocalStorage(); - await setBrowserViewport( 'large' ); -} ); - -afterAll( () => { - removePageEvents(); -} ); diff --git a/tests/e2e/config/global-setup.js b/tests/e2e/config/global-setup.js new file mode 100644 index 0000000000000..0c8063cf1a5a5 --- /dev/null +++ b/tests/e2e/config/global-setup.js @@ -0,0 +1,43 @@ +/** + * External dependencies + */ +import { request } from '@playwright/test'; + +/** + * WordPress dependencies + */ +import { RequestUtils } from '@wordpress/e2e-test-utils-playwright'; + +/** + * + * @param {import('@playwright/test').FullConfig} config + * @returns {Promise} + */ +async function globalSetup( config ) { + const { storageState, baseURL } = config.projects[ 0 ].use; + const storageStatePath = + typeof storageState === 'string' ? storageState : undefined; + + const requestContext = await request.newContext( { + baseURL, + } ); + + const requestUtils = new RequestUtils( requestContext, { + storageStatePath, + } ); + + // Authenticate and save the storageState to disk. + await requestUtils.setupRest(); + + // Reset the test environment before running the tests. + await Promise.all( [ + requestUtils.activateTheme( 'twentytwentyone' ), + requestUtils.deleteAllPosts(), + requestUtils.deleteAllBlocks(), + requestUtils.resetPreferences(), + ] ); + + await requestContext.dispose(); +} + +export default globalSetup; diff --git a/tests/e2e/jest.config.js b/tests/e2e/jest.config.js deleted file mode 100644 index c0b5ca35e1945..0000000000000 --- a/tests/e2e/jest.config.js +++ /dev/null @@ -1,10 +0,0 @@ -const config = require( '@wordpress/scripts/config/jest-e2e.config' ); - -const jestE2EConfig = { - ...config, - setupFilesAfterEnv: [ - '/config/bootstrap.js', - ], -}; - -module.exports = jestE2EConfig; diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js new file mode 100644 index 0000000000000..cca8d794c4c27 --- /dev/null +++ b/tests/e2e/playwright.config.js @@ -0,0 +1,64 @@ +/** + * External dependencies + */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, devices } from '@playwright/test'; + +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( { + reporter: process.env.CI ? [ [ 'github' ] ] : [ [ 'list' ] ], + forbidOnly: !! process.env.CI, + // fullyParallel: false, + workers: 1, + retries: process.env.CI ? 2 : 0, + timeout: parseInt( process.env.TIMEOUT || '', 10 ) || 100_000, // Defaults to 100 seconds. + // Don't report slow test "files", as we will be running our tests in serial. + reportSlowTests: null, + testDir: './specs', + outputDir: path.join( process.env.WP_ARTIFACTS_PATH, 'test-results' ), + snapshotPathTemplate: + '{testDir}/{testFileDir}/__snapshots__/{arg}-{projectName}{ext}', + globalSetup: fileURLToPath( + new URL( './config/global-setup.js', 'file:' + __filename ).href + ), + fullyParallel: false, + use: { + baseURL: process.env.WP_BASE_URL || 'http://localhost:8889', + headless: true, + viewport: { + width: 960, + height: 700, + }, + ignoreHTTPSErrors: true, + locale: 'en-US', + contextOptions: { + reducedMotion: 'reduce', + strictSelectors: true, + }, + storageState: process.env.STORAGE_STATE_PATH, + actionTimeout: 10_000, // 10 seconds. + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'on-first-retry', + }, + webServer: { + command: 'npm run wp-env start', + port: 8889, + timeout: 120_000, // 120 seconds. + reuseExistingServer: true, + }, + projects: [ + { + name: 'chromium', + use: { ...devices[ 'Desktop Chrome' ] }, + }, + ], +} ); + +export default config; diff --git a/tests/e2e/run-tests.js b/tests/e2e/run-tests.js deleted file mode 100644 index d52a56f221a56..0000000000000 --- a/tests/e2e/run-tests.js +++ /dev/null @@ -1,13 +0,0 @@ -const dotenv = require( 'dotenv' ); -const dotenv_expand = require( 'dotenv-expand' ); -const { execSync } = require( 'child_process' ); - -// WP_BASE_URL interpolates LOCAL_PORT, so needs to be parsed by dotenv_expand(). -dotenv_expand.expand( dotenv.config() ); - -// Run the tests, passing additional arguments through to the test script. -execSync( - 'wp-scripts test-e2e --config tests/e2e/jest.config.js ' + - process.argv.slice( 2 ).join( ' ' ), - { stdio: 'inherit' } -); diff --git a/tests/e2e/specs/cache-control-headers-directives.test.js b/tests/e2e/specs/cache-control-headers-directives.test.js index f451e251721a6..427188915098a 100644 --- a/tests/e2e/specs/cache-control-headers-directives.test.js +++ b/tests/e2e/specs/cache-control-headers-directives.test.js @@ -1,38 +1,46 @@ -import { - visitAdminPage, - createNewPost, - publishPost, - trashAllPosts, - createURL, - logout, -} from "@wordpress/e2e-test-utils"; - -describe( 'Cache Control header directives', () => { - - beforeEach( async () => { - await trashAllPosts(); - } ); - - it( 'No private directive present in cache control when user not logged in.', async () => { - await createNewPost( { title: 'Hello World' } ); - await publishPost(); - await logout(); - - const response = await page.goto( createURL( '/hello-world/' ) ); +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +test.describe( 'Cache Control header directives', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.deleteAllPosts(); + }); + + test( + 'No private directive present in cache control when user not logged in.', + async ( { browser, admin, editor} + ) => { + await admin.createNewPost( { title: 'Hello World' } ); + await editor.publishPost(); + + await admin.visitAdminPage( '/' ); + + // Create a new incognito browser context to simulate logged-out state. + const context = await browser.newContext(); + const loggedOutPage = await context.newPage(); + + const response = await loggedOutPage.goto( '/hello-world/' ); const responseHeaders = response.headers(); + // Dispose context once it's no longer needed. + await context.close(); + expect( responseHeaders ).toEqual( expect.not.objectContaining( { "cache-control": "no-store" } ) ); expect( responseHeaders ).toEqual( expect.not.objectContaining( { "cache-control": "private" } ) ); } ); - it( 'Private directive header present in cache control when logged in.', async () => { - await visitAdminPage( '/' ); + test( + 'Private directive header present in cache control when logged in.', + async ( { page, admin } + ) => { + await admin.visitAdminPage( '/' ); - const response = await page.goto( createURL( '/wp-admin' ) ); + const response = await page.goto( '/wp-admin' ); const responseHeaders = response.headers(); expect( responseHeaders[ 'cache-control' ] ).toContain( 'no-store' ); expect( responseHeaders[ 'cache-control' ] ).toContain( 'private' ); } ); - } ); diff --git a/tests/e2e/specs/dashboard.test.js b/tests/e2e/specs/dashboard.test.js index 21da4dba0b97c..4c732e0806033 100644 --- a/tests/e2e/specs/dashboard.test.js +++ b/tests/e2e/specs/dashboard.test.js @@ -1,5 +1,4 @@ import { - pressKeyTimes, trashAllPosts, visitAdminPage, } from '@wordpress/e2e-test-utils'; diff --git a/tests/e2e/specs/empty-trash-restore-trashed-posts.test.js b/tests/e2e/specs/empty-trash-restore-trashed-posts.test.js index e18df9f8fe7b1..44472b2c76b19 100644 --- a/tests/e2e/specs/empty-trash-restore-trashed-posts.test.js +++ b/tests/e2e/specs/empty-trash-restore-trashed-posts.test.js @@ -1,72 +1,55 @@ -import { - visitAdminPage, - createNewPost, - trashAllPosts, - publishPost, -} from "@wordpress/e2e-test-utils"; +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; -const POST_TITLE = "Test Title"; +const POST_TITLE = 'Test Title'; -describe("Empty Trash", () => { - async function createPost(title) { - // Create a Post - await createNewPost({ title }); - await publishPost(); - } +test.describe( 'Empty Trash', () => { + test.beforeAll( 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"); + // Move post to trash + await page.getByLabel( new RegExp( `^“${POST_TITLE}”` ) ).hover(); + await page.getByLabel( `Move “${POST_TITLE}” to the Trash` ).click(); - // Move post to trash - await page.hover(`[aria-label^="“${POST_TITLE}”"]`); - await page.click(`[aria-label='Move “${POST_TITLE}” to the Trash']`); + // Empty trash + await page.getByRole( 'link', { name: 'Trash' } ).click(); + await page.getByRole( 'button', { name: 'Empty 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(), - ]); + 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\./); + } ); - 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\./); - }); + test('Restore trash post', async ( { admin, editor, page }) => { + await admin.createNewPost( { title: POST_TITLE } ); + await editor.publishPost(); - it("Restore trash post", async () => { - await createPost(POST_TITLE); + await admin.visitAdminPage( '/edit.php' ); - await visitAdminPage("/edit.php"); + // Move post to trash. + await page.getByLabel( new RegExp( `^“${POST_TITLE}”` ) ).hover(); + await page.getByLabel( `Move “${POST_TITLE}” to the Trash` ).click(); - // Move one post to trash. - await page.hover(`[aria-label^="“${POST_TITLE}”"]`); - await page.click(`[aria-label='Move “${POST_TITLE}” to the Trash']`); + // Remove post from trash. + await page.getByRole( 'link', { name: 'Trash' } ).click(); - // 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"]`); + const postTitle = await page.getByText( new RegExp( `^${POST_TITLE}$` ) ).first(); + await postTitle.hover(); + await page.getByLabel( `Restore “${POST_TITLE}” from 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."); - }); -}); + // 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.' ); + } ); +} ); diff --git a/tests/e2e/specs/gutenberg-plugin.test.js b/tests/e2e/specs/gutenberg-plugin.test.js index 21b6e9737d0f1..fd3faa90956fd 100644 --- a/tests/e2e/specs/gutenberg-plugin.test.js +++ b/tests/e2e/specs/gutenberg-plugin.test.js @@ -1,26 +1,24 @@ -import { - activatePlugin, - deactivatePlugin, - installPlugin, - uninstallPlugin, -} from '@wordpress/e2e-test-utils'; +/** + * WordPress dependencies + */ +import { test } from '@wordpress/e2e-test-utils-playwright'; -describe( 'Gutenberg plugin', () => { - beforeAll( async () => { - await installPlugin( 'gutenberg' ); - } ); +test.describe( 'Gutenberg plugin', () => { + test( 'should activate', async ( { requestUtils }) => { + // Increasing timeout to 5 minutes because install could take longer. + test.setTimeout( 300_000 ); - afterAll( async () => { - await uninstallPlugin( 'gutenberg' ); - } ); + await requestUtils.rest( { + method: 'POST', + path: 'wp/v2/plugins?slug=gutenberg&status=active', + } ); + + // This flow will only work if the activation previously succeeded. + await requestUtils.deactivatePlugin( 'gutenberg' ); - it( 'should activate', async () => { - await activatePlugin( 'gutenberg' ); - /* - * If plugin activation fails, it will time out and throw an error, - * since the activatePlugin helper is looking for a `.deactivate` link - * which is only there if activation succeeds. - */ - await deactivatePlugin( 'gutenberg' ); + await requestUtils.rest( { + method: 'DELETE', + path: 'wp/v2/plugins/gutenberg', + } ); } ); } ); diff --git a/tests/e2e/specs/hello.test.js b/tests/e2e/specs/hello.test.js index 038957883be09..e20c6b5be49fa 100644 --- a/tests/e2e/specs/hello.test.js +++ b/tests/e2e/specs/hello.test.js @@ -1,11 +1,13 @@ -import { visitAdminPage } from '@wordpress/e2e-test-utils'; +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; -describe( 'Hello World', () => { - it( 'Should load properly', async () => { - await visitAdminPage( '/' ); - const nodes = await page.$x( - '//h2[contains(text(), "Welcome to WordPress!")]' - ); - expect( nodes.length ).not.toEqual( 0 ); +test.describe( 'Site editor title', () => { + test( 'Should load properly', async ( { admin, page }) => { + await admin.visitAdminPage( '/' ); + await expect( + page.getByRole('heading', { name: 'Welcome to WordPress', level: 2 }) + ).toBeVisible(); } ); } ); diff --git a/tests/performance/compare-results.js b/tests/performance/compare-results.js index e722552aeda04..c63b60bea99b4 100644 --- a/tests/performance/compare-results.js +++ b/tests/performance/compare-results.js @@ -3,8 +3,12 @@ /** * External dependencies. */ -const fs = require( 'fs' ); -const path = require( 'path' ); +const fs = require( 'node:fs' ); +const path = require( 'node:path' ); + +/** + * Internal dependencies + */ const { median } = require( './utils' ); /** diff --git a/tests/performance/config/bootstrap.js b/tests/performance/config/bootstrap.js deleted file mode 100644 index 773d2c1d74f87..0000000000000 --- a/tests/performance/config/bootstrap.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * WordPress dependencies. - */ -import { - clearLocalStorage, - enablePageDialogAccept, - setBrowserViewport, -} from '@wordpress/e2e-test-utils'; - -/** - * Timeout, in seconds, that the test should be allowed to run. - * - * @type {string|undefined} - */ -const PUPPETEER_TIMEOUT = process.env.PUPPETEER_TIMEOUT; - -// The Jest timeout is increased because these tests are a bit slow. -jest.setTimeout( PUPPETEER_TIMEOUT || 100000 ); - -async function setupBrowser() { - await clearLocalStorage(); - await setBrowserViewport( 'large' ); -} - -/* - * Before every test suite run, delete all content created by the test. This ensures - * other posts/comments/etc. aren't dirtying tests and tests don't depend on - * each other's side-effects. - */ -beforeAll( async () => { - enablePageDialogAccept(); - - await setBrowserViewport( 'large' ); - await page.emulateMediaFeatures( [ - { name: 'prefers-reduced-motion', value: 'reduce' }, - ] ); -} ); - -afterEach( async () => { - await setupBrowser(); -} ); diff --git a/tests/performance/config/global-setup.js b/tests/performance/config/global-setup.js new file mode 100644 index 0000000000000..f3a0a4f26a691 --- /dev/null +++ b/tests/performance/config/global-setup.js @@ -0,0 +1,40 @@ +/** + * External dependencies + */ +import { request } from '@playwright/test'; + +/** + * WordPress dependencies + */ +import { RequestUtils } from '@wordpress/e2e-test-utils-playwright'; + +/** + * + * @param {import('@playwright/test').FullConfig} config + * @returns {Promise} + */ +async function globalSetup( config ) { + const { storageState, baseURL } = config.projects[ 0 ].use; + const storageStatePath = + typeof storageState === 'string' ? storageState : undefined; + + const requestContext = await request.newContext( { + baseURL, + } ); + + const requestUtils = new RequestUtils( requestContext, { + storageStatePath, + } ); + + // Authenticate and save the storageState to disk. + await requestUtils.setupRest(); + + // Reset the test environment before running the tests. + await Promise.all( [ + requestUtils.activateTheme( 'twentytwentyone' ), + ] ); + + await requestContext.dispose(); +} + +export default globalSetup; diff --git a/tests/performance/config/performance-reporter.js b/tests/performance/config/performance-reporter.js new file mode 100644 index 0000000000000..e557faa135cbd --- /dev/null +++ b/tests/performance/config/performance-reporter.js @@ -0,0 +1,38 @@ +/** + * External dependencies + */ +import { join, dirname, basename } from 'node:path'; +import { writeFileSync } from 'node:fs'; + +/** + * Internal dependencies + */ +import { getResultsFilename } from '../utils'; + +/** + * @implements {import('@playwright/test/reporter').Reporter} + */ +class PerformanceReporter { + /** + * + * @param {import('@playwright/test/reporter').TestCase} test + * @param {import('@playwright/test/reporter').TestResult} result + */ + onTestEnd( test, result ) { + const performanceResults = result.attachments.find( + ( attachment ) => attachment.name === 'results' + ); + + if ( performanceResults?.body ) { + writeFileSync( + join( + dirname( test.location.file ), + getResultsFilename( basename( test.location.file, '.js' ) ) + ), + performanceResults.body.toString( 'utf-8' ) + ); + } + } +} + +export default PerformanceReporter; diff --git a/tests/performance/jest.config.js b/tests/performance/jest.config.js deleted file mode 100644 index b62bb016c3b03..0000000000000 --- a/tests/performance/jest.config.js +++ /dev/null @@ -1,14 +0,0 @@ -const config = require( '@wordpress/scripts/config/jest-e2e.config' ); - -const jestE2EConfig = { - ...config, - setupFilesAfterEnv: [ - '/config/bootstrap.js', - ], - globals: { - // Number of requests to run per test. - TEST_RUNS: 20, - } -}; - -module.exports = jestE2EConfig; diff --git a/tests/performance/playwright.config.js b/tests/performance/playwright.config.js new file mode 100644 index 0000000000000..dc93573d3d1bb --- /dev/null +++ b/tests/performance/playwright.config.js @@ -0,0 +1,67 @@ +/** + * External dependencies + */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, devices } from '@playwright/test'; + +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( { + reporter: process.env.CI + ? './config/performance-reporter.ts' + : [ [ 'list' ], [ './config/performance-reporter.ts' ] ], + forbidOnly: !! process.env.CI, + // fullyParallel: false, + 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, + testDir: './specs', + outputDir: path.join( process.env.WP_ARTIFACTS_PATH, 'test-results' ), + snapshotPathTemplate: + '{testDir}/{testFileDir}/__snapshots__/{arg}-{projectName}{ext}', + globalSetup: fileURLToPath( + new URL( './config/global-setup.js', 'file:' + __filename ).href + ), + fullyParallel: false, + use: { + baseURL: process.env.WP_BASE_URL || 'http://localhost:8889', + headless: true, + viewport: { + width: 960, + height: 700, + }, + ignoreHTTPSErrors: true, + locale: 'en-US', + contextOptions: { + reducedMotion: 'reduce', + strictSelectors: true, + }, + storageState: process.env.STORAGE_STATE_PATH, + actionTimeout: 10_000, // 10 seconds. + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'off', + }, + webServer: { + command: 'npm run wp-env start', + port: 8889, + timeout: 120_000, // 120 seconds. + reuseExistingServer: true, + }, + projects: [ + { + name: 'chromium', + use: { ...devices[ 'Desktop Chrome' ] }, + }, + ], +} ); + +export default config; diff --git a/tests/performance/results.js b/tests/performance/results.js index 3ebf47edaabd0..c7a977181da6a 100644 --- a/tests/performance/results.js +++ b/tests/performance/results.js @@ -3,8 +3,8 @@ /** * External dependencies. */ -const fs = require( 'fs' ); -const { join } = require( 'path' ); +const fs = require( 'node:fs' ); +const { join } = require( 'node:path' ); const { median, getResultsFilename } = require( './utils' ); const testSuites = [ diff --git a/tests/performance/run-tests.js b/tests/performance/run-tests.js deleted file mode 100644 index 84e3c847842c2..0000000000000 --- a/tests/performance/run-tests.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * External dependencies. - */ -const dotenv = require( 'dotenv' ); -const dotenv_expand = require( 'dotenv-expand' ); -const { execSync } = require( 'child_process' ); - -// WP_BASE_URL interpolates LOCAL_PORT, so needs to be parsed by dotenv_expand(). -dotenv_expand.expand( dotenv.config() ); - -// Run the tests, passing additional arguments through to the test script. -execSync( - 'wp-scripts test-e2e --config tests/performance/jest.config.js ' + - process.argv.slice( 2 ).join( ' ' ), - { stdio: 'inherit' } -); diff --git a/tests/performance/specs/home-block-theme.test.js b/tests/performance/specs/home-block-theme.test.js index f9a93824a6077..496445ad0d0c8 100644 --- a/tests/performance/specs/home-block-theme.test.js +++ b/tests/performance/specs/home-block-theme.test.js @@ -1,67 +1,57 @@ /** - * External dependencies. + * WordPress dependencies */ -const { basename, join } = require( 'path' ); -const { writeFileSync } = require( 'fs' ); -const { - getResultsFilename, - getTimeToFirstByte, - getLargestContentfulPaint, -} = require( './../utils' ); +import { test } from '@wordpress/e2e-test-utils-playwright'; /** - * WordPress dependencies. + * Internal dependencies */ -import { activateTheme, createURL } from '@wordpress/e2e-test-utils'; +import { camelCaseDashes } from '../utils'; -describe( 'Server Timing - Twenty Twenty Three', () => { - const results = { - wpBeforeTemplate: [], - wpTemplate: [], - wpTotal: [], - timeToFirstByte: [], - largestContentfulPaint: [], - lcpMinusTtfb: [], - }; +const results = { + timeToFirstByte: [], + largestContentfulPaint: [], + lcpMinusTtfb: [], +}; - beforeAll( async () => { - await activateTheme( 'twentytwentythree' ); +test.describe( 'Front End - Twenty Twenty Three', () => { + test.use( { + storageState: {}, // User will be logged out. } ); - afterAll( async () => { - const resultsFilename = getResultsFilename( - basename( __filename, '.js' ) - ); - writeFileSync( - join( __dirname, resultsFilename ), - JSON.stringify( results, null, 2 ) - ); + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentythree' ); } ); - it( 'Server Timing Metrics', async () => { - let i = TEST_RUNS; - while ( i-- ) { - await page.goto( createURL( '/' ) ); - const navigationTimingJson = await page.evaluate( () => - JSON.stringify( performance.getEntriesByType( 'navigation' ) ) - ); + test.afterAll( async ( { requestUtils }, testInfo ) => { + await testInfo.attach( 'results', { + body: JSON.stringify( results, null, 2 ), + contentType: 'application/json', + } ); + await requestUtils.activateTheme( 'twentytwentyone' ); + } ); - const [ navigationTiming ] = JSON.parse( navigationTimingJson ); + const iterations = Number( process.env.TEST_RUNS ); + for ( let i = 1; i <= iterations; i++ ) { + test( `Measure load time metrics (${ i } of ${ iterations })`, async ( { + page, + metrics, + } ) => { + await page.goto( '/' ); - results.wpBeforeTemplate.push( - navigationTiming.serverTiming[ 0 ].duration - ); - results.wpTemplate.push( - navigationTiming.serverTiming[ 1 ].duration - ); - results.wpTotal.push( navigationTiming.serverTiming[ 2 ].duration ); + const serverTiming = await metrics.getServerTiming(); - const ttfb = await getTimeToFirstByte(); - const lcp = await getLargestContentfulPaint(); + for ( const [key, value] of Object.entries( serverTiming ) ) { + results[ camelCaseDashes( key ) ] ??= []; + results[ camelCaseDashes( key ) ].push( value ); + } + + const ttfb = await metrics.getTimeToFirstByte(); + const lcp = await metrics.getLargestContentfulPaint(); - results.timeToFirstByte.push( ttfb ); results.largestContentfulPaint.push( lcp ); + results.timeToFirstByte.push( ttfb ); results.lcpMinusTtfb.push( lcp - ttfb ); - } - } ); + } ); + } } ); diff --git a/tests/performance/specs/home-classic-theme.test.js b/tests/performance/specs/home-classic-theme.test.js index 7ae9282ddc3cc..32125c37a42a1 100644 --- a/tests/performance/specs/home-classic-theme.test.js +++ b/tests/performance/specs/home-classic-theme.test.js @@ -1,71 +1,56 @@ /** - * External dependencies. + * WordPress dependencies */ -const { basename, join } = require( 'path' ); -const { writeFileSync } = require( 'fs' ); -const { exec } = require( 'child_process' ); -const { - getResultsFilename, - getTimeToFirstByte, - getLargestContentfulPaint, -} = require( './../utils' ); +import { test } from '@wordpress/e2e-test-utils-playwright'; /** - * WordPress dependencies. + * Internal dependencies */ -import { activateTheme, createURL } from '@wordpress/e2e-test-utils'; +import { camelCaseDashes } from '../utils'; -describe( 'Server Timing - Twenty Twenty One', () => { - const results = { - wpBeforeTemplate: [], - wpTemplate: [], - wpTotal: [], - timeToFirstByte: [], - largestContentfulPaint: [], - lcpMinusTtfb: [], - }; +const results = { + timeToFirstByte: [], + largestContentfulPaint: [], + lcpMinusTtfb: [], +}; - beforeAll( async () => { - await activateTheme( 'twentytwentyone' ); - await exec( - 'npm run env:cli -- menu location assign all-pages primary' - ); +test.describe( 'Front End - Twenty Twenty One', () => { + test.use( { + storageState: {}, // User will be logged out. } ); - afterAll( async () => { - const resultsFilename = getResultsFilename( - basename( __filename, '.js' ) - ); - writeFileSync( - join( __dirname, resultsFilename ), - JSON.stringify( results, null, 2 ) - ); + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentyone' ); } ); - it( 'Server Timing Metrics', async () => { - let i = TEST_RUNS; - while ( i-- ) { - await page.goto( createURL( '/' ) ); - const navigationTimingJson = await page.evaluate( () => - JSON.stringify( performance.getEntriesByType( 'navigation' ) ) - ); + test.afterAll( async ( {}, testInfo ) => { + await testInfo.attach( 'results', { + body: JSON.stringify( results, null, 2 ), + contentType: 'application/json', + } ); + } ); - const [ navigationTiming ] = JSON.parse( navigationTimingJson ); + const iterations = Number( process.env.TEST_RUNS ); + for ( let i = 1; i <= iterations; i++ ) { + test( `Measure load time metrics (${ i } of ${ iterations })`, async ( { + page, + metrics, + } ) => { + await page.goto( '/' ); - results.wpBeforeTemplate.push( - navigationTiming.serverTiming[ 0 ].duration - ); - results.wpTemplate.push( - navigationTiming.serverTiming[ 1 ].duration - ); - results.wpTotal.push( navigationTiming.serverTiming[ 2 ].duration ); + const serverTiming = await metrics.getServerTiming(); - const ttfb = await getTimeToFirstByte(); - const lcp = await getLargestContentfulPaint(); + for (const [key, value] of Object.entries( serverTiming ) ) { + results[ camelCaseDashes( key ) ] ??= []; + results[ camelCaseDashes( key ) ].push( value ); + } + + const ttfb = await metrics.getTimeToFirstByte(); + const lcp = await metrics.getLargestContentfulPaint(); - results.timeToFirstByte.push( ttfb ); results.largestContentfulPaint.push( lcp ); + results.timeToFirstByte.push( ttfb ); results.lcpMinusTtfb.push( lcp - ttfb ); - } - } ); + } ); + } } ); diff --git a/tests/performance/utils.js b/tests/performance/utils.js index 732ab66d447fa..69cd945cc3979 100644 --- a/tests/performance/utils.js +++ b/tests/performance/utils.js @@ -16,7 +16,7 @@ function median( array ) { /** * Gets the result file name. * - * @param {string} File name. + * @param {string} fileName File name. * * @return {string} Result file name. */ @@ -25,54 +25,17 @@ function getResultsFilename( fileName ) { arg.startsWith( '--prefix' ) ); const fileNamePrefix = prefixArg ? `${ prefixArg.split( '=' )[ 1 ] }-` : ''; - const resultsFilename = fileNamePrefix + fileName + '.results.json'; - return resultsFilename; + return `${fileNamePrefix + fileName}.results.json`; } -/** - * Returns time to first byte (TTFB) using the Navigation Timing API. - * - * @see https://web.dev/ttfb/#measure-ttfb-in-javascript - * - * @return {Promise} - */ -async function getTimeToFirstByte() { - return page.evaluate( () => { - const { responseStart, startTime } = - performance.getEntriesByType( 'navigation' )[ 0 ]; - return responseStart - startTime; +function camelCaseDashes( str ) { + return str.replace( /-([a-z])/g, function( g ) { + return g[ 1 ].toUpperCase(); } ); } -/** - * Returns the Largest Contentful Paint (LCP) value using the dedicated API. - * - * @see https://w3c.github.io/largest-contentful-paint/ - * @see https://web.dev/lcp/#measure-lcp-in-javascript - * - * @return {Promise} - */ -async function getLargestContentfulPaint() { - return page.evaluate( - () => - new Promise( ( resolve ) => { - new PerformanceObserver( ( entryList ) => { - const entries = entryList.getEntries(); - // The last entry is the largest contentful paint. - const largestPaintEntry = entries.at( -1 ); - - resolve( largestPaintEntry?.startTime || 0 ); - } ).observe( { - type: 'largest-contentful-paint', - buffered: true, - } ); - } ) - ); -} - module.exports = { median, getResultsFilename, - getTimeToFirstByte, - getLargestContentfulPaint, + camelCaseDashes, }; From d76a2c393148883be03e084a2f4f32613915e94d Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 27 Sep 2023 20:27:06 +0200 Subject: [PATCH 02/59] Convert visual regression tests --- .gitignore | 2 +- package.json | 2 +- tests/visual-regression/config/bootstrap.js | 10 - tests/visual-regression/jest.config.js | 8 - tests/visual-regression/playwright.config.js | 61 +++ tests/visual-regression/run-tests.js | 13 - .../specs/visual-snapshots.test.js | 360 ++++++++---------- 7 files changed, 215 insertions(+), 241 deletions(-) delete mode 100644 tests/visual-regression/config/bootstrap.js delete mode 100644 tests/visual-regression/jest.config.js create mode 100644 tests/visual-regression/playwright.config.js delete mode 100644 tests/visual-regression/run-tests.js diff --git a/.gitignore b/.gitignore index 596abbaa6432d..0a02b30a1548d 100644 --- a/.gitignore +++ b/.gitignore @@ -100,4 +100,4 @@ wp-tests-config.php /docker-compose.override.yml # Visual regression test diffs -tests/visual-regression/specs/__image_snapshots__ +tests/visual-regression/specs/__snapshots__ diff --git a/package.json b/package.json index 0017d41e4fff4..8cdf4307bbef7 100644 --- a/package.json +++ b/package.json @@ -195,7 +195,7 @@ "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": "wp-scripts test-playwright --config tests/e2e/playwright.config.js", - "test:visual": "node ./tests/visual-regression/run-tests.js", + "test:visual": "wp-scripts test-playwright --config tests/visual-regression/playwright.config.js", "sync-gutenberg-packages": "grunt sync-gutenberg-packages", "postsync-gutenberg-packages": "grunt wp-packages:sync-stable-blocks && grunt build --dev && grunt build" } diff --git a/tests/visual-regression/config/bootstrap.js b/tests/visual-regression/config/bootstrap.js deleted file mode 100644 index 6130909e0c6fe..0000000000000 --- a/tests/visual-regression/config/bootstrap.js +++ /dev/null @@ -1,10 +0,0 @@ -import { configureToMatchImageSnapshot } from 'jest-image-snapshot'; - -// All available options: https://github.com/americanexpress/jest-image-snapshot#%EF%B8%8F-api -const toMatchImageSnapshot = configureToMatchImageSnapshot( { - // Maximum diff to allow in px. - failureThreshold: 1, -} ); - -// Extend Jest's "expect" with image snapshot functionality. -expect.extend( { toMatchImageSnapshot } ); diff --git a/tests/visual-regression/jest.config.js b/tests/visual-regression/jest.config.js deleted file mode 100644 index aa5d3d1fbd0fb..0000000000000 --- a/tests/visual-regression/jest.config.js +++ /dev/null @@ -1,8 +0,0 @@ -const config = require( '@wordpress/scripts/config/jest-e2e.config' ); - -const jestVisualRegressionConfig = { - ...config, - setupFilesAfterEnv: [ '/config/bootstrap.js' ], -}; - -module.exports = jestVisualRegressionConfig; diff --git a/tests/visual-regression/playwright.config.js b/tests/visual-regression/playwright.config.js new file mode 100644 index 0000000000000..2b60e9e783c79 --- /dev/null +++ b/tests/visual-regression/playwright.config.js @@ -0,0 +1,61 @@ +/** + * External dependencies + */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, devices } from '@playwright/test'; + +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( { + reporter: process.env.CI ? [ [ 'github' ] ] : [ [ 'list' ] ], + forbidOnly: !! process.env.CI, + // fullyParallel: false, + workers: 1, + retries: process.env.CI ? 2 : 0, + timeout: parseInt( process.env.TIMEOUT || '', 10 ) || 100_000, // Defaults to 100 seconds. + // Don't report slow test "files", as we will be running our tests in serial. + reportSlowTests: null, + testDir: './specs', + outputDir: path.join( process.env.WP_ARTIFACTS_PATH, 'test-results' ), + snapshotPathTemplate: + '{testDir}/{testFileDir}/__snapshots__/{arg}-{projectName}{ext}', + fullyParallel: false, + use: { + baseURL: process.env.WP_BASE_URL || 'http://localhost:8889', + headless: true, + viewport: { + width: 1000, + height: 750, + }, + ignoreHTTPSErrors: true, + locale: 'en-US', + contextOptions: { + reducedMotion: 'reduce', + strictSelectors: true, + }, + storageState: process.env.STORAGE_STATE_PATH, + actionTimeout: 10_000, // 10 seconds. + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'on-first-retry', + }, + webServer: { + command: 'npm run wp-env start', + port: 8889, + timeout: 120_000, // 120 seconds. + reuseExistingServer: true, + }, + projects: [ + { + name: 'chromium', + use: { ...devices[ 'Desktop Chrome' ] }, + }, + ], +} ); + +export default config; diff --git a/tests/visual-regression/run-tests.js b/tests/visual-regression/run-tests.js deleted file mode 100644 index a94c914d72d22..0000000000000 --- a/tests/visual-regression/run-tests.js +++ /dev/null @@ -1,13 +0,0 @@ -const dotenv = require( 'dotenv' ); -const dotenv_expand = require( 'dotenv-expand' ); -const { execSync } = require( 'child_process' ); - -// WP_BASE_URL interpolates LOCAL_PORT, so needs to be parsed by dotenv_expand(). -dotenv_expand.expand( dotenv.config() ); - -// Run the tests, passing additional arguments through to the test script. -execSync( - 'wp-scripts test-e2e --config tests/visual-regression/jest.config.js ' + - process.argv.slice( 2 ).join( ' ' ), - { stdio: 'inherit' } -); diff --git a/tests/visual-regression/specs/visual-snapshots.test.js b/tests/visual-regression/specs/visual-snapshots.test.js index 458c40f86e0b7..1aa5771d7ecc1 100644 --- a/tests/visual-regression/specs/visual-snapshots.test.js +++ b/tests/visual-regression/specs/visual-snapshots.test.js @@ -1,222 +1,166 @@ -import { visitAdminPage } from '@wordpress/e2e-test-utils'; - -// See https://github.com/puppeteer/puppeteer/blob/main/docs/api.md#pagescreenshotoptions for more available options. -const screenshotOptions = { - fullPage: true, -}; - -async function hideElementVisibility( elements ) { - for ( let i = 0; i < elements.length; i++ ) { - const elementOnPage = await page.$( elements[ i ] ); - if ( elementOnPage ) { - await elementOnPage.evaluate( ( el ) => { - el.style.visibility = 'hidden'; - } ); - } - } - await page.waitFor( 1000 ); -} - -async function removeElementFromLayout( elements ) { - for ( let i = 0; i < elements.length; i++ ) { - const elementOnPage = await page.$( elements[ i ] ); - if ( elementOnPage ) { - await elementOnPage.evaluate( ( el ) => { - el.style.visibility = 'hidden'; - } ); - } - } - await page.waitFor( 1000 ); -} - -const elementsToHide = [ '#footer-upgrade', '#wp-admin-bar-root-default' ]; - -const elementsToRemove = [ '#toplevel_page_gutenberg' ]; - -describe( 'Admin Visual Snapshots', () => { - beforeAll( async () => { - await page.setViewport( { - width: 1000, - height: 750, - } ); - } ); - - it( 'All Posts', async () => { - await visitAdminPage( '/edit.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); - - it( 'Categories', async () => { - await visitAdminPage( '/edit-tags.php', 'taxonomy=category' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); - - it( 'Tags', async () => { - await visitAdminPage( '/edit-tags.php', 'taxonomy=post_tag' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); - - it( 'Media Library', async () => { - await visitAdminPage( '/upload.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); - - it( 'Add New Media', async () => { - await visitAdminPage( '/media-new.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); - - it( 'All Pages', async () => { - await visitAdminPage( '/edit.php', 'post_type=page' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); - - it( 'Comments', async () => { - await visitAdminPage( '/edit-comments.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); - - it( 'Widgets', async () => { - await visitAdminPage( '/widgets.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); - - it( 'Menus', async () => { - await visitAdminPage( '/nav-menus.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); - - it( 'Plugins', async () => { - await visitAdminPage( '/plugins.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); - - it( 'All Users', async () => { - await visitAdminPage( '/users.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +const elementsToHide = [ + '#footer-upgrade', + '#wp-admin-bar-root-default', + '#toplevel_page_gutenberg' +]; + +test.describe( 'Admin Visual Snapshots', () => { + test( 'All Posts', async ({ admin, page }) => { + await admin.visitAdminPage( '/edit.php' ); + await expect( page ).toHaveScreenshot( 'All Posts.png', { + masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); + + test( 'Categories', async ({ admin, page }) => { + await admin.visitAdminPage( '/edit-tags.php', 'taxonomy=category' ); + await expect( page ).toHaveScreenshot( 'Categories.png', { + masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); + + test( 'Tags', async ({ admin, page }) => { + await admin.visitAdminPage( '/edit-tags.php', 'taxonomy=post_tag' ); + await expect( page ).toHaveScreenshot( 'Tags.png', { + masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); + + test( 'Media Library', async ({ admin, page }) => { + await admin.visitAdminPage( '/upload.php' ); + await expect( page ).toHaveScreenshot( 'Media Library.png', { + masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); + + test( 'Add New Media', async ({ admin, page }) => { + await admin.visitAdminPage( '/media-new.php' ); + await expect( page ).toHaveScreenshot( 'Add New Media.png', { + masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); + + test( 'All Pages', async ({ admin, page }) => { + await admin.visitAdminPage( '/edit.php', 'post_type=page' ); + await expect( page ).toHaveScreenshot( 'All Pages.png', { + masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); + + test( 'Comments', async ({ admin, page }) => { + await admin.visitAdminPage( '/edit-comments.php' ); + await expect( page ).toHaveScreenshot( 'Comments.png', { + masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); + + test( 'Widgets', async ({ admin, page }) => { + await admin.visitAdminPage( '/widgets.php' ); + await expect( page ).toHaveScreenshot( 'Widgets.png', { + masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); } ); - it( 'Add New User', async () => { - await visitAdminPage( '/user-new.php' ); - await hideElementVisibility( [ - ...elementsToHide, - '.password-input-wrapper', - ] ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); + test( 'Menus', async ({ admin, page }) => { + await admin.visitAdminPage( '/nav-menus.php' ); + await expect( page ).toHaveScreenshot( 'Menus.png', { + masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); + + test( 'Plugins', async ({ admin, page }) => { + await admin.visitAdminPage( '/plugins.php' ); + await expect( page ).toHaveScreenshot( 'Plugins.png', { + masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); + + test( 'All Users', async ({ admin, page }) => { + await admin.visitAdminPage( '/users.php' ); + await expect( page ).toHaveScreenshot( 'All Users.png', { + masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); + + test( 'Add New User', async ({ admin, page }) => { + await admin.visitAdminPage( '/user-new.php' ); + await expect( page ).toHaveScreenshot( 'Add New User.png', { + mask: [ + ...elementsToHide, + '.password-input-wrapper' + ].map( ( selector ) => page.locator( selector ) ), + }); + } ); - it( 'Your Profile', async () => { - await visitAdminPage( '/profile.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); + test( 'Your Profile', async ({ admin, page }) => { + await admin.visitAdminPage( '/profile.php' ); + await expect( page ).toHaveScreenshot( 'Your Profile.png', { + masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); - it( 'Available Tools', async () => { - await visitAdminPage( '/tools.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); + test( 'Available Tools', async ({ admin, page }) => { + await admin.visitAdminPage( '/tools.php' ); + await expect( page ).toHaveScreenshot( 'Available Tools.png', { + masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); - it( 'Import', async () => { - await visitAdminPage( '/import.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); + test( 'Import', async ({ admin, page }) => { + await admin.visitAdminPage( '/import.php' ); + await expect( page ).toHaveScreenshot( 'Import.png', { + masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); - it( 'Export', async () => { - await visitAdminPage( '/export.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); + test( 'Export', async ({ admin, page }) => { + await admin.visitAdminPage( '/export.php' ); + await expect( page ).toHaveScreenshot( 'Export.png', { + masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); - it( 'Export Personal Data', async () => { - await visitAdminPage( '/export-personal-data.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); + test( 'Export Personal Data', async ({ admin, page }) => { + await admin.visitAdminPage( '/export-personal-data.php' ); + await expect( page ).toHaveScreenshot( 'Export Personal Data.png', { + masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); - it( 'Erase Personal Data', async () => { - await visitAdminPage( '/erase-personal-data.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); + test( 'Erase Personal Data', async ({ admin, page }) => { + await admin.visitAdminPage( '/erase-personal-data.php' ); + await expect( page ).toHaveScreenshot( 'Erase Personal Data.png', { + masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); - it( 'Reading Settings', async () => { - await visitAdminPage( '/options-reading.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); + test( 'Reading Settings', async ({ admin, page }) => { + await admin.visitAdminPage( '/options-reading.php' ); + await expect( page ).toHaveScreenshot( 'Reading Settings.png', { + masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); - it( 'Discussion Settings', async () => { - await visitAdminPage( '/options-discussion.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); + test( 'Discussion Settings', async ({ admin, page }) => { + await admin.visitAdminPage( '/options-discussion.php' ); + await expect( page ).toHaveScreenshot( 'Discussion Settings.png', { + masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); - it( 'Media Settings', async () => { - await visitAdminPage( '/options-media.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); - } ); + test( 'Media Settings', async ({ admin, page }) => { + await admin.visitAdminPage( '/options-media.php' ); + await expect( page ).toHaveScreenshot( 'Media Settings.png', { + masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); + } ); - it( 'Privacy Settings', async () => { - await visitAdminPage( '/options-privacy.php' ); - await hideElementVisibility( elementsToHide ); - await removeElementFromLayout( elementsToRemove ); - const image = await page.screenshot( screenshotOptions ); - expect( image ).toMatchImageSnapshot(); + test( 'Privacy Settings', async ({ admin, page }) => { + await admin.visitAdminPage( '/options-privacy.php' ); + await expect( page ).toHaveScreenshot( 'Privacy Settings.png', { + masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + }); } ); } ); From f1f7ce1b0e9de17d1b18f3b1e2d1e63115f8a616 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 27 Sep 2023 21:09:45 +0200 Subject: [PATCH 03/59] Fix mask config --- .../specs/visual-snapshots.test.js | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/tests/visual-regression/specs/visual-snapshots.test.js b/tests/visual-regression/specs/visual-snapshots.test.js index 1aa5771d7ecc1..d2f1eb9e7ebe0 100644 --- a/tests/visual-regression/specs/visual-snapshots.test.js +++ b/tests/visual-regression/specs/visual-snapshots.test.js @@ -10,77 +10,77 @@ test.describe( 'Admin Visual Snapshots', () => { test( 'All Posts', async ({ admin, page }) => { await admin.visitAdminPage( '/edit.php' ); await expect( page ).toHaveScreenshot( 'All Posts.png', { - masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), }); } ); test( 'Categories', async ({ admin, page }) => { await admin.visitAdminPage( '/edit-tags.php', 'taxonomy=category' ); await expect( page ).toHaveScreenshot( 'Categories.png', { - masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), }); } ); test( 'Tags', async ({ admin, page }) => { await admin.visitAdminPage( '/edit-tags.php', 'taxonomy=post_tag' ); await expect( page ).toHaveScreenshot( 'Tags.png', { - masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), }); } ); test( 'Media Library', async ({ admin, page }) => { await admin.visitAdminPage( '/upload.php' ); await expect( page ).toHaveScreenshot( 'Media Library.png', { - masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), }); } ); test( 'Add New Media', async ({ admin, page }) => { await admin.visitAdminPage( '/media-new.php' ); await expect( page ).toHaveScreenshot( 'Add New Media.png', { - masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), }); } ); test( 'All Pages', async ({ admin, page }) => { await admin.visitAdminPage( '/edit.php', 'post_type=page' ); await expect( page ).toHaveScreenshot( 'All Pages.png', { - masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), }); } ); test( 'Comments', async ({ admin, page }) => { await admin.visitAdminPage( '/edit-comments.php' ); await expect( page ).toHaveScreenshot( 'Comments.png', { - masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), }); } ); test( 'Widgets', async ({ admin, page }) => { await admin.visitAdminPage( '/widgets.php' ); await expect( page ).toHaveScreenshot( 'Widgets.png', { - masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), }); } ); test( 'Menus', async ({ admin, page }) => { await admin.visitAdminPage( '/nav-menus.php' ); await expect( page ).toHaveScreenshot( 'Menus.png', { - masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), }); } ); test( 'Plugins', async ({ admin, page }) => { await admin.visitAdminPage( '/plugins.php' ); await expect( page ).toHaveScreenshot( 'Plugins.png', { - masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), }); } ); test( 'All Users', async ({ admin, page }) => { await admin.visitAdminPage( '/users.php' ); await expect( page ).toHaveScreenshot( 'All Users.png', { - masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), }); } ); @@ -97,70 +97,70 @@ test.describe( 'Admin Visual Snapshots', () => { test( 'Your Profile', async ({ admin, page }) => { await admin.visitAdminPage( '/profile.php' ); await expect( page ).toHaveScreenshot( 'Your Profile.png', { - masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), }); } ); test( 'Available Tools', async ({ admin, page }) => { await admin.visitAdminPage( '/tools.php' ); await expect( page ).toHaveScreenshot( 'Available Tools.png', { - masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), }); } ); test( 'Import', async ({ admin, page }) => { await admin.visitAdminPage( '/import.php' ); await expect( page ).toHaveScreenshot( 'Import.png', { - masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), }); } ); test( 'Export', async ({ admin, page }) => { await admin.visitAdminPage( '/export.php' ); await expect( page ).toHaveScreenshot( 'Export.png', { - masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), }); } ); test( 'Export Personal Data', async ({ admin, page }) => { await admin.visitAdminPage( '/export-personal-data.php' ); await expect( page ).toHaveScreenshot( 'Export Personal Data.png', { - masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), }); } ); test( 'Erase Personal Data', async ({ admin, page }) => { await admin.visitAdminPage( '/erase-personal-data.php' ); await expect( page ).toHaveScreenshot( 'Erase Personal Data.png', { - masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), }); } ); test( 'Reading Settings', async ({ admin, page }) => { await admin.visitAdminPage( '/options-reading.php' ); await expect( page ).toHaveScreenshot( 'Reading Settings.png', { - masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), }); } ); test( 'Discussion Settings', async ({ admin, page }) => { await admin.visitAdminPage( '/options-discussion.php' ); await expect( page ).toHaveScreenshot( 'Discussion Settings.png', { - masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), }); } ); test( 'Media Settings', async ({ admin, page }) => { await admin.visitAdminPage( '/options-media.php' ); await expect( page ).toHaveScreenshot( 'Media Settings.png', { - masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), }); } ); test( 'Privacy Settings', async ({ admin, page }) => { await admin.visitAdminPage( '/options-privacy.php' ); await expect( page ).toHaveScreenshot( 'Privacy Settings.png', { - masks: elementsToHide.map( ( selector ) => page.locator( selector ) ), + mask: elementsToHide.map( ( selector ) => page.locator( selector ) ), }); } ); } ); From 792c7582a62bf1ea5f81187b78ff0049f70ab630 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 27 Sep 2023 21:57:04 +0200 Subject: [PATCH 04/59] Initial conversion of edit posts tests --- tests/e2e/specs/edit-posts.test.js | 82 +++++++++++++++++++----------- 1 file changed, 51 insertions(+), 31 deletions(-) diff --git a/tests/e2e/specs/edit-posts.test.js b/tests/e2e/specs/edit-posts.test.js index 5c07019f4d1e6..524d8cead8a96 100644 --- a/tests/e2e/specs/edit-posts.test.js +++ b/tests/e2e/specs/edit-posts.test.js @@ -1,29 +1,33 @@ -import { - createNewPost, - pressKeyTimes, - publishPost, - trashAllPosts, - visitAdminPage, -} from '@wordpress/e2e-test-utils'; - -describe( 'Edit Posts', () => { - beforeEach( async () => { - await trashAllPosts(); +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +test.describe( 'Edit Posts', () => { + test.beforeEach( async ( { requestUtils }) => { + await requestUtils.deleteAllPosts(); } ); - it( 'displays a message in the posts table when no posts are present', async () => { - await visitAdminPage( '/edit.php' ); + test( 'displays a message in the posts table when no posts are present',async ( { + admin, + page, + } ) => { + await admin.visitAdminPage( '/edit.php' ); const noPostsMessage = await page.$x( '//td[text()="No posts found."]' ); expect( noPostsMessage.length ).toBe( 1 ); } ); - 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' ); @@ -40,11 +44,15 @@ describe( 'Edit Posts', () => { expect( postTitle.length ).toBe( 1 ); } ); - 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' ); @@ -69,11 +77,15 @@ describe( 'Edit Posts', () => { expect( editorPostTitleInput.length ).toBe( 1 ); } ); - 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 + } ) => { 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' ); @@ -84,7 +96,8 @@ describe( 'Edit Posts', () => { await editLink.focus(); // Tab to the Quick Edit button and press Enter to quick edit. - await pressKeyTimes( 'Tab', 2 ); + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Tab' ); await page.keyboard.press( 'Enter' ); // Type in the currently focused (title) field to modify the title, testing that focus is moved to the input. @@ -108,11 +121,16 @@ describe( 'Edit Posts', () => { ); expect( postTitle.length ).toBe( 1 ); } ); - it( 'allows an existing post to be deleted using the Trash button', async () => { + + test( 'allows an existing post to be deleted using the Trash button', async ( { + admin, + editor, + page + } ) => { 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' ); @@ -123,7 +141,9 @@ describe( 'Edit Posts', () => { await editLink.focus(); // Tab to the Trash button and press Enter to delete the post. - await pressKeyTimes( 'Tab', 3 ); + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Tab' ); await page.keyboard.press( 'Enter' ); const noPostsMessage = await page.waitForSelector( From 8fb05604b1cd7e73c0f1326a6da98560f42637fa Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 27 Sep 2023 21:57:33 +0200 Subject: [PATCH 05/59] Fix file ext --- tests/performance/playwright.config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/performance/playwright.config.js b/tests/performance/playwright.config.js index dc93573d3d1bb..7fca610e16afd 100644 --- a/tests/performance/playwright.config.js +++ b/tests/performance/playwright.config.js @@ -14,8 +14,8 @@ process.env.TEST_RUNS ??= '20'; const config = defineConfig( { reporter: process.env.CI - ? './config/performance-reporter.ts' - : [ [ 'list' ], [ './config/performance-reporter.ts' ] ], + ? './config/performance-reporter.js' + : [ [ 'list' ], [ './config/performance-reporter.js' ] ], forbidOnly: !! process.env.CI, // fullyParallel: false, workers: 1, From c7b7ce64b36d5990323507f1d5024da667003092 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 27 Sep 2023 22:18:45 +0200 Subject: [PATCH 06/59] Convert dashboard test --- tests/e2e/specs/dashboard.test.js | 34 ++++++++++++++++++------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/tests/e2e/specs/dashboard.test.js b/tests/e2e/specs/dashboard.test.js index 4c732e0806033..19eaef078911d 100644 --- a/tests/e2e/specs/dashboard.test.js +++ b/tests/e2e/specs/dashboard.test.js @@ -1,15 +1,18 @@ -import { - trashAllPosts, - visitAdminPage, -} from '@wordpress/e2e-test-utils'; - -describe( 'Quick Draft', () => { - beforeEach( async () => { - await trashAllPosts(); +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +test.describe( 'Quick Draft', () => { + test.beforeEach( async ({ requestUtils }) => { + await requestUtils.deleteAllPosts(); } ); - it( 'Allows draft to be created with Title and Content', async () => { - await visitAdminPage( '/' ); + test( 'Allows draft to be created with Title and Content', async ( { + admin, + page + } ) => { + await admin.visitAdminPage( '/' ); // Wait for Quick Draft title field to appear and focus it const draftTitleField = await page.waitForSelector( @@ -36,7 +39,7 @@ describe( 'Quick Draft', () => { ).toContain( 'Test Draft Title' ); // Check that new draft appears in Posts page - await visitAdminPage( '/edit.php' ); + await admin.visitAdminPage( '/edit.php' ); const postsListDraft = await page.waitForSelector( '.type-post.status-draft .title' ); @@ -46,8 +49,11 @@ describe( 'Quick Draft', () => { ).toContain( '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( @@ -63,7 +69,7 @@ describe( 'Quick Draft', () => { ).toContain( '(no title)' ); // Check that new draft appears in Posts page - await visitAdminPage( '/edit.php' ); + await admin.visitAdminPage( '/edit.php' ); const postsListDraft = await page.waitForSelector( '.type-post.status-draft .title a' ); From a88e396360f9e3d7f4118084c1d27133fa3d2dfc Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 27 Sep 2023 23:09:49 +0200 Subject: [PATCH 07/59] Convert another test --- .../profile/applications-passwords.test.js | 125 ++++++++++-------- 1 file changed, 73 insertions(+), 52 deletions(-) diff --git a/tests/e2e/specs/profile/applications-passwords.test.js b/tests/e2e/specs/profile/applications-passwords.test.js index 1b53a76811d10..ed2ab1c358c6a 100644 --- a/tests/e2e/specs/profile/applications-passwords.test.js +++ b/tests/e2e/specs/profile/applications-passwords.test.js @@ -1,52 +1,31 @@ -import { - visitAdminPage, - __experimentalRest as rest, -} from "@wordpress/e2e-test-utils"; - -async function getResponseForApplicationPassword() { - return await rest({ - method: "GET", - path: "/wp/v2/users/me/application-passwords", - }); -} +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; -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"); -} +const TEST_APPLICATION_NAME = 'Test Application'; -async function createApplicationPasswordWithApi(applicationName) { - await rest({ - method: "POST", - path: "/wp/v2/users/me/application-passwords", - data: { - name: applicationName, +test.describe( 'Manage applications passwords', () => { + test.use( { + applicationPasswords: async ( { requestUtils }, use ) => { + await use( new ApplicationPasswords( { requestUtils } ) ); }, - }); -} + } ); -async function revokeAllApplicationPasswordsWithApi() { - await rest({ - method: "DELETE", - path: `/wp/v2/users/me/application-passwords`, + test.beforeEach(async ( { requestUtils } ) => { + await requestUtils.rest( { + method: 'DELETE', + path: '/wp/v2/users/me/application-passwords', + } ); }); -} -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(); - }); - - it("should correctly create a new application password", async () => { - await createApplicationPassword(TEST_APPLICATION_NAME); - - const response = await getResponseForApplicationPassword(); + const response = await applicationPasswords.get(); expect(response[0]["name"]).toBe(TEST_APPLICATION_NAME); const successMessage = await page.waitForSelector( @@ -59,9 +38,12 @@ describe("Manage applications passwords", () => { ); }); - 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 not allow to create two applications passwords with the same name", async ( { + page, + applicationPasswords + } ) => { + await applicationPasswords.create(); + await applicationPasswords.create(); const errorMessage = await page.waitForSelector( "#application-passwords-section .notice-error" @@ -72,13 +54,16 @@ describe("Manage applications passwords", () => { ).toContain("Each application name should be unique."); }); - it("should correctly revoke a single application password", async () => { - await createApplicationPassword(TEST_APPLICATION_NAME); + test("should correctly revoke a single application password", async ( { + page, + applicationPasswords + } ) => { + await applicationPasswords.create(); const revokeApplicationButton = await page.waitForSelector( ".application-passwords-user tr button.delete" ); - + const revocationDialogPromise = new Promise((resolve) => { page.once("dialog", resolve); }); @@ -95,12 +80,15 @@ describe("Manage applications passwords", () => { await successMessage.evaluate((element) => element.textContent) ).toContain("Application password revoked."); - const response = await getResponseForApplicationPassword(); + const response = await applicationPasswords.get(); expect(response).toEqual([]); }); - it("should correctly revoke all the application passwords", async () => { - await createApplicationPassword(TEST_APPLICATION_NAME); + test("should correctly revoke all the application passwords", async ( { + page, + applicationPasswords + } ) => { + await applicationPasswords.create(); const revokeAllApplicationPasswordsButton = await page.waitForSelector( "#revoke-all-application-passwords" @@ -132,7 +120,40 @@ describe("Manage applications passwords", () => { await successMessage.evaluate((element) => element.textContent) ).toContain("All application passwords revoked."); - const response = await getResponseForApplicationPassword(); + const response = await applicationPasswords.get(); expect(response).toEqual([]); }); }); + +class ApplicationPasswords { + constructor( { requestUtils, page, admin }) { + this.requestUtils = requestUtils; + this.page = page; + this.admin = admin; + } + + async createInUi(applicationName = TEST_APPLICATION_NAME) { + await this.admin.visitAdminPage('profile.php' ); + await this.page.waitForSelector('#new_application_password_name' ); + await this.page.type( '#new_application_password_name', applicationName ); + await this.page.click( '#do_new_application_password' ); + await this.page.waitForSelector( '#application-passwords-section .notice' ); + } + + async create( applicationName = TEST_APPLICATION_NAME ) { + await this.requestUtils.rest( { + method: 'POST', + path: '/wp/v2/users/me/application-passwords', + data: { + name: applicationName, + }, + } ); + } + + async get() { + await this.requestUtils.rest( { + method: 'GET', + path: '/wp/v2/users/me/application-passwords', + } ); + } +} From c91d0cac6d2936d4acea37711cd08fb206ec90b1 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 27 Sep 2023 23:24:04 +0200 Subject: [PATCH 08/59] Improve test --- .../profile/applications-passwords.test.js | 58 ++++++++----------- 1 file changed, 24 insertions(+), 34 deletions(-) diff --git a/tests/e2e/specs/profile/applications-passwords.test.js b/tests/e2e/specs/profile/applications-passwords.test.js index ed2ab1c358c6a..6210ce509e4b8 100644 --- a/tests/e2e/specs/profile/applications-passwords.test.js +++ b/tests/e2e/specs/profile/applications-passwords.test.js @@ -17,7 +17,7 @@ test.describe( 'Manage applications passwords', () => { method: 'DELETE', path: '/wp/v2/users/me/application-passwords', } ); - }); + } ); test('should correctly create a new application password', async ( { page, @@ -26,19 +26,19 @@ test.describe( 'Manage applications passwords', () => { await applicationPasswords.create(); const response = await applicationPasswords.get(); - expect(response[0]["name"]).toBe(TEST_APPLICATION_NAME); + expect( response[0]['name']).toBe( TEST_APPLICATION_NAME ); const successMessage = await page.waitForSelector( - "#application-passwords-section .notice-success" + '#application-passwords-section .notice-success' ); expect( - await successMessage.evaluate((element) => element.innerText) + await successMessage.evaluate( ( element ) => element.innerText ) ).toContain( `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 ( { + test('should not allow to create two applications passwords with the same name', async ( { page, applicationPasswords } ) => { @@ -46,26 +46,26 @@ test.describe( 'Manage applications passwords', () => { await applicationPasswords.create(); const errorMessage = await page.waitForSelector( - "#application-passwords-section .notice-error" + '#application-passwords-section .notice-error' ); expect( - await errorMessage.evaluate((element) => element.textContent) - ).toContain("Each application name should be unique."); + await errorMessage.evaluate( ( element ) => element.textContent ) + ).toContain( 'Each application name should be unique.' ); }); - test("should correctly revoke a single application password", async ( { + test( 'should correctly revoke a single application password', async ( { page, applicationPasswords } ) => { await applicationPasswords.create(); const revokeApplicationButton = await page.waitForSelector( - ".application-passwords-user tr button.delete" + '.application-passwords-user tr button.delete' ); const revocationDialogPromise = new Promise((resolve) => { - page.once("dialog", resolve); + page.once( 'dialog', resolve ); }); await Promise.all([ @@ -74,28 +74,28 @@ test.describe( 'Manage applications passwords', () => { ]); const successMessage = await page.waitForSelector( - "#application-passwords-section .notice-success" + '#application-passwords-section .notice-success' ); expect( await successMessage.evaluate((element) => element.textContent) - ).toContain("Application password revoked."); + ).toContain( 'Application password revoked.' ); const response = await applicationPasswords.get(); - expect(response).toEqual([]); - }); + expect( response ).toEqual([]); + } ); - test("should correctly revoke all the application passwords", async ( { + test( 'should correctly revoke all the application passwords', async ( { page, applicationPasswords } ) => { await applicationPasswords.create(); const revokeAllApplicationPasswordsButton = await page.waitForSelector( - "#revoke-all-application-passwords" + '#revoke-all-application-passwords' ); - const revocationDialogPromise = new Promise((resolve) => { - page.once("dialog", resolve); + const revocationDialogPromise = new Promise(( resolve ) => { + page.once( 'dialog', resolve ); }); await Promise.all([ @@ -121,9 +121,9 @@ test.describe( 'Manage applications passwords', () => { ).toContain("All application passwords revoked."); const response = await applicationPasswords.get(); - expect(response).toEqual([]); - }); -}); + expect( response ).toEqual([]); + } ); +} ); class ApplicationPasswords { constructor( { requestUtils, page, admin }) { @@ -132,7 +132,7 @@ class ApplicationPasswords { this.admin = admin; } - async createInUi(applicationName = TEST_APPLICATION_NAME) { + async create(applicationName = TEST_APPLICATION_NAME) { await this.admin.visitAdminPage('profile.php' ); await this.page.waitForSelector('#new_application_password_name' ); await this.page.type( '#new_application_password_name', applicationName ); @@ -140,16 +140,6 @@ class ApplicationPasswords { await this.page.waitForSelector( '#application-passwords-section .notice' ); } - async create( applicationName = TEST_APPLICATION_NAME ) { - await this.requestUtils.rest( { - method: 'POST', - path: '/wp/v2/users/me/application-passwords', - data: { - name: applicationName, - }, - } ); - } - async get() { await this.requestUtils.rest( { method: 'GET', From 0778ffe41059411185666f41728369e55430da81 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 28 Sep 2023 10:13:10 +0200 Subject: [PATCH 09/59] Remove types package again --- package-lock.json | 1 - package.json | 1 - 2 files changed, 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 370b8ef8d6d59..29d5663362fc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -108,7 +108,6 @@ "@lodder/grunt-postcss": "^3.1.1", "@playwright/test": "1.38.1", "@pmmmwh/react-refresh-webpack-plugin": "0.5.5", - "@types/node": "16.18.54", "@wordpress/babel-preset-default": "7.26.2", "@wordpress/dependency-extraction-webpack-plugin": "4.25.2", "@wordpress/e2e-test-utils": "10.13.2", diff --git a/package.json b/package.json index 8cdf4307bbef7..d7cc664fbb456 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ "@lodder/grunt-postcss": "^3.1.1", "@playwright/test": "1.38.1", "@pmmmwh/react-refresh-webpack-plugin": "0.5.5", - "@types/node": "16.18.54", "@wordpress/babel-preset-default": "7.26.2", "@wordpress/dependency-extraction-webpack-plugin": "4.25.2", "@wordpress/e2e-test-utils": "10.13.2", From e691f09aa9c0a9d2f84db90aa918bb0d593a7ccc Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 28 Sep 2023 10:13:46 +0200 Subject: [PATCH 10/59] Remove debug cruft --- src/wp-includes/blocks.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php index d48ee3fa57c9e..482955ea21d53 100644 --- a/src/wp-includes/blocks.php +++ b/src/wp-includes/blocks.php @@ -181,7 +181,7 @@ function register_block_script_handle( $metadata, $field_name, $index = 0 ) { $script_uri = get_block_asset_url( $script_path_norm ); $script_args = array(); - if ( 'viewScript' === $field_name && $script_uri ) { + if ( 'viewScript' === $field_name ) { $script_args['strategy'] = 'defer'; } From ca36ecb1c14c5121e1e6ee36738010a8d137f571 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 28 Sep 2023 10:19:09 +0200 Subject: [PATCH 11/59] Install browsers separately --- .github/workflows/end-to-end-tests.yml | 4 ++++ .github/workflows/performance.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index 42eab6ff447da..8abb65b034492 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -42,6 +42,7 @@ 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. @@ -90,6 +91,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 diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index 4ec15b95913af..019ae34cb63c2 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -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. @@ -119,6 +120,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 From b044cff44fba54976f077e69b9792c0a863bc149 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 28 Sep 2023 11:14:40 +0200 Subject: [PATCH 12/59] Fix webServer command --- tests/e2e/playwright.config.js | 2 +- tests/performance/playwright.config.js | 2 +- tests/visual-regression/playwright.config.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js index cca8d794c4c27..ecdece2a76ed1 100644 --- a/tests/e2e/playwright.config.js +++ b/tests/e2e/playwright.config.js @@ -48,7 +48,7 @@ const config = defineConfig( { video: 'on-first-retry', }, webServer: { - command: 'npm run wp-env start', + command: 'npm run env:start', port: 8889, timeout: 120_000, // 120 seconds. reuseExistingServer: true, diff --git a/tests/performance/playwright.config.js b/tests/performance/playwright.config.js index 7fca610e16afd..c0e41007f6b49 100644 --- a/tests/performance/playwright.config.js +++ b/tests/performance/playwright.config.js @@ -51,7 +51,7 @@ const config = defineConfig( { video: 'off', }, webServer: { - command: 'npm run wp-env start', + command: 'npm run env:start', port: 8889, timeout: 120_000, // 120 seconds. reuseExistingServer: true, diff --git a/tests/visual-regression/playwright.config.js b/tests/visual-regression/playwright.config.js index 2b60e9e783c79..f35a5db322a53 100644 --- a/tests/visual-regression/playwright.config.js +++ b/tests/visual-regression/playwright.config.js @@ -45,7 +45,7 @@ const config = defineConfig( { video: 'on-first-retry', }, webServer: { - command: 'npm run wp-env start', + command: 'npm run env:start', port: 8889, timeout: 120_000, // 120 seconds. reuseExistingServer: true, From 85021bb3a8caba7ddfca5dc0bab76614fd8e70e9 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 28 Sep 2023 11:20:51 +0200 Subject: [PATCH 13/59] Some more bug fixes --- tests/e2e/specs/edit-posts.test.js | 20 +++++-------------- .../empty-trash-restore-trashed-posts.test.js | 2 +- .../profile/applications-passwords.test.js | 4 ++-- 3 files changed, 8 insertions(+), 18 deletions(-) diff --git a/tests/e2e/specs/edit-posts.test.js b/tests/e2e/specs/edit-posts.test.js index 524d8cead8a96..92960af9cc292 100644 --- a/tests/e2e/specs/edit-posts.test.js +++ b/tests/e2e/specs/edit-posts.test.js @@ -13,10 +13,9 @@ test.describe( 'Edit Posts', () => { page, } ) => { await admin.visitAdminPage( '/edit.php' ); - const noPostsMessage = await page.$x( + await expect( page.locator( '//td[text()="No posts found."]' - ); - expect( noPostsMessage.length ).toBe( 1 ); + ) ).toBeVisible(); } ); test( 'shows a single post after one is published with the correct title',async ( { @@ -57,10 +56,7 @@ test.describe( 'Edit Posts', () => { await page.waitForSelector( '#the-list .type-post' ); // Click the post title (edit) link - const [ editLink ] = await page.$x( - `//a[contains(@class, "row-title")][contains(text(), "${ title }")]` - ); - await editLink.click(); + await page.locator( `//a[contains(@class, "row-title")][contains(text(), "${ title }")]` ).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"]' ); @@ -90,10 +86,7 @@ test.describe( 'Edit Posts', () => { await page.waitForSelector( '#the-list .type-post' ); // Focus on the post title link. - const [ editLink ] = await page.$x( - `//a[contains(@class, "row-title")][contains(text(), "${ title }")]` - ); - await editLink.focus(); + await page.locator( `//a[contains(@class, "row-title")][contains(text(), "${ title }")]` ).focus(); // Tab to the Quick Edit button and press Enter to quick edit. await page.keyboard.press( 'Tab' ); @@ -135,10 +128,7 @@ test.describe( 'Edit Posts', () => { await page.waitForSelector( '#the-list .type-post' ); // Focus on the post title link. - const [ editLink ] = await page.$x( - `//a[contains(@class, "row-title")][contains(text(), "${ title }")]` - ); - await editLink.focus(); + await page.locator( `//a[contains(@class, "row-title")][contains(text(), "${ title }")]` ).focus(); // Tab to the Trash button and press Enter to delete the post. await page.keyboard.press( 'Tab' ); diff --git a/tests/e2e/specs/empty-trash-restore-trashed-posts.test.js b/tests/e2e/specs/empty-trash-restore-trashed-posts.test.js index 44472b2c76b19..c50877cd6edca 100644 --- a/tests/e2e/specs/empty-trash-restore-trashed-posts.test.js +++ b/tests/e2e/specs/empty-trash-restore-trashed-posts.test.js @@ -22,7 +22,7 @@ test.describe( 'Empty Trash', () => { // Empty trash await page.getByRole( 'link', { name: 'Trash' } ).click(); - await page.getByRole( 'button', { name: 'Empty 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 ); diff --git a/tests/e2e/specs/profile/applications-passwords.test.js b/tests/e2e/specs/profile/applications-passwords.test.js index 6210ce509e4b8..c28191d06a691 100644 --- a/tests/e2e/specs/profile/applications-passwords.test.js +++ b/tests/e2e/specs/profile/applications-passwords.test.js @@ -7,8 +7,8 @@ const TEST_APPLICATION_NAME = 'Test Application'; test.describe( 'Manage applications passwords', () => { test.use( { - applicationPasswords: async ( { requestUtils }, use ) => { - await use( new ApplicationPasswords( { requestUtils } ) ); + applicationPasswords: async ( { requestUtils, admin, page }, use ) => { + await use( new ApplicationPasswords( { requestUtils, admin, page } ) ); }, } ); From f6fd1cab620eff7868d31e4b3977341f46fdf73e Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Thu, 28 Sep 2023 13:22:58 +0400 Subject: [PATCH 14/59] Update dashboard tests --- tests/e2e/specs/dashboard.test.js | 57 ++++++++++++++----------------- 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/tests/e2e/specs/dashboard.test.js b/tests/e2e/specs/dashboard.test.js index 19eaef078911d..90459ac83ae6f 100644 --- a/tests/e2e/specs/dashboard.test.js +++ b/tests/e2e/specs/dashboard.test.js @@ -14,14 +14,15 @@ test.describe( 'Quick Draft', () => { } ) => { 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' ); @@ -32,21 +33,16 @@ test.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 admin.visitAdminPage( '/edit.php' ); - const postsListDraft = await page.waitForSelector( - '.type-post.status-draft .title' - ); - expect( - await postsListDraft.evaluate( ( element ) => element.innerText ) - ).toContain( 'Test Draft Title' ); + await expect( + page.locator( '.type-post.status-draft .title' ).first() + ).toContainText( 'Test Draft Title' ); } ); test( 'Allows draft to be created without Title or Content', async ( { @@ -56,26 +52,23 @@ test.describe( 'Quick Draft', () => { 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 admin.visitAdminPage( '/edit.php' ); - const postsListDraft = await page.waitForSelector( - '.type-post.status-draft .title a' - ); - expect( - await postsListDraft.evaluate( ( element ) => element.innerText ) - ).toContain( '(no title)' ); + await expect( + page.locator( '.type-post.status-draft .title' ).first() + ).toContainText( 'Untitled' ); } ); } ); From fda76fc05553ba047729d3d594c8e42d754e92fa Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Thu, 28 Sep 2023 14:41:01 +0400 Subject: [PATCH 15/59] Update edit post tests --- tests/e2e/specs/edit-posts.test.js | 83 +++++++++++++----------------- 1 file changed, 36 insertions(+), 47 deletions(-) diff --git a/tests/e2e/specs/edit-posts.test.js b/tests/e2e/specs/edit-posts.test.js index 92960af9cc292..5c03797e9b9b1 100644 --- a/tests/e2e/specs/edit-posts.test.js +++ b/tests/e2e/specs/edit-posts.test.js @@ -13,9 +13,9 @@ test.describe( 'Edit Posts', () => { page, } ) => { await admin.visitAdminPage( '/edit.php' ); - await expect( page.locator( - '//td[text()="No posts found."]' - ) ).toBeVisible(); + await expect( + page.getByRole( 'cell', { name: 'No posts found.' } ) + ).toBeVisible(); } ); test( 'shows a single post after one is published with the correct title',async ( { @@ -28,19 +28,15 @@ test.describe( 'Edit Posts', () => { 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 ); } ); test( 'allows an existing post to be edited using the Edit button', async ( { @@ -53,24 +49,24 @@ test.describe( 'Edit Posts', () => { 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 - await page.locator( `//a[contains(@class, "row-title")][contains(text(), "${ title }")]` ).click(); + await listTable.getByRole( 'link', { name: `“${ title }” (Edit)` } ).click(); // Wait for the editor iframe to load, and switch to it as the active content frame. - const editorFrame = await page.waitForSelector( 'iframe[name="editor-canvas"]' ); - - const innerFrame = await editorFrame.contentFrame(); + await page + .frameLocator( '[name=editor-canvas]' ) + .locator( 'body > *' ) + .first() + .waitFor(); - // Wait for title field to render onscreen. - await innerFrame.waitForSelector( '.editor-post-title__input' ); + const editorPostTitle = editor.canvas.getByRole( 'textbox', { name: 'Add title' } ); - // Expect to now be in the editor with the correct post title shown. - const editorPostTitleInput = await innerFrame.$x( - `//h1[contains(@class, "editor-post-title__input")][contains(text(), "${ title }")]` - ); - expect( editorPostTitleInput.length ).toBe( 1 ); + // Expect title field to be in the editor with correct title shown. + await expect( editorPostTitle ).toBeVisible(); + await expect( editorPostTitle ).toHaveText( title ); } ); test( 'allows an existing post to be quick edited using the Quick Edit button', async ( { @@ -83,10 +79,11 @@ test.describe( 'Edit Posts', () => { 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. - await page.locator( `//a[contains(@class, "row-title")][contains(text(), "${ title }")]` ).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 page.keyboard.press( 'Tab' ); @@ -97,22 +94,17 @@ test.describe( 'Edit Posts', () => { 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` ); } ); test( 'allows an existing post to be deleted using the Trash button', async ( { @@ -125,10 +117,11 @@ test.describe( 'Edit Posts', () => { 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. - await page.locator( `//a[contains(@class, "row-title")][contains(text(), "${ title }")]` ).focus(); + // // Focus on the post title link. + await listTable.getByRole( 'link', { name: `“${ title }” (Edit)` } ).focus(); // Tab to the Trash button and press Enter to delete the post. await page.keyboard.press( 'Tab' ); @@ -136,12 +129,8 @@ test.describe( 'Edit Posts', () => { await page.keyboard.press( 'Tab' ); 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(); } ); } ); From 5afa82352267779fe823cef73ef2eb09b775240e Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Thu, 28 Sep 2023 14:42:32 +0400 Subject: [PATCH 16/59] Use keyPress util --- tests/e2e/specs/edit-posts.test.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/e2e/specs/edit-posts.test.js b/tests/e2e/specs/edit-posts.test.js index 5c03797e9b9b1..e7b3e81174224 100644 --- a/tests/e2e/specs/edit-posts.test.js +++ b/tests/e2e/specs/edit-posts.test.js @@ -72,7 +72,8 @@ test.describe( 'Edit Posts', () => { test( 'allows an existing post to be quick edited using the Quick Edit button', async ( { admin, editor, - page + page, + pageUtils } ) => { const title = 'Test Title'; await admin.createNewPost( { title } ); @@ -86,8 +87,7 @@ test.describe( 'Edit Posts', () => { await listTable.getByRole( 'link', { name: `“${ title }” (Edit)` } ).focus(); // Tab to the Quick Edit button and press Enter to quick edit. - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Tab' ); + 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. @@ -110,7 +110,8 @@ test.describe( 'Edit Posts', () => { test( 'allows an existing post to be deleted using the Trash button', async ( { admin, editor, - page + page, + pageUtils } ) => { const title = 'Test Title'; await admin.createNewPost( { title } ); @@ -124,9 +125,7 @@ test.describe( 'Edit Posts', () => { await listTable.getByRole( 'link', { name: `“${ title }” (Edit)` } ).focus(); // Tab to the Trash button and press Enter to delete the post. - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Tab' ); + await pageUtils.pressKeys( 'Tab', { times: 3 } ) await page.keyboard.press( 'Enter' ); await expect( From 9ae926f656d15291d1ced3bc370774d45554f489 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Thu, 28 Sep 2023 15:24:09 +0400 Subject: [PATCH 17/59] Update empty and restore trash tests --- .../empty-trash-restore-trashed-posts.test.js | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/tests/e2e/specs/empty-trash-restore-trashed-posts.test.js b/tests/e2e/specs/empty-trash-restore-trashed-posts.test.js index c50877cd6edca..d970ca09b1c90 100644 --- a/tests/e2e/specs/empty-trash-restore-trashed-posts.test.js +++ b/tests/e2e/specs/empty-trash-restore-trashed-posts.test.js @@ -6,7 +6,7 @@ import { test, expect } from '@wordpress/e2e-test-utils-playwright'; const POST_TITLE = 'Test Title'; test.describe( 'Empty Trash', () => { - test.beforeAll( async ( { requestUtils } ) => { + test.beforeEach( async ( { requestUtils } ) => { await requestUtils.deleteAllPosts(); }); @@ -16,18 +16,18 @@ test.describe( 'Empty Trash', () => { await admin.visitAdminPage( '/edit.php' ); + const listTable = page.getByRole( 'table', { name: 'Table ordered by' } ); + await expect( listTable ).toBeVisible(); + // Move post to trash - await page.getByLabel( new RegExp( `^“${POST_TITLE}”` ) ).hover(); - await page.getByLabel( `Move “${POST_TITLE}” to the Trash` ).click(); + await listTable.getByRole( 'link', { name: `“${ POST_TITLE }” (Edit)` } ).hover(); + await listTable.getByRole( 'link', { name: `Move “${POST_TITLE}” to the Trash` } ).click(); // 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.' ); } ); test('Restore trash post', async ( { admin, editor, page }) => { @@ -36,20 +36,20 @@ test.describe( 'Empty Trash', () => { await admin.visitAdminPage( '/edit.php' ); - // Move post to trash. - await page.getByLabel( new RegExp( `^“${POST_TITLE}”` ) ).hover(); - await page.getByLabel( `Move “${POST_TITLE}” to the Trash` ).click(); + const listTable = page.getByRole( 'table', { name: 'Table ordered by' } ); + await expect( listTable ).toBeVisible(); + + // 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(); - // Remove post from trash. await page.getByRole( 'link', { name: 'Trash' } ).click(); - const postTitle = await page.getByText( new RegExp( `^${POST_TITLE}$` ) ).first(); - await postTitle.hover(); - await page.getByLabel( `Restore “${POST_TITLE}” from the 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 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.' ); + // Expect for success message for restored post. + await expect( page.locator( '#message' ) ).toContainText( '1 post restored from the Trash.' ); } ); } ); From 21e9f12fb76267d60711251f416d9015d88578a7 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Thu, 28 Sep 2023 15:26:48 +0400 Subject: [PATCH 18/59] Try using same PW version as Gutenberg and wp-scripts --- package-lock.json | 74 ++++++++++------------------------------------- package.json | 2 +- 2 files changed, 17 insertions(+), 59 deletions(-) diff --git a/package-lock.json b/package-lock.json index 29d5663362fc7..805ed7db362cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -106,7 +106,7 @@ }, "devDependencies": { "@lodder/grunt-postcss": "^3.1.1", - "@playwright/test": "1.38.1", + "@playwright/test": "1.32.0", "@pmmmwh/react-refresh-webpack-plugin": "0.5.5", "@wordpress/babel-preset-default": "7.26.2", "@wordpress/dependency-extraction-webpack-plugin": "4.25.2", @@ -3746,18 +3746,22 @@ } }, "node_modules/@playwright/test": { - "version": "1.38.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.38.1.tgz", - "integrity": "sha512-NqRp8XMwj3AK+zKLbZShl0r/9wKgzqI/527bkptKXomtuo+dOjU9NdMASQ8DNC9z9zLOMbG53T4eihYr3XR+BQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.32.0.tgz", + "integrity": "sha512-zOdGloaF0jeec7hqoLqM5S3L2rR4WxMJs6lgiAeR70JlH7Ml54ZPoIIf3X7cvnKde3Q9jJ/gaxkFh8fYI9s1rg==", "dev": true, "dependencies": { - "playwright": "1.38.1" + "@types/node": "*", + "playwright-core": "1.32.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=16" + "node": ">=14" + }, + "optionalDependencies": { + "fsevents": "2.3.2" } }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { @@ -26591,24 +26595,6 @@ "node": ">=8" } }, - "node_modules/playwright": { - "version": "1.38.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.38.1.tgz", - "integrity": "sha512-oRMSJmZrOu1FP5iu3UrCx8JEFRIMxLDM0c/3o4bpzU5Tz97BypefWf7TuTNPWeCe279TPal5RtPPZ+9lW/Qkow==", - "dev": true, - "dependencies": { - "playwright-core": "1.38.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, "node_modules/playwright-core": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.32.0.tgz", @@ -26621,18 +26607,6 @@ "node": ">=14" } }, - "node_modules/playwright/node_modules/playwright-core": { - "version": "1.38.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.38.1.tgz", - "integrity": "sha512-tQqNFUKa3OfMf4b2jQ7aGLB8o9bS3bOY0yMEtldtC2+spf8QXG9zvXLTXUeRsoNuxEYMgLYR+NXfAa1rjKRcrg==", - "dev": true, - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/plur": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/plur/-/plur-4.0.0.tgz", @@ -36778,12 +36752,14 @@ } }, "@playwright/test": { - "version": "1.38.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.38.1.tgz", - "integrity": "sha512-NqRp8XMwj3AK+zKLbZShl0r/9wKgzqI/527bkptKXomtuo+dOjU9NdMASQ8DNC9z9zLOMbG53T4eihYr3XR+BQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.32.0.tgz", + "integrity": "sha512-zOdGloaF0jeec7hqoLqM5S3L2rR4WxMJs6lgiAeR70JlH7Ml54ZPoIIf3X7cvnKde3Q9jJ/gaxkFh8fYI9s1rg==", "dev": true, "requires": { - "playwright": "1.38.1" + "@types/node": "*", + "fsevents": "2.3.2", + "playwright-core": "1.32.0" } }, "@pmmmwh/react-refresh-webpack-plugin": { @@ -54099,24 +54075,6 @@ } } }, - "playwright": { - "version": "1.38.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.38.1.tgz", - "integrity": "sha512-oRMSJmZrOu1FP5iu3UrCx8JEFRIMxLDM0c/3o4bpzU5Tz97BypefWf7TuTNPWeCe279TPal5RtPPZ+9lW/Qkow==", - "dev": true, - "requires": { - "fsevents": "2.3.2", - "playwright-core": "1.38.1" - }, - "dependencies": { - "playwright-core": { - "version": "1.38.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.38.1.tgz", - "integrity": "sha512-tQqNFUKa3OfMf4b2jQ7aGLB8o9bS3bOY0yMEtldtC2+spf8QXG9zvXLTXUeRsoNuxEYMgLYR+NXfAa1rjKRcrg==", - "dev": true - } - } - }, "playwright-core": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.32.0.tgz", diff --git a/package.json b/package.json index d7cc664fbb456..3866649d0a6db 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ ], "devDependencies": { "@lodder/grunt-postcss": "^3.1.1", - "@playwright/test": "1.38.1", + "@playwright/test": "1.32.0", "@pmmmwh/react-refresh-webpack-plugin": "0.5.5", "@wordpress/babel-preset-default": "7.26.2", "@wordpress/dependency-extraction-webpack-plugin": "4.25.2", From 5ffb5d7c39843ca2558f46f5f0008a2aec65f931 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 28 Sep 2023 13:50:06 +0200 Subject: [PATCH 19/59] Install deps again after git checkout --- .github/workflows/performance.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index 019ae34cb63c2..d9db9b1df4613 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -74,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. @@ -194,6 +195,9 @@ jobs: - 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 }} From 5ca807c0380dabaf32029a8753b53fddacf8795d Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 28 Sep 2023 13:54:44 +0200 Subject: [PATCH 20/59] Remove commented out config --- tests/e2e/playwright.config.js | 1 - tests/performance/playwright.config.js | 1 - tests/visual-regression/playwright.config.js | 1 - 3 files changed, 3 deletions(-) diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js index ecdece2a76ed1..f916128f8e950 100644 --- a/tests/e2e/playwright.config.js +++ b/tests/e2e/playwright.config.js @@ -14,7 +14,6 @@ process.env.STORAGE_STATE_PATH ??= path.join( const config = defineConfig( { reporter: process.env.CI ? [ [ 'github' ] ] : [ [ 'list' ] ], forbidOnly: !! process.env.CI, - // fullyParallel: false, workers: 1, retries: process.env.CI ? 2 : 0, timeout: parseInt( process.env.TIMEOUT || '', 10 ) || 100_000, // Defaults to 100 seconds. diff --git a/tests/performance/playwright.config.js b/tests/performance/playwright.config.js index c0e41007f6b49..a19dcb0dd51bb 100644 --- a/tests/performance/playwright.config.js +++ b/tests/performance/playwright.config.js @@ -17,7 +17,6 @@ const config = defineConfig( { ? './config/performance-reporter.js' : [ [ 'list' ], [ './config/performance-reporter.js' ] ], forbidOnly: !! process.env.CI, - // fullyParallel: false, workers: 1, retries: 0, timeout: parseInt( process.env.TIMEOUT || '', 10 ) || 600_000, // Defaults to 10 minutes. diff --git a/tests/visual-regression/playwright.config.js b/tests/visual-regression/playwright.config.js index f35a5db322a53..c44ba4ac036a6 100644 --- a/tests/visual-regression/playwright.config.js +++ b/tests/visual-regression/playwright.config.js @@ -14,7 +14,6 @@ process.env.STORAGE_STATE_PATH ??= path.join( const config = defineConfig( { reporter: process.env.CI ? [ [ 'github' ] ] : [ [ 'list' ] ], forbidOnly: !! process.env.CI, - // fullyParallel: false, workers: 1, retries: process.env.CI ? 2 : 0, timeout: parseInt( process.env.TIMEOUT || '', 10 ) || 100_000, // Defaults to 100 seconds. From 1cd3f2f2801a7e175819787a39216104302c71c9 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 28 Sep 2023 14:12:15 +0200 Subject: [PATCH 21/59] Fix test title --- tests/e2e/specs/hello.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/specs/hello.test.js b/tests/e2e/specs/hello.test.js index e20c6b5be49fa..cfe018bbd6260 100644 --- a/tests/e2e/specs/hello.test.js +++ b/tests/e2e/specs/hello.test.js @@ -3,7 +3,7 @@ */ import { test, expect } from '@wordpress/e2e-test-utils-playwright'; -test.describe( 'Site editor title', () => { +test.describe( 'Hello World', () => { test( 'Should load properly', async ( { admin, page }) => { await admin.visitAdminPage( '/' ); await expect( From fb49007020d70bafc35859379f7ceeea3ddafb42 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 28 Sep 2023 14:12:30 +0200 Subject: [PATCH 22/59] Use env var for prefix instead of argument --- .github/workflows/performance.yml | 16 ++++++++++++---- tests/performance/utils.js | 6 ++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index d9db9b1df4613..2e37d01ab80ac 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -187,10 +187,14 @@ jobs: run: npm run build - name: Run target performance tests (base/previous commit) - run: npm run test:performance -- --prefix=before + run: npm run test:performance + env: + TEST_RESULTS_PREFIX: before - name: Print target performance tests results - run: node ./tests/performance/results.js --prefix=before + run: node ./tests/performance/results.js + env: + TEST_RESULTS_PREFIX: before - name: Reset to original commit run: git reset --hard $GITHUB_SHA @@ -204,10 +208,14 @@ jobs: npm run env:cli -- core version --path=/var/www/${{ env.LOCAL_DIR }} - name: Run baseline performance tests - run: npm run test:performance -- --prefix=base + run: npm run test:performance + env: + TEST_RESULTS_PREFIX: base - name: Print baseline performance tests results - run: node ./tests/performance/results.js --prefix=base + run: node ./tests/performance/results.js + env: + TEST_RESULTS_PREFIX: base - name: Compare results with base run: node ./tests/performance/compare-results.js ${{ runner.temp }}/summary.md diff --git a/tests/performance/utils.js b/tests/performance/utils.js index 69cd945cc3979..9d6502e8e688e 100644 --- a/tests/performance/utils.js +++ b/tests/performance/utils.js @@ -21,10 +21,8 @@ function median( array ) { * @return {string} Result file name. */ function getResultsFilename( fileName ) { - const prefixArg = process.argv.find( ( arg ) => - arg.startsWith( '--prefix' ) - ); - const fileNamePrefix = prefixArg ? `${ prefixArg.split( '=' )[ 1 ] }-` : ''; + const prefix = process.env.TEST_RESULTS_PREFIX; + const fileNamePrefix = prefix ? `${ prefix.split( '=' )[ 1 ] }-` : ''; return `${fileNamePrefix + fileName}.results.json`; } From 6f3a89fe98e199330581c73883bbc59411061f78 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 28 Sep 2023 14:39:33 +0200 Subject: [PATCH 23/59] Fix app passwords tests --- .../profile/applications-passwords.test.js | 70 +++++-------------- 1 file changed, 18 insertions(+), 52 deletions(-) diff --git a/tests/e2e/specs/profile/applications-passwords.test.js b/tests/e2e/specs/profile/applications-passwords.test.js index c28191d06a691..f54b048476354 100644 --- a/tests/e2e/specs/profile/applications-passwords.test.js +++ b/tests/e2e/specs/profile/applications-passwords.test.js @@ -12,11 +12,8 @@ test.describe( 'Manage applications passwords', () => { }, } ); - test.beforeEach(async ( { requestUtils } ) => { - await requestUtils.rest( { - method: 'DELETE', - path: '/wp/v2/users/me/application-passwords', - } ); + test.beforeEach(async ( { applicationPasswords } ) => { + await applicationPasswords.delete(); } ); test('should correctly create a new application password', async ( { @@ -60,25 +57,11 @@ test.describe( 'Manage applications passwords', () => { } ) => { await applicationPasswords.create(); - const revokeApplicationButton = await page.waitForSelector( - '.application-passwords-user tr button.delete' - ); + page.on('dialog', dialog => dialog.accept()); + await page.getByRole( 'button', { name: `Revoke "${TEST_APPLICATION_NAME}"` } ).click(); - const revocationDialogPromise = new Promise((resolve) => { - page.once( 'dialog', resolve ); - }); - - await Promise.all([ - revocationDialogPromise, - revokeApplicationButton.click(), - ]); - - const successMessage = await page.waitForSelector( - '#application-passwords-section .notice-success' - ); - expect( - await successMessage.evaluate((element) => element.textContent) - ).toContain( 'Application password revoked.' ); + await expect( page.locator( '#application-passwords-section .notice-success' ) ) + .toContainText( 'Application password revoked.' ); const response = await applicationPasswords.get(); expect( response ).toEqual([]); @@ -90,35 +73,11 @@ test.describe( 'Manage applications passwords', () => { } ) => { await applicationPasswords.create(); - const revokeAllApplicationPasswordsButton = await page.waitForSelector( - '#revoke-all-application-passwords' - ); - - const revocationDialogPromise = new Promise(( resolve ) => { - page.once( 'dialog', resolve ); - }); - - await Promise.all([ - revocationDialogPromise, - revokeAllApplicationPasswordsButton.click(), - ]); + page.on('dialog', dialog => dialog.accept()); + await page.getByRole( 'button', { name: 'Revoke all application passwords' } ).click(); - /** - * This is commented out because we're using enablePageDialogAccept - * which is overly aggressive and no way to temporary disable it either. - */ - // await dialog.accept(); - - await page.waitForSelector( - "#application-passwords-section .notice-success" - ); - - const successMessage = await page.waitForSelector( - "#application-passwords-section .notice-success" - ); - expect( - await successMessage.evaluate((element) => element.textContent) - ).toContain("All application passwords revoked."); + await expect( page.locator( '#application-passwords-section .notice-success' ) ) + .toContainText( 'All application passwords revoked.' ); const response = await applicationPasswords.get(); expect( response ).toEqual([]); @@ -141,9 +100,16 @@ class ApplicationPasswords { } async get() { - await this.requestUtils.rest( { + 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', + } ); + } } From 53691a17f156d1f48bbec7b8db0e71dc3f020046 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 28 Sep 2023 14:41:09 +0200 Subject: [PATCH 24/59] Debug: use different reporter on CI --- tests/e2e/playwright.config.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js index f916128f8e950..41b52db268185 100644 --- a/tests/e2e/playwright.config.js +++ b/tests/e2e/playwright.config.js @@ -12,7 +12,8 @@ process.env.STORAGE_STATE_PATH ??= path.join( ); const config = defineConfig( { - reporter: process.env.CI ? [ [ 'github' ] ] : [ [ 'list' ] ], + // reporter: process.env.CI ? [ [ 'github' ] ] : [ [ 'list' ] ], + reporter: 'list', // FIXME: just for testing. forbidOnly: !! process.env.CI, workers: 1, retries: process.env.CI ? 2 : 0, From f4882949e588ea26e7cc2b710e733926ab56a9ec Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Thu, 28 Sep 2023 16:41:20 +0400 Subject: [PATCH 25/59] Update app password tests --- .../profile/applications-passwords.test.js | 80 ++++++++++++------- 1 file changed, 51 insertions(+), 29 deletions(-) diff --git a/tests/e2e/specs/profile/applications-passwords.test.js b/tests/e2e/specs/profile/applications-passwords.test.js index f54b048476354..a66ba8bc7bbb2 100644 --- a/tests/e2e/specs/profile/applications-passwords.test.js +++ b/tests/e2e/specs/profile/applications-passwords.test.js @@ -17,20 +17,20 @@ test.describe( 'Manage applications passwords', () => { } ); test('should correctly create a new application password', async ( { - page, - applicationPasswords + page, + applicationPasswords } ) => { await applicationPasswords.create(); - const response = await applicationPasswords.get(); - expect( response[0]['name']).toBe( TEST_APPLICATION_NAME ); + const [ app ] = await applicationPasswords.get(); + expect( app['name']).toBe( TEST_APPLICATION_NAME ); - const successMessage = await page.waitForSelector( - '#application-passwords-section .notice-success' - ); - expect( - await successMessage.evaluate( ( element ) => element.innerText ) - ).toContain( + const successMessage = page.getByRole( 'alert' ); + + 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.` ); } ); @@ -42,13 +42,14 @@ test.describe( 'Manage applications passwords', () => { await applicationPasswords.create(); await applicationPasswords.create(); - const errorMessage = await page.waitForSelector( - '#application-passwords-section .notice-error' - ); + const errorMessage = page.getByRole( 'alert' ); - expect( - await errorMessage.evaluate( ( element ) => element.textContent ) - ).toContain( 'Each application name should be unique.' ); + await expect( errorMessage ).toHaveClass( /notice-error/ ); + await expect( + errorMessage + ).toContainText( + 'Each application name should be unique.' + ); }); test( 'should correctly revoke a single application password', async ( { @@ -57,11 +58,18 @@ test.describe( 'Manage applications passwords', () => { } ) => { await applicationPasswords.create(); - page.on('dialog', dialog => dialog.accept()); - await page.getByRole( 'button', { name: `Revoke "${TEST_APPLICATION_NAME}"` } ).click(); + const revokeButton = page.getByRole( 'button', { name: `Revoke "${ TEST_APPLICATION_NAME }"` } ); + await expect( revokeButton ).toBeVisible(); - await expect( page.locator( '#application-passwords-section .notice-success' ) ) - .toContainText( 'Application password revoked.' ); + // Revoke password. + page.on( 'dialog', ( dialog ) => dialog.accept() ); + await revokeButton.click(); + + await expect( + page.getByRole( 'alert' ) + ).toContainText( + 'Application password revoked.' + ); const response = await applicationPasswords.get(); expect( response ).toEqual([]); @@ -73,11 +81,22 @@ test.describe( 'Manage applications passwords', () => { } ) => { await applicationPasswords.create(); - page.on('dialog', dialog => dialog.accept()); - await page.getByRole( 'button', { name: 'Revoke all application passwords' } ).click(); + const revokeAllButton = page.getByRole( 'button', { name: 'Revoke all application passwords' } ); + await expect( revokeAllButton ).toBeVisible(); - await expect( page.locator( '#application-passwords-section .notice-success' ) ) - .toContainText( 'All application passwords revoked.' ); + // Confirms revoking action. + page.on( 'dialog', ( dialog ) => dialog.accept() ); + await revokeAllButton.click(); + + 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([]); @@ -92,11 +111,14 @@ class ApplicationPasswords { } async create(applicationName = TEST_APPLICATION_NAME) { - await this.admin.visitAdminPage('profile.php' ); - await this.page.waitForSelector('#new_application_password_name' ); - await this.page.type( '#new_application_password_name', applicationName ); - await this.page.click( '#do_new_application_password' ); - await this.page.waitForSelector( '#application-passwords-section .notice' ); + await this.admin.visitAdminPage( '/profile.php' ); + + const newPasswordField = this.page.getByRole( 'textbox', { name: 'New Application Password Name' } ); + await expect( newPasswordField ).toBeVisible(); + await newPasswordField.fill( applicationName ); + + await this.page.getByRole( 'button', { name: 'Add New Application Password' } ).click(); + await expect( this.page.getByRole( 'alert' ) ).toBeVisible(); } async get() { From d4016634a16d3721bd156155bf7c289725107956 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 28 Sep 2023 14:49:30 +0200 Subject: [PATCH 26/59] Remove leftovers --- tests/e2e/specs/profile/applications-passwords.test.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/e2e/specs/profile/applications-passwords.test.js b/tests/e2e/specs/profile/applications-passwords.test.js index a66ba8bc7bbb2..38aed7372eace 100644 --- a/tests/e2e/specs/profile/applications-passwords.test.js +++ b/tests/e2e/specs/profile/applications-passwords.test.js @@ -94,10 +94,6 @@ test.describe( 'Manage applications passwords', () => { 'All application passwords revoked.' ); - const revocationDialogPromise = new Promise(( resolve ) => { - page.once( 'dialog', resolve ); - }); - const response = await applicationPasswords.get(); expect( response ).toEqual([]); } ); From 210976544e8b6940f41c6f4392e72f937ece5c1b Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 28 Sep 2023 15:19:22 +0200 Subject: [PATCH 27/59] Move order --- .github/workflows/performance.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index 2e37d01ab80ac..dbd96c0b416b2 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -187,14 +187,14 @@ jobs: run: npm run build - name: Run target performance tests (base/previous commit) - run: npm run test:performance env: TEST_RESULTS_PREFIX: before + run: npm run test:performance - name: Print target performance tests results - run: node ./tests/performance/results.js env: TEST_RESULTS_PREFIX: before + run: node ./tests/performance/results.js - name: Reset to original commit run: git reset --hard $GITHUB_SHA @@ -208,14 +208,14 @@ jobs: npm run env:cli -- core version --path=/var/www/${{ env.LOCAL_DIR }} - name: Run baseline performance tests - run: npm run test:performance env: TEST_RESULTS_PREFIX: base + run: npm run test:performance - name: Print baseline performance tests results - run: node ./tests/performance/results.js 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 From c346d2bfa5eecde72074d336904a6066e0be58f1 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 28 Sep 2023 15:22:48 +0200 Subject: [PATCH 28/59] Make Gutenberg test more robust --- .github/workflows/end-to-end-tests.yml | 4 +++ tests/e2e/specs/gutenberg-plugin.test.js | 42 ++++++++++++++++++------ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index 8abb65b034492..c37c8cf4f2a59 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -48,6 +48,7 @@ jobs: # - 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: @@ -119,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 diff --git a/tests/e2e/specs/gutenberg-plugin.test.js b/tests/e2e/specs/gutenberg-plugin.test.js index fd3faa90956fd..5a0006f33f23e 100644 --- a/tests/e2e/specs/gutenberg-plugin.test.js +++ b/tests/e2e/specs/gutenberg-plugin.test.js @@ -1,24 +1,46 @@ /** * WordPress dependencies */ -import { test } from '@wordpress/e2e-test-utils-playwright'; +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; test.describe( 'Gutenberg plugin', () => { + // Increasing timeout to 5 minutes because potential plugin install could take longer. + test.setTimeout( 300_000 ); + + test.beforeAll( async ( { requestUtils } ) => { + // Install Gutenberg plugin if it's not yet installed. + const pluginsMap = await requestUtils.getPluginsMap(); + if ( ! pluginsMap.gutenberg ) { + await requestUtils.rest( { + method: 'POST', + path: 'wp/v2/plugins?slug=gutenberg', + } ); + } + + await requestUtils.deactivatePlugin( 'gutenberg' ); + } ); + test( 'should activate', async ( { requestUtils }) => { - // Increasing timeout to 5 minutes because install could take longer. - test.setTimeout( 300_000 ); + let plugin = await requestUtils.rest( { + path: 'wp/v2/plugins/gutenberg/gutenberg', + } ); + + expect( plugin.status ).toBe( 'inactive' ); + + await requestUtils.activatePlugin( 'gutenberg' ); - await requestUtils.rest( { - method: 'POST', - path: 'wp/v2/plugins?slug=gutenberg&status=active', + plugin = await requestUtils.rest( { + path: 'wp/v2/plugins/gutenberg/gutenberg', } ); - // This flow will only work if the activation previously succeeded. + expect( plugin.status ).toBe( 'active' ); + await requestUtils.deactivatePlugin( 'gutenberg' ); - await requestUtils.rest( { - method: 'DELETE', - path: 'wp/v2/plugins/gutenberg', + plugin = await requestUtils.rest( { + path: 'wp/v2/plugins/gutenberg/gutenberg', } ); + + expect( plugin.status ).toBe( 'inactive' ); } ); } ); From 1079d5cbc7f1600eff2d1e84b333128ae3c52cf9 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 28 Sep 2023 15:27:11 +0200 Subject: [PATCH 29/59] Make comparison script more robust --- tests/performance/compare-results.js | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/performance/compare-results.js b/tests/performance/compare-results.js index c63b60bea99b4..52c5eeb5a471e 100644 --- a/tests/performance/compare-results.js +++ b/tests/performance/compare-results.js @@ -27,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 ); From 14d74969c703f49f4e2e9dcf773627dcd3251ede Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 28 Sep 2023 15:50:08 +0200 Subject: [PATCH 30/59] Further improve comparison script --- tests/performance/compare-results.js | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/performance/compare-results.js b/tests/performance/compare-results.js index 52c5eeb5a471e..aea827b51130a 100644 --- a/tests/performance/compare-results.js +++ b/tests/performance/compare-results.js @@ -129,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, @@ -154,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 + ); +} From cb8e4d60ed76319de5f0d7ca146134dc3980116c Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 28 Sep 2023 15:50:52 +0200 Subject: [PATCH 31/59] Undo reporter change --- tests/e2e/playwright.config.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js index 41b52db268185..f916128f8e950 100644 --- a/tests/e2e/playwright.config.js +++ b/tests/e2e/playwright.config.js @@ -12,8 +12,7 @@ process.env.STORAGE_STATE_PATH ??= path.join( ); const config = defineConfig( { - // reporter: process.env.CI ? [ [ 'github' ] ] : [ [ 'list' ] ], - reporter: 'list', // FIXME: just for testing. + reporter: process.env.CI ? [ [ 'github' ] ] : [ [ 'list' ] ], forbidOnly: !! process.env.CI, workers: 1, retries: process.env.CI ? 2 : 0, From a3048ac782d77138024c4871cfe07b010a035d85 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 2 Oct 2023 15:36:11 +0200 Subject: [PATCH 32/59] Remove `PUPPETEER_SKIP_DOWNLOAD` Browsers are only installed when running tests --- .github/workflows/coding-standards.yml | 2 -- .github/workflows/phpunit-tests-run.yml | 1 - .github/workflows/test-coverage.yml | 1 - .github/workflows/test-npm.yml | 3 --- 4 files changed, 7 deletions(-) diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 0709194d35eab..79ebf67416e34 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -142,8 +142,6 @@ jobs: contents: read timeout-minutes: 20 if: ${{ github.repository == 'WordPress/wordpress-develop' || github.event_name == 'pull_request' }} - env: - PUPPETEER_SKIP_DOWNLOAD: ${{ true }} steps: - name: Checkout repository diff --git a/.github/workflows/phpunit-tests-run.yml b/.github/workflows/phpunit-tests-run.yml index 54c34c6511e00..1609c7cbdbf5b 100644 --- a/.github/workflows/phpunit-tests-run.yml +++ b/.github/workflows/phpunit-tests-run.yml @@ -51,7 +51,6 @@ env: LOCAL_DB_VERSION: ${{ inputs.db-version }} LOCAL_PHP_MEMCACHED: ${{ inputs.memcached }} PHPUNIT_CONFIG: ${{ inputs.phpunit-config }} - PUPPETEER_SKIP_DOWNLOAD: ${{ true }} jobs: # Runs the PHPUnit tests for WordPress. diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml index cd00e9b7f0359..5e963900fb08f 100644 --- a/.github/workflows/test-coverage.yml +++ b/.github/workflows/test-coverage.yml @@ -29,7 +29,6 @@ on: permissions: {} env: - PUPPETEER_SKIP_DOWNLOAD: ${{ true }} LOCAL_PHP: '7.4-fpm' LOCAL_PHP_XDEBUG: true LOCAL_PHP_XDEBUG_MODE: 'coverage' diff --git a/.github/workflows/test-npm.yml b/.github/workflows/test-npm.yml index d53c8ec825690..185908f7943db 100644 --- a/.github/workflows/test-npm.yml +++ b/.github/workflows/test-npm.yml @@ -37,9 +37,6 @@ concurrency: # Any needed permissions should be configured at the job level. permissions: {} -env: - PUPPETEER_SKIP_DOWNLOAD: ${{ true }} - jobs: # Verifies that installing npm dependencies and building WordPress works as expected. # From 4141e47d57cee0e4fcfd670b48b0bddb3f677b24 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 2 Oct 2023 15:41:53 +0200 Subject: [PATCH 33/59] Update readmes --- tests/e2e/README.md | 47 ++++++++++++++++++++++++++++++- tests/visual-regression/README.md | 4 +-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/tests/e2e/README.md b/tests/e2e/README.md index fffb5e6fc4a82..26f20d1bda560 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -1 +1,46 @@ -# E2E Tests End-To-End (E2E) tests for WordPress. ## Running the tests The e2e tests require a production-like environment to run. By default, they will assume an environment is available at `http://localhost:8889`, with username=admin and password=password. If you don't already have an environment ready, you can set one up by following [these instructions](https://github.com/WordPress/wordpress-develop/blob/master/README.md). Then you can launch the tests by running: ``` npm run test:e2e ``` which will run the test suite using a headless browser. If your environment has a different url, username or password to the default, you can provide the base URL, username and password like this: ``` npm run test:e2e -- --wordpress-base-url=http://mycustomurl --wordpress-username=username --wordpress-password=password ``` **DO NOT run these tests in an actual production environment, as they will delete all your content.** For debugging purposes, you might want to follow the test visually. You can do so by running the tests in an interactive mode. ``` npm run test:e2e -- --puppeteer-interactive ``` You can also run a single test file separately: ``` npm run test:e2e tests/e2e/specs/hello.test.js ``` ## Documentation * Block Editor Handbook end to end testing overview: https://developer.wordpress.org/block-editor/contributors/code/testing-overview/#end-to-end-testing * Gutenberg e2e-test-utils package API docs: https://github.com/WordPress/gutenberg/tree/trunk/packages/e2e-test-utils * Puppeteer API docs: https://github.com/puppeteer/puppeteer#readme (the version we are using is indicated in the @wordpress/scripts package: https://github.com/WordPress/gutenberg/blob/trunk/packages/scripts/package.json) \ No newline at end of file +# E2E Tests + +End-To-End (E2E) tests for WordPress. + + +## Running the tests + +The e2e tests require a production-like environment to run. By default, they will assume an environment is available at `http://localhost:8889`, with username `admin` and password `password`. + +If you don't already have an environment ready, you can set one up by following [these instructions](https://github.com/WordPress/wordpress-develop/blob/master/README.md). + +Then you can launch the tests by running: + +``` +npm run test:e2e +``` + +which will run the test suite using a headless browser. + +If your environment has a different url, username or password to the default, you can provide the base URL, username and password like this: + +``` +WP_BASE_URL=http://mycustomurl WP_USERNAME=username WP_PASSWORD=password npm run test:e2e +``` +**DO NOT run these tests in an actual production environment, as they will delete all your content.** + +For debugging purposes, you might want to follow the test visually. You can do so by running the tests in an interactive mode. + +``` +npm run test:e2e -- --debug +``` + +You can also run a single test file separately: + +``` +npm run test:e2e tests/e2e/specs/hello.test.js +``` + + +## Documentation + +* Block Editor Handbook end to end testing overview: https://developer.wordpress.org/block-editor/contributors/code/testing-overview/#end-to-end-testing + +* Gutenberg e2e-test-utils-playwright package API docs: https://github.com/WordPress/gutenberg/tree/trunk/packages/e2e-test-utils-playwright + +* Playwright API docs: https://playwright.dev/docs (the version we are using is indicated in the `@wordpress/scripts` package: https://github.com/WordPress/gutenberg/blob/trunk/packages/scripts/package.json) diff --git a/tests/visual-regression/README.md b/tests/visual-regression/README.md index fe32fc0688b13..d7ef71e64324b 100644 --- a/tests/visual-regression/README.md +++ b/tests/visual-regression/README.md @@ -1,11 +1,11 @@ # Visual Regression Tests in WordPress Core -These tests make use of Jest and Puppeteer, with a setup very similar to that of the e2e tests, together with [jest-image-snapshot](https://github.com/americanexpress/jest-image-snapshot) for generating the visual diffs. +These tests make use of Playwright, with a setup very similar to that of the e2e tests. ## How to Run the Tests Locally 1. Check out trunk. 2. Run `npm run test:visual` to generate some base snapshots. 3. Check out the feature branch to be tested. -4. Run `npm run test:visual` again. If any tests fail, the diff images can be found in `tests/visual-regression/specs/__image_snapshots__/__diff_output__`. +4. Run `npm run test:visual` again. If any tests fail, the diff images can be found in `artifacts/` From 737b58cf097ebb53cae7684f83eebde0fdf9efce Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Mon, 2 Oct 2023 18:23:48 +0400 Subject: [PATCH 34/59] Make Gutenberg plugin tests more resilient --- tests/e2e/specs/gutenberg-plugin.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/e2e/specs/gutenberg-plugin.test.js b/tests/e2e/specs/gutenberg-plugin.test.js index 5a0006f33f23e..8f3ff20acc1c1 100644 --- a/tests/e2e/specs/gutenberg-plugin.test.js +++ b/tests/e2e/specs/gutenberg-plugin.test.js @@ -17,6 +17,8 @@ test.describe( 'Gutenberg plugin', () => { } ); } + // Refetch installed plugin details. It avoids stale values when the test installs the plugin. + await requestUtils.getPluginsMap( /* forceRefetch */ true ); await requestUtils.deactivatePlugin( 'gutenberg' ); } ); From fb782808e879273f85a91a24b5bcddc76ba7d6b8 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 10 Oct 2023 11:16:18 +0200 Subject: [PATCH 35/59] Extend base PW config from wp-scripts --- tests/e2e/playwright.config.js | 49 +++--------------- tests/performance/playwright.config.js | 53 ++++++-------------- tests/visual-regression/playwright.config.js | 51 ++++--------------- 3 files changed, 33 insertions(+), 120 deletions(-) diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js index f916128f8e950..8d212e2dde597 100644 --- a/tests/e2e/playwright.config.js +++ b/tests/e2e/playwright.config.js @@ -3,7 +3,12 @@ */ import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { defineConfig, devices } from '@playwright/test'; +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( @@ -12,52 +17,14 @@ process.env.STORAGE_STATE_PATH ??= path.join( ); const config = defineConfig( { - reporter: process.env.CI ? [ [ 'github' ] ] : [ [ 'list' ] ], - forbidOnly: !! process.env.CI, - workers: 1, - retries: process.env.CI ? 2 : 0, - timeout: parseInt( process.env.TIMEOUT || '', 10 ) || 100_000, // Defaults to 100 seconds. - // Don't report slow test "files", as we will be running our tests in serial. - reportSlowTests: null, - testDir: './specs', - outputDir: path.join( process.env.WP_ARTIFACTS_PATH, 'test-results' ), - snapshotPathTemplate: - '{testDir}/{testFileDir}/__snapshots__/{arg}-{projectName}{ext}', + ...baseConfig, globalSetup: fileURLToPath( new URL( './config/global-setup.js', 'file:' + __filename ).href ), - fullyParallel: false, - use: { - baseURL: process.env.WP_BASE_URL || 'http://localhost:8889', - headless: true, - viewport: { - width: 960, - height: 700, - }, - ignoreHTTPSErrors: true, - locale: 'en-US', - contextOptions: { - reducedMotion: 'reduce', - strictSelectors: true, - }, - storageState: process.env.STORAGE_STATE_PATH, - actionTimeout: 10_000, // 10 seconds. - trace: 'retain-on-failure', - screenshot: 'only-on-failure', - video: 'on-first-retry', - }, webServer: { + ...baseConfig.webServer, command: 'npm run env:start', - port: 8889, - timeout: 120_000, // 120 seconds. - reuseExistingServer: true, }, - projects: [ - { - name: 'chromium', - use: { ...devices[ 'Desktop Chrome' ] }, - }, - ], } ); export default config; diff --git a/tests/performance/playwright.config.js b/tests/performance/playwright.config.js index a19dcb0dd51bb..92f62ba5b8998 100644 --- a/tests/performance/playwright.config.js +++ b/tests/performance/playwright.config.js @@ -3,7 +3,12 @@ */ import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { defineConfig, devices } from '@playwright/test'; +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( @@ -13,6 +18,10 @@ process.env.STORAGE_STATE_PATH ??= path.join( process.env.TEST_RUNS ??= '20'; const config = defineConfig( { + ...baseConfig, + globalSetup: fileURLToPath( + new URL( './config/global-setup.js', 'file:' + __filename ).href + ), reporter: process.env.CI ? './config/performance-reporter.js' : [ [ 'list' ], [ './config/performance-reporter.js' ] ], @@ -22,45 +31,15 @@ const config = defineConfig( { 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, - testDir: './specs', - outputDir: path.join( process.env.WP_ARTIFACTS_PATH, 'test-results' ), - snapshotPathTemplate: - '{testDir}/{testFileDir}/__snapshots__/{arg}-{projectName}{ext}', - globalSetup: fileURLToPath( - new URL( './config/global-setup.js', 'file:' + __filename ).href - ), - fullyParallel: false, - use: { - baseURL: process.env.WP_BASE_URL || 'http://localhost:8889', - headless: true, - viewport: { - width: 960, - height: 700, - }, - ignoreHTTPSErrors: true, - locale: 'en-US', - contextOptions: { - reducedMotion: 'reduce', - strictSelectors: true, - }, - storageState: process.env.STORAGE_STATE_PATH, - actionTimeout: 10_000, // 10 seconds. - trace: 'retain-on-failure', - screenshot: 'only-on-failure', - video: 'off', - }, webServer: { + ...baseConfig.webServer, command: 'npm run env:start', - port: 8889, - timeout: 120_000, // 120 seconds. - reuseExistingServer: true, }, - projects: [ - { - name: 'chromium', - use: { ...devices[ 'Desktop Chrome' ] }, - }, - ], + use: { + ...baseConfig.default.use, + video: 'off', + }, } ); export default config; + diff --git a/tests/visual-regression/playwright.config.js b/tests/visual-regression/playwright.config.js index c44ba4ac036a6..759d887bf71c2 100644 --- a/tests/visual-regression/playwright.config.js +++ b/tests/visual-regression/playwright.config.js @@ -2,8 +2,12 @@ * External dependencies */ import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { defineConfig, devices } from '@playwright/test'; +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( @@ -12,49 +16,12 @@ process.env.STORAGE_STATE_PATH ??= path.join( ); const config = defineConfig( { - reporter: process.env.CI ? [ [ 'github' ] ] : [ [ 'list' ] ], - forbidOnly: !! process.env.CI, - workers: 1, - retries: process.env.CI ? 2 : 0, - timeout: parseInt( process.env.TIMEOUT || '', 10 ) || 100_000, // Defaults to 100 seconds. - // Don't report slow test "files", as we will be running our tests in serial. - reportSlowTests: null, - testDir: './specs', - outputDir: path.join( process.env.WP_ARTIFACTS_PATH, 'test-results' ), - snapshotPathTemplate: - '{testDir}/{testFileDir}/__snapshots__/{arg}-{projectName}{ext}', - fullyParallel: false, - use: { - baseURL: process.env.WP_BASE_URL || 'http://localhost:8889', - headless: true, - viewport: { - width: 1000, - height: 750, - }, - ignoreHTTPSErrors: true, - locale: 'en-US', - contextOptions: { - reducedMotion: 'reduce', - strictSelectors: true, - }, - storageState: process.env.STORAGE_STATE_PATH, - actionTimeout: 10_000, // 10 seconds. - trace: 'retain-on-failure', - screenshot: 'only-on-failure', - video: 'on-first-retry', - }, + ...baseConfig, + globalSetup: undefined, webServer: { + ...baseConfig.webServer, command: 'npm run env:start', - port: 8889, - timeout: 120_000, // 120 seconds. - reuseExistingServer: true, }, - projects: [ - { - name: 'chromium', - use: { ...devices[ 'Desktop Chrome' ] }, - }, - ], } ); export default config; From bc35c5eb7bf99bb3e3a46f87e8fbf1894bf32c64 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 10 Oct 2023 13:41:21 +0200 Subject: [PATCH 36/59] Fix config --- tests/performance/playwright.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/performance/playwright.config.js b/tests/performance/playwright.config.js index 92f62ba5b8998..cd49879fd89c8 100644 --- a/tests/performance/playwright.config.js +++ b/tests/performance/playwright.config.js @@ -36,7 +36,7 @@ const config = defineConfig( { command: 'npm run env:start', }, use: { - ...baseConfig.default.use, + ...baseConfig.use, video: 'off', }, } ); From 827d43d6820d8bbc98a8c672984be064b50e52f3 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 10 Oct 2023 13:47:43 +0200 Subject: [PATCH 37/59] Fix indentation --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 600022f0dc689..699e0ab4568ee 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ ], "devDependencies": { "@lodder/grunt-postcss": "^3.1.1", - "@playwright/test": "1.32.0", + "@playwright/test": "1.32.0", "@pmmmwh/react-refresh-webpack-plugin": "0.5.5", "@wordpress/babel-preset-default": "7.26.5", "@wordpress/dependency-extraction-webpack-plugin": "4.25.5", From 9f07be2ac40344824c874183eaf589ea336998de Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Tue, 10 Oct 2023 13:49:45 +0200 Subject: [PATCH 38/59] use `require.resolve` --- tests/e2e/playwright.config.js | 5 +---- tests/performance/playwright.config.js | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js index 8d212e2dde597..0de694e324c24 100644 --- a/tests/e2e/playwright.config.js +++ b/tests/e2e/playwright.config.js @@ -2,7 +2,6 @@ * External dependencies */ import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { defineConfig } from '@playwright/test'; /** @@ -18,9 +17,7 @@ process.env.STORAGE_STATE_PATH ??= path.join( const config = defineConfig( { ...baseConfig, - globalSetup: fileURLToPath( - new URL( './config/global-setup.js', 'file:' + __filename ).href - ), + globalSetup: require.resolve( './config/global-setup.js' ), webServer: { ...baseConfig.webServer, command: 'npm run env:start', diff --git a/tests/performance/playwright.config.js b/tests/performance/playwright.config.js index cd49879fd89c8..6c2ff454725ed 100644 --- a/tests/performance/playwright.config.js +++ b/tests/performance/playwright.config.js @@ -2,7 +2,6 @@ * External dependencies */ import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { defineConfig } from '@playwright/test'; /** @@ -19,9 +18,7 @@ process.env.TEST_RUNS ??= '20'; const config = defineConfig( { ...baseConfig, - globalSetup: fileURLToPath( - new URL( './config/global-setup.js', 'file:' + __filename ).href - ), + globalSetup: require.resolve( './config/global-setup.js' ), reporter: process.env.CI ? './config/performance-reporter.js' : [ [ 'list' ], [ './config/performance-reporter.js' ] ], From 53b5166d1c36dc1d37daf7fca3e960074eaaff4b Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 12 Oct 2023 14:13:57 +0200 Subject: [PATCH 39/59] Fix double comment Co-authored-by: Kai Hao --- tests/e2e/specs/edit-posts.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/specs/edit-posts.test.js b/tests/e2e/specs/edit-posts.test.js index e7b3e81174224..0e2eb3687f778 100644 --- a/tests/e2e/specs/edit-posts.test.js +++ b/tests/e2e/specs/edit-posts.test.js @@ -121,7 +121,7 @@ test.describe( 'Edit Posts', () => { const listTable = page.getByRole( 'table', { name: 'Table ordered by' } ); await expect( listTable ).toBeVisible(); - // // Focus on the post title link. + // Focus on the post title link. await listTable.getByRole( 'link', { name: `“${ title }” (Edit)` } ).focus(); // Tab to the Trash button and press Enter to delete the post. From a027cae6ae6461f428fbfb1fd4eb2781c3d4c03c Mon Sep 17 00:00:00 2001 From: Tonya Mork Date: Tue, 10 Oct 2023 12:50:19 +0000 Subject: [PATCH 40/59] Options, Meta APIs: Check setting group exists before search in unregister_setting(). Checks if the given `$option_group` exists before searching for the `$option_name`. Sets the `$pos` to `false`, as `array_search()` returns `false` if the option name (needle) does not exist. This changeset fixes 2 different PHP Warning|Notice scenarios: 1. When the global `$new_allowed_options` is `null`, fixes raising `Trying to access array offset on value of type null` PHP Notice (PHP 7.4) | Warning (on PHP 8). 2. When the global `$new_allowed_options` is an `array` and the setting group key does not exist, fixes raising "Undefined index: unknown_setting_group" PHP Notice (PHP 7) | Warning (on PHP 8). For both scenarios, the `array_search()` is skipped and the `$pos` is set to a default of `false`, i.e. which is the value returned when `array_search()` is unsuccessful. Props xknown, hellofromTonya, nicolefurlan, oglekler, SergeyBiryukov, shailu25. Fixes #57674. git-svn-id: https://develop.svn.wordpress.org/trunk@56817 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/option.php | 5 ++++- tests/phpunit/tests/option/registration.php | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index c8136950307a3..4df0b81458b68 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -2903,7 +2903,10 @@ function unregister_setting( $option_group, $option_name, $deprecated = '' ) { $option_group = 'reading'; } - $pos = array_search( $option_name, (array) $new_allowed_options[ $option_group ], true ); + $pos = false; + if ( isset( $new_allowed_options[ $option_group ] ) ) { + $pos = array_search( $option_name, (array) $new_allowed_options[ $option_group ], true ); + } if ( false !== $pos ) { unset( $new_allowed_options[ $option_group ][ $pos ] ); diff --git a/tests/phpunit/tests/option/registration.php b/tests/phpunit/tests/option/registration.php index 04991e4a33832..9b0e418c91c57 100644 --- a/tests/phpunit/tests/option/registration.php +++ b/tests/phpunit/tests/option/registration.php @@ -149,4 +149,18 @@ public function test_unregister_setting_removes_default() { $this->assertFalse( has_filter( 'default_option_test_default', 'filter_default_option' ) ); } + + /** + * The test passes if a Notice | Warning | Error is not raised. Thus. the absence of a Notice | Warning | Error + * is an indicator the fix in the ticket resolves the issue. + * + * @ticket 57674 + * + * @covers ::unregister_setting + */ + public function test_unregister_invalid_setting_does_not_raise_php_notice_warning_or_error() { + $setting = uniqid(); + unregister_setting( $setting, $setting ); + $this->assertFalse( has_filter( 'default_option_' . $setting, 'filter_default_option' ) ); + } } From 593894a2324a04935007352dd41d083f02501f35 Mon Sep 17 00:00:00 2001 From: Bernie Reiter Date: Tue, 10 Oct 2023 13:19:42 +0000 Subject: [PATCH 41/59] Patterns: Inject `theme` attribute into Template Part blocks. [56805] introduced a regression: The `theme` attribute was no longer injected into Template Part blocks inside of patterns. This caused errors on the frontend, where instead of a given template part, an error message such as `Template part has been deleted or is unavailable: header` was seen. This changeset rectifies that problem, and adds unit test coverage to guard against future regressions. Follow-up to [56805]. Props scruffian, gziolo. Fixes #59583. git-svn-id: https://develop.svn.wordpress.org/trunk@56818 602fd350-edb4-49c9-b593-d223f7449a82 --- .../class-wp-block-patterns-registry.php | 7 ++- .../tests/blocks/wpBlockPatternsRegistry.php | 46 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/class-wp-block-patterns-registry.php b/src/wp-includes/class-wp-block-patterns-registry.php index d1ef2ad422a59..e516277e42dde 100644 --- a/src/wp-includes/class-wp-block-patterns-registry.php +++ b/src/wp-includes/class-wp-block-patterns-registry.php @@ -164,12 +164,15 @@ public function unregister( $pattern_name ) { */ private function prepare_content( $pattern, $hooked_blocks ) { $content = $pattern['content']; + + $before_block_visitor = '_inject_theme_attribute_in_template_part_block'; + $after_block_visitor = null; if ( ! empty( $hooked_blocks ) || has_filter( 'hooked_block_types' ) ) { - $blocks = parse_blocks( $content ); $before_block_visitor = make_before_block_visitor( $hooked_blocks, $pattern ); $after_block_visitor = make_after_block_visitor( $hooked_blocks, $pattern ); - $content = traverse_and_serialize_blocks( $blocks, $before_block_visitor, $after_block_visitor ); } + $blocks = parse_blocks( $content ); + $content = traverse_and_serialize_blocks( $blocks, $before_block_visitor, $after_block_visitor ); return $content; } diff --git a/tests/phpunit/tests/blocks/wpBlockPatternsRegistry.php b/tests/phpunit/tests/blocks/wpBlockPatternsRegistry.php index 8eb4fce78b251..61298d1b164af 100644 --- a/tests/phpunit/tests/blocks/wpBlockPatternsRegistry.php +++ b/tests/phpunit/tests/blocks/wpBlockPatternsRegistry.php @@ -316,6 +316,29 @@ public function test_get_registered() { $this->assertSame( $pattern_two, $pattern ); } + /** + * Should insert a theme attribute into Template Part blocks in registered patterns. + * + * @ticket 59583 + * + * @covers WP_Block_Patterns_Registry::register + * @covers WP_Block_Patterns_Registry::get_all_registered + */ + public function test_get_all_registered_includes_theme_attribute() { + $test_pattern = array( + 'title' => 'Test Pattern', + 'content' => '', + ); + $this->registry->register( 'test/pattern', $test_pattern ); + + $expected = sprintf( + '', + get_stylesheet() + ); + $patterns = $this->registry->get_all_registered(); + $this->assertSame( $expected, $patterns[0]['content'] ); + } + /** * Should insert hooked blocks into registered patterns. * @@ -368,6 +391,29 @@ public function test_get_all_registered_includes_hooked_blocks() { $this->assertSame( $expected, $registered ); } + /** + * Should insert a theme attribute into Template Part blocks in registered patterns. + * + * @ticket 59583 + * + * @covers WP_Block_Patterns_Registry::register + * @covers WP_Block_Patterns_Registry::get_registered + */ + public function test_get_registered_includes_theme_attribute() { + $test_pattern = array( + 'title' => 'Test Pattern', + 'content' => '', + ); + $this->registry->register( 'test/pattern', $test_pattern ); + + $expected = sprintf( + '', + get_stylesheet() + ); + $pattern = $this->registry->get_registered( 'test/pattern' ); + $this->assertSame( $expected, $pattern['content'] ); + } + /** * Should insert hooked blocks into registered patterns. * From 050997ce000864b3927175702097fbdec086d60c Mon Sep 17 00:00:00 2001 From: Jonny Harris Date: Tue, 10 Oct 2023 14:03:03 +0000 Subject: [PATCH 42/59] REST API: Fix issue with Template and Template Part Revision/Autosave REST API controllers. The Template and Template Part REST API controllers have unique characteristics compared to other post type REST API controllers. They do not rely on integer IDs to reference objects; instead, they use a combination of the theme name and slug of the template, like 'twentytwentyfour//home.' Consequently, when the post types template and template part were introduced in [52062], it led to the registration of REST API endpoints for autosaves and revisions with invalid URL structures. In this commit, we introduce new functionality to enable custom autosave and revisions endpoints to be registered at the post type level. Similar to the 'rest_controller_class' parameter, developers can now define 'revisions_rest_controller' and 'autosave_rest_controller.' This empowers developers to create custom controllers for these functionalities. Additionally, we introduce a 'late_route_registration' parameter, which proves helpful when dealing with custom URL patterns and regex pattern matching issues. This commit registers new classes for template and template part autosave and revisions controllers, differentiating them from standard controllers in the following ways: * The response shape now matches that of the template controller. * Permission checks align with the template controller. * A custom URL pattern is introduced to support slug-based identification of templates. Furthermore, we've updated the utility function '_build_block_template_result_from_post' to support passing revision post objects. This enhancement ensures compatibility with the custom revisions controller. Props spacedmonkey, revgeorge, andraganescu, hellofromTonya, antonvlasenko, kadamwhite, ironprogrammer, costdev, mukesh27, timothyblynjacobs, adamsilverstein. Fixes 56922. git-svn-id: https://develop.svn.wordpress.org/trunk@56819 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/block-template-utils.php | 26 +- src/wp-includes/class-wp-post-type.php | 193 +- src/wp-includes/post.php | 281 +- src/wp-includes/rest-api.php | 16 +- .../class-wp-rest-autosaves-controller.php | 13 +- ...-wp-rest-template-autosaves-controller.php | 276 ++ ...-wp-rest-template-revisions-controller.php | 297 ++ .../class-wp-rest-templates-controller.php | 23 +- src/wp-settings.php | 2 + tests/phpunit/tests/post/wpPostType.php | 210 ++ .../tests/rest-api/rest-schema-setup.php | 16 +- .../wpRestTemplateAutosavesController.php | 413 ++ .../wpRestTemplateRevisionsController.php | 511 +++ tests/qunit/fixtures/wp-api-generated.js | 3350 ++++++++--------- 14 files changed, 3758 insertions(+), 1869 deletions(-) create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-template-autosaves-controller.php create mode 100644 src/wp-includes/rest-api/endpoints/class-wp-rest-template-revisions-controller.php create mode 100644 tests/phpunit/tests/rest-api/wpRestTemplateAutosavesController.php create mode 100644 tests/phpunit/tests/rest-api/wpRestTemplateRevisionsController.php diff --git a/src/wp-includes/block-template-utils.php b/src/wp-includes/block-template-utils.php index 0ce64a659d6a6..c5953e1d4ce2c 100644 --- a/src/wp-includes/block-template-utils.php +++ b/src/wp-includes/block-template-utils.php @@ -724,6 +724,7 @@ function _wp_build_title_and_description_for_taxonomy_block_template( $taxonomy, * * @since 5.9.0 * @since 6.3.0 Added `modified` property to template objects. + * @since 6.4.0 Added support for a revision post to be passed to this function. * @access private * * @param WP_Post $post Template post. @@ -731,7 +732,14 @@ function _wp_build_title_and_description_for_taxonomy_block_template( $taxonomy, */ function _build_block_template_result_from_post( $post ) { $default_template_types = get_default_block_template_types(); - $terms = get_the_terms( $post, 'wp_theme' ); + + $post_id = wp_is_post_revision( $post ); + if ( ! $post_id ) { + $post_id = $post; + } + $parent_post = get_post( $post_id ); + + $terms = get_the_terms( $parent_post, 'wp_theme' ); if ( is_wp_error( $terms ) ) { return $terms; @@ -745,12 +753,12 @@ function _build_block_template_result_from_post( $post ) { $template_file = _get_block_template_file( $post->post_type, $post->post_name ); $has_theme_file = get_stylesheet() === $theme && null !== $template_file; - $origin = get_post_meta( $post->ID, 'origin', true ); - $is_wp_suggestion = get_post_meta( $post->ID, 'is_wp_suggestion', true ); + $origin = get_post_meta( $parent_post->ID, 'origin', true ); + $is_wp_suggestion = get_post_meta( $parent_post->ID, 'is_wp_suggestion', true ); $template = new WP_Block_Template(); $template->wp_id = $post->ID; - $template->id = $theme . '//' . $post->post_name; + $template->id = $theme . '//' . $parent_post->post_name; $template->theme = $theme; $template->content = $post->post_content; $template->slug = $post->post_name; @@ -765,23 +773,23 @@ function _build_block_template_result_from_post( $post ) { $template->author = $post->post_author; $template->modified = $post->post_modified; - if ( 'wp_template' === $post->post_type && $has_theme_file && isset( $template_file['postTypes'] ) ) { + if ( 'wp_template' === $parent_post->post_type && $has_theme_file && isset( $template_file['postTypes'] ) ) { $template->post_types = $template_file['postTypes']; } - if ( 'wp_template' === $post->post_type && isset( $default_template_types[ $template->slug ] ) ) { + if ( 'wp_template' === $parent_post->post_type && isset( $default_template_types[ $template->slug ] ) ) { $template->is_custom = false; } - if ( 'wp_template_part' === $post->post_type ) { - $type_terms = get_the_terms( $post, 'wp_template_part_area' ); + if ( 'wp_template_part' === $parent_post->post_type ) { + $type_terms = get_the_terms( $parent_post, 'wp_template_part_area' ); if ( ! is_wp_error( $type_terms ) && false !== $type_terms ) { $template->area = $type_terms[0]->name; } } // Check for a block template without a description and title or with a title equal to the slug. - if ( 'wp_template' === $post->post_type && empty( $template->description ) && ( empty( $template->title ) || $template->title === $template->slug ) ) { + if ( 'wp_template' === $parent_post->post_type && empty( $template->description ) && ( empty( $template->title ) || $template->title === $template->slug ) ) { $matches = array(); // Check for a block template for a single author, page, post, tag, category, custom post type, or custom taxonomy. diff --git a/src/wp-includes/class-wp-post-type.php b/src/wp-includes/class-wp-post-type.php index ce6d9d348b480..7a2769ed88327 100644 --- a/src/wp-includes/class-wp-post-type.php +++ b/src/wp-includes/class-wp-post-type.php @@ -396,6 +396,54 @@ final class WP_Post_Type { */ public $rest_controller; + /** + * The controller for this post type's revisions REST API endpoints. + * + * Custom controllers must extend WP_REST_Controller. + * + * @since 6.4.0 + * @var string|bool $revisions_rest_controller_class + */ + public $revisions_rest_controller_class; + + /** + * The controller instance for this post type's revisions REST API endpoints. + * + * Lazily computed. Should be accessed using {@see WP_Post_Type::get_revisions_rest_controller()}. + * + * @since 6.4.0 + * @var WP_REST_Controller $revisions_rest_controller + */ + public $revisions_rest_controller; + + /** + * The controller for this post type's autosave REST API endpoints. + * + * Custom controllers must extend WP_REST_Controller. + * + * @since 6.4.0 + * @var string|bool $autosave_rest_controller_class + */ + public $autosave_rest_controller_class; + + /** + * The controller instance for this post type's autosave REST API endpoints. + * + * Lazily computed. Should be accessed using {@see WP_Post_Type::get_autosave_rest_controller()}. + * + * @since 6.4.0 + * @var WP_REST_Controller $autosave_rest_controller + */ + public $autosave_rest_controller; + + /** + * A flag to register the post type REST API controller after its associated autosave / revisions controllers, instead of before. Registration order affects route matching priority. + * + * @since 6.4.0 + * @var bool $late_route_registration + */ + public $late_route_registration; + /** * Constructor. * @@ -455,6 +503,7 @@ public function set_props( $args ) { * - `register_page_post_type_args` * * @since 6.0.0 + * @since 6.4.0 Added `late_route_registration`, `autosave_rest_controller_class` and `revisions_rest_controller_class` arguments. * * @param array $args Array of arguments for registering a post type. * See the register_post_type() function for accepted arguments. @@ -466,37 +515,40 @@ public function set_props( $args ) { // Args prefixed with an underscore are reserved for internal use. $defaults = array( - 'labels' => array(), - 'description' => '', - 'public' => false, - 'hierarchical' => false, - 'exclude_from_search' => null, - 'publicly_queryable' => null, - 'show_ui' => null, - 'show_in_menu' => null, - 'show_in_nav_menus' => null, - 'show_in_admin_bar' => null, - 'menu_position' => null, - 'menu_icon' => null, - 'capability_type' => 'post', - 'capabilities' => array(), - 'map_meta_cap' => null, - 'supports' => array(), - 'register_meta_box_cb' => null, - 'taxonomies' => array(), - 'has_archive' => false, - 'rewrite' => true, - 'query_var' => true, - 'can_export' => true, - 'delete_with_user' => null, - 'show_in_rest' => false, - 'rest_base' => false, - 'rest_namespace' => false, - 'rest_controller_class' => false, - 'template' => array(), - 'template_lock' => false, - '_builtin' => false, - '_edit_link' => 'post.php?post=%d', + 'labels' => array(), + 'description' => '', + 'public' => false, + 'hierarchical' => false, + 'exclude_from_search' => null, + 'publicly_queryable' => null, + 'show_ui' => null, + 'show_in_menu' => null, + 'show_in_nav_menus' => null, + 'show_in_admin_bar' => null, + 'menu_position' => null, + 'menu_icon' => null, + 'capability_type' => 'post', + 'capabilities' => array(), + 'map_meta_cap' => null, + 'supports' => array(), + 'register_meta_box_cb' => null, + 'taxonomies' => array(), + 'has_archive' => false, + 'rewrite' => true, + 'query_var' => true, + 'can_export' => true, + 'delete_with_user' => null, + 'show_in_rest' => false, + 'rest_base' => false, + 'rest_namespace' => false, + 'rest_controller_class' => false, + 'autosave_rest_controller_class' => false, + 'revisions_rest_controller_class' => false, + 'late_route_registration' => false, + 'template' => array(), + 'template_lock' => false, + '_builtin' => false, + '_edit_link' => 'post.php?post=%d', ); $args = array_merge( $defaults, $args ); @@ -816,6 +868,85 @@ public function get_rest_controller() { return $this->rest_controller; } + /** + * Gets the REST API revisions controller for this post type. + * + * Will only instantiate the controller class once per request. + * + * @since 6.4.0 + * + * @return WP_REST_Controller|null The controller instance, or null if the post type + * is set not to show in rest. + */ + public function get_revisions_rest_controller() { + if ( ! $this->show_in_rest ) { + return null; + } + + if ( ! post_type_supports( $this->name, 'revisions' ) ) { + return null; + } + + $class = $this->revisions_rest_controller_class ? $this->revisions_rest_controller_class : WP_REST_Revisions_Controller::class; + if ( ! class_exists( $class ) ) { + return null; + } + + if ( ! is_subclass_of( $class, WP_REST_Controller::class ) ) { + return null; + } + + if ( ! $this->revisions_rest_controller ) { + $this->revisions_rest_controller = new $class( $this->name ); + } + + if ( ! ( $this->revisions_rest_controller instanceof $class ) ) { + return null; + } + + return $this->revisions_rest_controller; + } + + /** + * Gets the REST API autosave controller for this post type. + * + * Will only instantiate the controller class once per request. + * + * @since 6.4.0 + * + * @return WP_REST_Controller|null The controller instance, or null if the post type + * is set not to show in rest. + */ + public function get_autosave_rest_controller() { + if ( ! $this->show_in_rest ) { + return null; + } + + if ( 'attachment' === $this->name ) { + return null; + } + + $class = $this->autosave_rest_controller_class ? $this->autosave_rest_controller_class : WP_REST_Autosaves_Controller::class; + + if ( ! class_exists( $class ) ) { + return null; + } + + if ( ! is_subclass_of( $class, WP_REST_Controller::class ) ) { + return null; + } + + if ( ! $this->autosave_rest_controller ) { + $this->autosave_rest_controller = new $class( $this->name ); + } + + if ( ! ( $this->autosave_rest_controller instanceof $class ) ) { + return null; + } + + return $this->autosave_rest_controller; + } + /** * Returns the default labels for post types. * diff --git a/src/wp-includes/post.php b/src/wp-includes/post.php index a85d8fbdb5e87..c3910d2b3a2d3 100644 --- a/src/wp-includes/post.php +++ b/src/wp-includes/post.php @@ -346,7 +346,7 @@ function create_initial_post_types() { register_post_type( 'wp_template', array( - 'labels' => array( + 'labels' => array( 'name' => _x( 'Templates', 'post type general name' ), 'singular_name' => _x( 'Template', 'post type singular name' ), 'add_new' => __( 'Add New Template' ), @@ -366,19 +366,22 @@ function create_initial_post_types() { 'items_list_navigation' => __( 'Templates list navigation' ), 'items_list' => __( 'Templates list' ), ), - 'description' => __( 'Templates to include in your theme.' ), - 'public' => false, - '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ - '_edit_link' => $template_edit_link, /* internal use only. don't use this when registering your own post type. */ - 'has_archive' => false, - 'show_ui' => false, - 'show_in_menu' => false, - 'show_in_rest' => true, - 'rewrite' => false, - 'rest_base' => 'templates', - 'rest_controller_class' => 'WP_REST_Templates_Controller', - 'capability_type' => array( 'template', 'templates' ), - 'capabilities' => array( + 'description' => __( 'Templates to include in your theme.' ), + 'public' => false, + '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ + '_edit_link' => $template_edit_link, /* internal use only. don't use this when registering your own post type. */ + 'has_archive' => false, + 'show_ui' => false, + 'show_in_menu' => false, + 'show_in_rest' => true, + 'rewrite' => false, + 'rest_base' => 'templates', + 'rest_controller_class' => 'WP_REST_Templates_Controller', + 'autosave_rest_controller_class' => 'WP_REST_Template_Autosaves_Controller', + 'revisions_rest_controller_class' => 'WP_REST_Template_Revisions_Controller', + 'late_route_registration' => true, + 'capability_type' => array( 'template', 'templates' ), + 'capabilities' => array( 'create_posts' => 'edit_theme_options', 'delete_posts' => 'edit_theme_options', 'delete_others_posts' => 'edit_theme_options', @@ -392,8 +395,8 @@ function create_initial_post_types() { 'read' => 'edit_theme_options', 'read_private_posts' => 'edit_theme_options', ), - 'map_meta_cap' => true, - 'supports' => array( + 'map_meta_cap' => true, + 'supports' => array( 'title', 'slug', 'excerpt', @@ -407,7 +410,7 @@ function create_initial_post_types() { register_post_type( 'wp_template_part', array( - 'labels' => array( + 'labels' => array( 'name' => _x( 'Template Parts', 'post type general name' ), 'singular_name' => _x( 'Template Part', 'post type singular name' ), 'add_new' => __( 'Add New Template Part' ), @@ -427,19 +430,22 @@ function create_initial_post_types() { 'items_list_navigation' => __( 'Template parts list navigation' ), 'items_list' => __( 'Template parts list' ), ), - 'description' => __( 'Template parts to include in your templates.' ), - 'public' => false, - '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ - '_edit_link' => $template_edit_link, /* internal use only. don't use this when registering your own post type. */ - 'has_archive' => false, - 'show_ui' => false, - 'show_in_menu' => false, - 'show_in_rest' => true, - 'rewrite' => false, - 'rest_base' => 'template-parts', - 'rest_controller_class' => 'WP_REST_Templates_Controller', - 'map_meta_cap' => true, - 'capabilities' => array( + 'description' => __( 'Template parts to include in your templates.' ), + 'public' => false, + '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ + '_edit_link' => $template_edit_link, /* internal use only. don't use this when registering your own post type. */ + 'has_archive' => false, + 'show_ui' => false, + 'show_in_menu' => false, + 'show_in_rest' => true, + 'rewrite' => false, + 'rest_base' => 'template-parts', + 'rest_controller_class' => 'WP_REST_Templates_Controller', + 'autosave_rest_controller_class' => 'WP_REST_Template_Autosaves_Controller', + 'revisions_rest_controller_class' => 'WP_REST_Template_Revisions_Controller', + 'late_route_registration' => true, + 'map_meta_cap' => true, + 'capabilities' => array( 'create_posts' => 'edit_theme_options', 'delete_posts' => 'edit_theme_options', 'delete_others_posts' => 'edit_theme_options', @@ -453,7 +459,7 @@ function create_initial_post_types() { 'read' => 'edit_theme_options', 'read_private_posts' => 'edit_theme_options', ), - 'supports' => array( + 'supports' => array( 'title', 'slug', 'excerpt', @@ -1575,85 +1581,88 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' ) * @param array|string $args { * Array or string of arguments for registering a post type. * - * @type string $label Name of the post type shown in the menu. Usually plural. - * Default is value of $labels['name']. - * @type string[] $labels An array of labels for this post type. If not set, post - * labels are inherited for non-hierarchical types and page - * labels for hierarchical ones. See get_post_type_labels() for a full - * list of supported labels. - * @type string $description A short descriptive summary of what the post type is. - * Default empty. - * @type bool $public Whether a post type is intended for use publicly either via - * the admin interface or by front-end users. While the default - * settings of $exclude_from_search, $publicly_queryable, $show_ui, - * and $show_in_nav_menus are inherited from $public, each does not - * rely on this relationship and controls a very specific intention. - * Default false. - * @type bool $hierarchical Whether the post type is hierarchical (e.g. page). Default false. - * @type bool $exclude_from_search Whether to exclude posts with this post type from front end search - * results. Default is the opposite value of $public. - * @type bool $publicly_queryable Whether queries can be performed on the front end for the post type - * as part of parse_request(). Endpoints would include: - * * ?post_type={post_type_key} - * * ?{post_type_key}={single_post_slug} - * * ?{post_type_query_var}={single_post_slug} - * If not set, the default is inherited from $public. - * @type bool $show_ui Whether to generate and allow a UI for managing this post type in the - * admin. Default is value of $public. - * @type bool|string $show_in_menu Where to show the post type in the admin menu. To work, $show_ui - * must be true. If true, the post type is shown in its own top level - * menu. If false, no menu is shown. If a string of an existing top - * level menu ('tools.php' or 'edit.php?post_type=page', for example), the - * post type will be placed as a sub-menu of that. - * Default is value of $show_ui. - * @type bool $show_in_nav_menus Makes this post type available for selection in navigation menus. - * Default is value of $public. - * @type bool $show_in_admin_bar Makes this post type available via the admin bar. Default is value - * of $show_in_menu. - * @type bool $show_in_rest Whether to include the post type in the REST API. Set this to true - * for the post type to be available in the block editor. - * @type string $rest_base To change the base URL of REST API route. Default is $post_type. - * @type string $rest_namespace To change the namespace URL of REST API route. Default is wp/v2. - * @type string $rest_controller_class REST API controller class name. Default is 'WP_REST_Posts_Controller'. - * @type int $menu_position The position in the menu order the post type should appear. To work, - * $show_in_menu must be true. Default null (at the bottom). - * @type string $menu_icon The URL to the icon to be used for this menu. Pass a base64-encoded - * SVG using a data URI, which will be colored to match the color scheme - * -- this should begin with 'data:image/svg+xml;base64,'. Pass the name - * of a Dashicons helper class to use a font icon, e.g. - * 'dashicons-chart-pie'. Pass 'none' to leave div.wp-menu-image empty - * so an icon can be added via CSS. Defaults to use the posts icon. - * @type string|array $capability_type The string to use to build the read, edit, and delete capabilities. - * May be passed as an array to allow for alternative plurals when using - * this argument as a base to construct the capabilities, e.g. - * array('story', 'stories'). Default 'post'. - * @type string[] $capabilities Array of capabilities for this post type. $capability_type is used - * as a base to construct capabilities by default. - * See get_post_type_capabilities(). - * @type bool $map_meta_cap Whether to use the internal default meta capability handling. - * Default false. - * @type array $supports Core feature(s) the post type supports. Serves as an alias for calling - * add_post_type_support() directly. Core features include 'title', - * 'editor', 'comments', 'revisions', 'trackbacks', 'author', 'excerpt', - * 'page-attributes', 'thumbnail', 'custom-fields', and 'post-formats'. - * Additionally, the 'revisions' feature dictates whether the post type - * will store revisions, and the 'comments' feature dictates whether the - * comments count will show on the edit screen. A feature can also be - * specified as an array of arguments to provide additional information - * about supporting that feature. - * Example: `array( 'my_feature', array( 'field' => 'value' ) )`. - * Default is an array containing 'title' and 'editor'. - * @type callable $register_meta_box_cb Provide a callback function that sets up the meta boxes for the - * edit form. Do remove_meta_box() and add_meta_box() calls in the - * callback. Default null. - * @type string[] $taxonomies An array of taxonomy identifiers that will be registered for the - * post type. Taxonomies can be registered later with register_taxonomy() - * or register_taxonomy_for_object_type(). - * Default empty array. - * @type bool|string $has_archive Whether there should be post type archives, or if a string, the - * archive slug to use. Will generate the proper rewrite rules if - * $rewrite is enabled. Default false. - * @type bool|array $rewrite { + * @type string $label Name of the post type shown in the menu. Usually plural. + * Default is value of $labels['name']. + * @type string[] $labels An array of labels for this post type. If not set, post + * labels are inherited for non-hierarchical types and page + * labels for hierarchical ones. See get_post_type_labels() for a full + * list of supported labels. + * @type string $description A short descriptive summary of what the post type is. + * Default empty. + * @type bool $public Whether a post type is intended for use publicly either via + * the admin interface or by front-end users. While the default + * settings of $exclude_from_search, $publicly_queryable, $show_ui, + * and $show_in_nav_menus are inherited from $public, each does not + * rely on this relationship and controls a very specific intention. + * Default false. + * @type bool $hierarchical Whether the post type is hierarchical (e.g. page). Default false. + * @type bool $exclude_from_search Whether to exclude posts with this post type from front end search + * results. Default is the opposite value of $public. + * @type bool $publicly_queryable Whether queries can be performed on the front end for the post type + * as part of parse_request(). Endpoints would include: + * * ?post_type={post_type_key} + * * ?{post_type_key}={single_post_slug} + * * ?{post_type_query_var}={single_post_slug} + * If not set, the default is inherited from $public. + * @type bool $show_ui Whether to generate and allow a UI for managing this post type in the + * admin. Default is value of $public. + * @type bool|string $show_in_menu Where to show the post type in the admin menu. To work, $show_ui + * must be true. If true, the post type is shown in its own top level + * menu. If false, no menu is shown. If a string of an existing top + * level menu ('tools.php' or 'edit.php?post_type=page', for example), the + * post type will be placed as a sub-menu of that. + * Default is value of $show_ui. + * @type bool $show_in_nav_menus Makes this post type available for selection in navigation menus. + * Default is value of $public. + * @type bool $show_in_admin_bar Makes this post type available via the admin bar. Default is value + * of $show_in_menu. + * @type bool $show_in_rest Whether to include the post type in the REST API. Set this to true + * for the post type to be available in the block editor. + * @type string $rest_base To change the base URL of REST API route. Default is $post_type. + * @type string $rest_namespace To change the namespace URL of REST API route. Default is wp/v2. + * @type string $rest_controller_class REST API controller class name. Default is 'WP_REST_Posts_Controller'. + * @type string|bool $autosave_rest_controller_class REST API controller class name. Default is 'WP_REST_Autosaves_Controller'. + * @type string|bool $revisions_rest_controller_class REST API controller class name. Default is 'WP_REST_Revisions_Controller'. + * @type bool $late_route_registration A flag to direct the REST API controllers for autosave / revisions should be registered before/after the post type controller. + * @type int $menu_position The position in the menu order the post type should appear. To work, + * $show_in_menu must be true. Default null (at the bottom). + * @type string $menu_icon The URL to the icon to be used for this menu. Pass a base64-encoded + * SVG using a data URI, which will be colored to match the color scheme + * -- this should begin with 'data:image/svg+xml;base64,'. Pass the name + * of a Dashicons helper class to use a font icon, e.g. + * 'dashicons-chart-pie'. Pass 'none' to leave div.wp-menu-image empty + * so an icon can be added via CSS. Defaults to use the posts icon. + * @type string|array $capability_type The string to use to build the read, edit, and delete capabilities. + * May be passed as an array to allow for alternative plurals when using + * this argument as a base to construct the capabilities, e.g. + * array('story', 'stories'). Default 'post'. + * @type string[] $capabilities Array of capabilities for this post type. $capability_type is used + * as a base to construct capabilities by default. + * See get_post_type_capabilities(). + * @type bool $map_meta_cap Whether to use the internal default meta capability handling. + * Default false. + * @type array $supports Core feature(s) the post type supports. Serves as an alias for calling + * add_post_type_support() directly. Core features include 'title', + * 'editor', 'comments', 'revisions', 'trackbacks', 'author', 'excerpt', + * 'page-attributes', 'thumbnail', 'custom-fields', and 'post-formats'. + * Additionally, the 'revisions' feature dictates whether the post type + * will store revisions, and the 'comments' feature dictates whether the + * comments count will show on the edit screen. A feature can also be + * specified as an array of arguments to provide additional information + * about supporting that feature. + * Example: `array( 'my_feature', array( 'field' => 'value' ) )`. + * Default is an array containing 'title' and 'editor'. + * @type callable $register_meta_box_cb Provide a callback function that sets up the meta boxes for the + * edit form. Do remove_meta_box() and add_meta_box() calls in the + * callback. Default null. + * @type string[] $taxonomies An array of taxonomy identifiers that will be registered for the + * post type. Taxonomies can be registered later with register_taxonomy() + * or register_taxonomy_for_object_type(). + * Default empty array. + * @type bool|string $has_archive Whether there should be post type archives, or if a string, the + * archive slug to use. Will generate the proper rewrite rules if + * $rewrite is enabled. Default false. + * @type bool|array $rewrite { * Triggers the handling of rewrites for this post type. To prevent rewrite, set to false. * Defaults to true, using $post_type as slug. To specify rewrite rules, an array can be * passed with any of these keys: @@ -1668,32 +1677,32 @@ function get_post_types( $args = array(), $output = 'names', $operator = 'and' ) * inherits from $permalink_epmask. If not specified and permalink_epmask * is not set, defaults to EP_PERMALINK. * } - * @type string|bool $query_var Sets the query_var key for this post type. Defaults to $post_type - * key. If false, a post type cannot be loaded at - * ?{query_var}={post_slug}. If specified as a string, the query - * ?{query_var_string}={post_slug} will be valid. - * @type bool $can_export Whether to allow this post type to be exported. Default true. - * @type bool $delete_with_user Whether to delete posts of this type when deleting a user. - * * If true, posts of this type belonging to the user will be moved - * to Trash when the user is deleted. - * * If false, posts of this type belonging to the user will *not* - * be trashed or deleted. - * * If not set (the default), posts are trashed if post type supports - * the 'author' feature. Otherwise posts are not trashed or deleted. - * Default null. - * @type array $template Array of blocks to use as the default initial state for an editor - * session. Each item should be an array containing block name and - * optional attributes. Default empty array. - * @type string|false $template_lock Whether the block template should be locked if $template is set. - * * If set to 'all', the user is unable to insert new blocks, - * move existing blocks and delete blocks. - * * If set to 'insert', the user is able to move existing blocks - * but is unable to insert new blocks and delete blocks. - * Default false. - * @type bool $_builtin FOR INTERNAL USE ONLY! True if this post type is a native or - * "built-in" post_type. Default false. - * @type string $_edit_link FOR INTERNAL USE ONLY! URL segment to use for edit link of - * this post type. Default 'post.php?post=%d'. + * @type string|bool $query_var Sets the query_var key for this post type. Defaults to $post_type + * key. If false, a post type cannot be loaded at + * ?{query_var}={post_slug}. If specified as a string, the query + * ?{query_var_string}={post_slug} will be valid. + * @type bool $can_export Whether to allow this post type to be exported. Default true. + * @type bool $delete_with_user Whether to delete posts of this type when deleting a user. + * * If true, posts of this type belonging to the user will be moved + * to Trash when the user is deleted. + * * If false, posts of this type belonging to the user will *not* + * be trashed or deleted. + * * If not set (the default), posts are trashed if post type supports + * the 'author' feature. Otherwise posts are not trashed or deleted. + * Default null. + * @type array $template Array of blocks to use as the default initial state for an editor + * session. Each item should be an array containing block name and + * optional attributes. Default empty array. + * @type string|false $template_lock Whether the block template should be locked if $template is set. + * * If set to 'all', the user is unable to insert new blocks, + * move existing blocks and delete blocks. + * * If set to 'insert', the user is able to move existing blocks + * but is unable to insert new blocks and delete blocks. + * Default false. + * @type bool $_builtin FOR INTERNAL USE ONLY! True if this post type is a native or + * "built-in" post_type. Default false. + * @type string $_edit_link FOR INTERNAL USE ONLY! URL segment to use for edit link of + * this post type. Default 'post.php?post=%d'. * } * @return WP_Post_Type|WP_Error The registered post type object on success, * WP_Error object on failure. diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 71539b084c26d..19d29d051f5fe 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -241,17 +241,23 @@ function create_initial_rest_routes() { continue; } - $controller->register_routes(); + if ( ! $post_type->late_route_registration ) { + $controller->register_routes(); + } - if ( post_type_supports( $post_type->name, 'revisions' ) ) { - $revisions_controller = new WP_REST_Revisions_Controller( $post_type->name ); + $revisions_controller = $post_type->get_revisions_rest_controller(); + if ( $revisions_controller ) { $revisions_controller->register_routes(); } - if ( 'attachment' !== $post_type->name ) { - $autosaves_controller = new WP_REST_Autosaves_Controller( $post_type->name ); + $autosaves_controller = $post_type->get_autosave_rest_controller(); + if ( $autosaves_controller ) { $autosaves_controller->register_routes(); } + + if ( $post_type->late_route_registration ) { + $controller->register_routes(); + } } // Post types. diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php index d14119133aff3..5545625df4609 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php @@ -65,8 +65,13 @@ public function __construct( $parent_post_type ) { $parent_controller = new WP_REST_Posts_Controller( $parent_post_type ); } - $this->parent_controller = $parent_controller; - $this->revisions_controller = new WP_REST_Revisions_Controller( $parent_post_type ); + $this->parent_controller = $parent_controller; + + $revisions_controller = $post_type_object->get_revisions_rest_controller(); + if ( ! $revisions_controller ) { + $revisions_controller = new WP_REST_Revisions_Controller( $parent_post_type ); + } + $this->revisions_controller = $revisions_controller; $this->rest_base = 'autosaves'; $this->parent_base = ! empty( $post_type_object->rest_base ) ? $post_type_object->rest_base : $post_type_object->name; $this->namespace = ! empty( $post_type_object->rest_namespace ) ? $post_type_object->rest_namespace : 'wp/v2'; @@ -205,11 +210,11 @@ public function create_item_permissions_check( $request ) { */ public function create_item( $request ) { - if ( ! defined( 'DOING_AUTOSAVE' ) ) { + if ( ! defined( 'WP_RUN_CORE_TESTS' ) && ! defined( 'DOING_AUTOSAVE' ) ) { define( 'DOING_AUTOSAVE', true ); } - $post = get_post( $request['id'] ); + $post = $this->get_parent( $request['id'] ); if ( is_wp_error( $post ) ) { return $post; diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-template-autosaves-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-template-autosaves-controller.php new file mode 100644 index 0000000000000..c996894a5933c --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-template-autosaves-controller.php @@ -0,0 +1,276 @@ +parent_post_type = $parent_post_type; + $post_type_object = get_post_type_object( $parent_post_type ); + $parent_controller = $post_type_object->get_rest_controller(); + + if ( ! $parent_controller ) { + $parent_controller = new WP_REST_Templates_Controller( $parent_post_type ); + } + + $this->parent_controller = $parent_controller; + + $revisions_controller = $post_type_object->get_revisions_rest_controller(); + if ( ! $revisions_controller ) { + $revisions_controller = new WP_REST_Revisions_Controller( $parent_post_type ); + } + $this->revisions_controller = $revisions_controller; + $this->rest_base = 'autosaves'; + $this->parent_base = ! empty( $post_type_object->rest_base ) ? $post_type_object->rest_base : $post_type_object->name; + $this->namespace = ! empty( $post_type_object->rest_namespace ) ? $post_type_object->rest_namespace : 'wp/v2'; + } + + /** + * Registers the routes for autosaves. + * + * @since 6.4.0 + * + * @see register_rest_route() + */ + public function register_routes() { + register_rest_route( + $this->namespace, + sprintf( + '/%s/(?P%s%s)/%s', + $this->parent_base, + /* + * Matches theme's directory: `/themes///` or `/themes//`. + * Excludes invalid directory name characters: `/:<>*?"|`. + */ + '([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)', + // Matches the template name. + '[\/\w%-]+', + $this->rest_base + ), + array( + 'args' => array( + 'id' => array( + 'description' => __( 'The id of a template' ), + 'type' => 'string', + 'sanitize_callback' => array( $this->parent_controller, '_sanitize_template_id' ), + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_item' ), + 'permission_callback' => array( $this, 'create_item_permissions_check' ), + 'args' => $this->parent_controller->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + sprintf( + '/%s/(?P%s%s)/%s/%s', + $this->parent_base, + /* + * Matches theme's directory: `/themes///` or `/themes//`. + * Excludes invalid directory name characters: `/:<>*?"|`. + */ + '([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)', + // Matches the template name. + '[\/\w%-]+', + $this->rest_base, + '(?P[\d]+)' + ), + array( + 'args' => array( + 'parent' => array( + 'description' => __( 'The id of a template' ), + 'type' => 'string', + 'sanitize_callback' => array( $this->parent_controller, '_sanitize_template_id' ), + ), + 'id' => array( + 'description' => __( 'The ID for the autosave.' ), + 'type' => 'integer', + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this->revisions_controller, 'get_item_permissions_check' ), + 'args' => array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Prepares the item for the REST response. + * + * @since 6.4.0 + * + * @param WP_Post $item Post revision object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $item, $request ) { + $template = _build_block_template_result_from_post( $item ); + $response = $this->parent_controller->prepare_item_for_response( $template, $request ); + + $fields = $this->get_fields_for_response( $request ); + $data = $response->get_data(); + + if ( in_array( 'parent', $fields, true ) ) { + $data['parent'] = (int) $item->post_parent; + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $data = $this->filter_response_by_context( $data, $context ); + + // Wrap the data in a response object. + $response = new WP_REST_Response( $data ); + + if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { + $links = $this->prepare_links( $template ); + $response->add_links( $links ); + } + + return $response; + } + + /** + * Gets the autosave, if the ID is valid. + * + * @since 6.4.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_Post|WP_Error Autosave post object if ID is valid, WP_Error otherwise. + */ + public function get_item( $request ) { + $parent = $this->get_parent( $request['parent'] ); + if ( is_wp_error( $parent ) ) { + return $parent; + } + + $autosave = wp_get_post_autosave( $parent->ID ); + + if ( ! $autosave ) { + return new WP_Error( + 'rest_post_no_autosave', + __( 'There is no autosave revision for this template.' ), + array( 'status' => 404 ) + ); + } + + $response = $this->prepare_item_for_response( $autosave, $request ); + return $response; + } + + /** + * Get the parent post. + * + * @since 6.4.0 + * + * @param int $parent_id Supplied ID. + * @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise. + */ + protected function get_parent( $parent_id ) { + return $this->revisions_controller->get_parent( $parent_id ); + } + + /** + * Prepares links for the request. + * + * @since 6.4.0 + * + * @param WP_Block_Template $template Template. + * @return array Links for the given post. + */ + protected function prepare_links( $template ) { + $links = array( + 'self' => array( + 'href' => rest_url( sprintf( '/%s/%s/%s/%s/%d', $this->namespace, $this->parent_base, $template->id, $this->rest_base, $template->wp_id ) ), + ), + 'parent' => array( + 'href' => rest_url( sprintf( '/%s/%s/%s', $this->namespace, $this->parent_base, $template->id ) ), + ), + ); + + return $links; + } + + /** + * Retrieves the autosave's schema, conforming to JSON Schema. + * + * @since 6.4.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $this->schema = $this->revisions_controller->get_item_schema(); + + return $this->add_additional_fields_schema( $this->schema ); + } +} diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-template-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-template-revisions-controller.php new file mode 100644 index 0000000000000..8d32ecb7c0904 --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-template-revisions-controller.php @@ -0,0 +1,297 @@ +parent_post_type = $parent_post_type; + $post_type_object = get_post_type_object( $parent_post_type ); + $parent_controller = $post_type_object->get_rest_controller(); + + if ( ! $parent_controller ) { + $parent_controller = new WP_REST_Templates_Controller( $parent_post_type ); + } + + $this->parent_controller = $parent_controller; + $this->rest_base = 'revisions'; + $this->parent_base = ! empty( $post_type_object->rest_base ) ? $post_type_object->rest_base : $post_type_object->name; + $this->namespace = ! empty( $post_type_object->rest_namespace ) ? $post_type_object->rest_namespace : 'wp/v2'; + } + + /** + * Registers the routes for revisions based on post types supporting revisions. + * + * @since 6.4.0 + * + * @see register_rest_route() + */ + public function register_routes() { + + register_rest_route( + $this->namespace, + sprintf( + '/%s/(?P%s%s)/%s', + $this->parent_base, + /* + * Matches theme's directory: `/themes///` or `/themes//`. + * Excludes invalid directory name characters: `/:<>*?"|`. + */ + '([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)', + // Matches the template name. + '[\/\w%-]+', + $this->rest_base + ), + array( + 'args' => array( + 'parent' => array( + 'description' => __( 'The id of a template' ), + 'type' => 'string', + 'sanitize_callback' => array( $this->parent_controller, '_sanitize_template_id' ), + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + sprintf( + '/%s/(?P%s%s)/%s/%s', + $this->parent_base, + /* + * Matches theme's directory: `/themes///` or `/themes//`. + * Excludes invalid directory name characters: `/:<>*?"|`. + */ + '([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)', + // Matches the template name. + '[\/\w%-]+', + $this->rest_base, + '(?P[\d]+)' + ), + array( + 'args' => array( + 'parent' => array( + 'description' => __( 'The id of a template' ), + 'type' => 'string', + 'sanitize_callback' => array( $this->parent_controller, '_sanitize_template_id' ), + ), + 'id' => array( + 'description' => __( 'Unique identifier for the revision.' ), + 'type' => 'integer', + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + ), + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_item' ), + 'permission_callback' => array( $this, 'delete_item_permissions_check' ), + 'args' => array( + 'force' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'Required to be true, as revisions do not support trashing.' ), + ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Gets the parent post, if the ID is valid. + * + * @since 6.4.0 + * + * @param int $parent_post_id Supplied ID. + * @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise. + */ + protected function get_parent( $parent_post_id ) { + $template = get_block_template( $parent_post_id, $this->parent_post_type ); + + if ( ! $template ) { + return new WP_Error( + 'rest_post_invalid_parent', + __( 'Invalid template parent ID.' ), + array( 'status' => 404 ) + ); + } + + return get_post( $template->wp_id ); + } + + /** + * Prepares the item for the REST response. + * + * @since 6.4.0 + * + * @param WP_Post $item Post revision object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $item, $request ) { + $template = _build_block_template_result_from_post( $item ); + $response = $this->parent_controller->prepare_item_for_response( $template, $request ); + + $fields = $this->get_fields_for_response( $request ); + $data = $response->get_data(); + + if ( in_array( 'parent', $fields, true ) ) { + $data['parent'] = (int) $item->post_parent; + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; + $data = $this->filter_response_by_context( $data, $context ); + + // Wrap the data in a response object. + $response = new WP_REST_Response( $data ); + + if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { + $links = $this->prepare_links( $template ); + $response->add_links( $links ); + } + + return $response; + } + + /** + * Checks if a given request has access to delete a revision. + * + * @since 6.4.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has access to delete the item, WP_Error object otherwise. + */ + public function delete_item_permissions_check( $request ) { + $parent = $this->get_parent( $request['parent'] ); + if ( is_wp_error( $parent ) ) { + return $parent; + } + + if ( ! current_user_can( 'delete_post', $parent->ID ) ) { + return new WP_Error( + 'rest_cannot_delete', + __( 'Sorry, you are not allowed to delete revisions of this post.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + $revision = $this->get_revision( $request['id'] ); + if ( is_wp_error( $revision ) ) { + return $revision; + } + + if ( ! current_user_can( 'edit_theme_options' ) ) { + return new WP_Error( + 'rest_cannot_delete', + __( 'Sorry, you are not allowed to delete this revision.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Prepares links for the request. + * + * @since 6.4.0 + * + * @param WP_Block_Template $template Template. + * @return array Links for the given post. + */ + protected function prepare_links( $template ) { + $links = array( + 'self' => array( + 'href' => rest_url( sprintf( '/%s/%s/%s/%s/%d', $this->namespace, $this->parent_base, $template->id, $this->rest_base, $template->wp_id ) ), + ), + 'parent' => array( + 'href' => rest_url( sprintf( '/%s/%s/%s', $this->namespace, $this->parent_base, $template->id ) ), + ), + ); + + return $links; + } + + /** + * Retrieves the item's schema, conforming to JSON Schema. + * + * @since 6.4.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $schema = $this->parent_controller->get_item_schema(); + + $schema['properties']['parent'] = array( + 'description' => __( 'The ID for the parent of the revision.' ), + 'type' => 'integer', + 'context' => array( 'view', 'edit', 'embed' ), + ); + + $this->schema = $schema; + + return $this->add_additional_fields_schema( $this->schema ); + } +} diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php index e9904ff234912..53f8faa75595b 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php @@ -760,7 +760,7 @@ public function prepare_item_for_response( $item, $request ) { protected function prepare_links( $id ) { $links = array( 'self' => array( - 'href' => rest_url( rest_get_route_for_post( $id ) ), + 'href' => rest_url( sprintf( '/%s/%s/%s', $this->namespace, $this->rest_base, $id ) ), ), 'collection' => array( 'href' => rest_url( rest_get_route_for_post_type_items( $this->post_type ) ), @@ -770,6 +770,27 @@ protected function prepare_links( $id ) { ), ); + if ( post_type_supports( $this->post_type, 'revisions' ) ) { + $template = get_block_template( $id, $this->post_type ); + if ( $template instanceof WP_Block_Template && ! empty( $template->wp_id ) ) { + $revisions = wp_get_latest_revision_id_and_total_count( $template->wp_id ); + $revisions_count = ! is_wp_error( $revisions ) ? $revisions['count'] : 0; + $revisions_base = sprintf( '/%s/%s/%s/revisions', $this->namespace, $this->rest_base, $id ); + + $links['version-history'] = array( + 'href' => rest_url( $revisions_base ), + 'count' => $revisions_count, + ); + + if ( $revisions_count > 0 ) { + $links['predecessor-version'] = array( + 'href' => rest_url( $revisions_base . '/' . $revisions['latest_id'] ), + 'id' => $revisions['latest_id'], + ); + } + } + } + return $links; } diff --git a/src/wp-settings.php b/src/wp-settings.php index 5b1e1205d2719..38b03ecf7268f 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -273,7 +273,9 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-post-types-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-post-statuses-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-revisions-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-template-revisions-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-autosaves-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-template-autosaves-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-taxonomies-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-terms-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-menu-items-controller.php'; diff --git a/tests/phpunit/tests/post/wpPostType.php b/tests/phpunit/tests/post/wpPostType.php index 8443c804ad868..2a8ad42f0a2ca 100644 --- a/tests/phpunit/tests/post/wpPostType.php +++ b/tests/phpunit/tests/post/wpPostType.php @@ -229,4 +229,214 @@ public function test_applies_registration_args_filters() { $this->assertSame( 3, $action->get_call_count() ); } + + /** + * @ticket 56922 + * + * @dataProvider data_should_have_correct_custom_revisions_and_autosaves_controllers_properties + * + * @covers WP_Post_Type::set_props + * + * @param string $property_name Property name. + * @param string $property_value Property value. + * @param string|bool $expected_property_value Expected property value. + */ + public function test_should_have_correct_custom_revisions_and_autosaves_controllers_properties( $property_name, $property_value, $expected_property_value ) { + $properties = null === $property_value ? array() : array( $property_name => $property_value ); + + $post_type = new WP_Post_Type( 'test_post_type', $properties ); + + $this->assertObjectHasProperty( $property_name, $post_type, "The WP_Post_Type object does not have the expected {$property_name} property." ); + $this->assertSame( + $expected_property_value, + $post_type->$property_name, + sprintf( 'Expected the property "%s" to have the %s value.', $property_name, var_export( $expected_property_value, true ) ) + ); + } + + /** + * Data provider for test_should_allow_to_set_custom_revisions_and_autosaves_controllers_properties. + * + * @return array[] Arguments { + * @type string $property_name Property name. + * @type string $property_value Property value. + * @type string|bool $expected_property_value Expected property value. + * } + */ + public function data_should_have_correct_custom_revisions_and_autosaves_controllers_properties() { + return array( + 'autosave_rest_controller_class property' => array( + 'autosave_rest_controller_class', + 'My_Custom_Template_Autosaves_Controller', + 'My_Custom_Template_Autosaves_Controller', + ), + 'autosave_rest_controller_class property (null value)' => array( + 'autosave_rest_controller_class', + null, + false, + ), + 'revisions_rest_controller_class property' => array( + 'revisions_rest_controller_class', + 'My_Custom_Template_Revisions_Controller', + 'My_Custom_Template_Revisions_Controller', + ), + 'revisions_rest_controller_class property (null value)' => array( + 'revisions_rest_controller_class', + null, + false, + ), + ); + } + + /** + * @ticket 56922 + * + * @covers WP_Post_Type::get_revisions_rest_controller + * + * @dataProvider data_get_revisions_rest_controller_should_return_correct_values + * + * @param bool $show_in_rest Enables "show_in_rest" support. + * @param bool $supports_revisions Enables revisions support. + * @param string|bool $revisions_rest_controller_class Custom revisions REST controller class. + * @param string|null $expected_value Expected value. + */ + public function test_get_revisions_rest_controller_should_return_correct_values( $show_in_rest, $supports_revisions, $revisions_rest_controller_class, $expected_value ) { + $post_type = 'test_post_type'; + $properties = array( + 'show_in_rest' => $show_in_rest, + 'supports' => $supports_revisions ? array( 'revisions' ) : array(), + 'revisions_rest_controller_class' => $revisions_rest_controller_class, + ); + register_post_type( $post_type, $properties ); + $post_type = get_post_type_object( $post_type ); + + $controller = $post_type->get_revisions_rest_controller(); + if ( $expected_value ) { + $this->assertInstanceOf( $expected_value, $controller ); + + return; + } + + $this->assertSame( $expected_value, $controller ); + } + + /** + * Data provider for test_get_revisions_rest_controller_should_return_correct_values. + * + * @return array[] Arguments { + * @type bool $show_in_rest Enables "show_in_rest" support. + * @type bool $supports_revisions Enables revisions support. + * @type string|bool $revisions_rest_controller_class Custom revisions REST controller class. + * @type string|null $expected_value Expected value. + * } + */ + public function data_get_revisions_rest_controller_should_return_correct_values() { + return array( + 'disable show_in_rest' => array( + false, + false, + false, + null, + ), + 'disable revisions support' => array( + true, + false, + false, + null, + ), + 'default rest revisions controller' => array( + true, + true, + false, + WP_REST_Revisions_Controller::class, + ), + 'incorrect rest revisions controller' => array( + true, + true, + stdClass::class, + null, + ), + 'correct rest revisions controller' => array( + true, + true, + WP_REST_Template_Revisions_Controller::class, + WP_REST_Template_Revisions_Controller::class, + ), + ); + } + + /** + * @ticket 56922 + * + * @covers WP_Post_Type::get_autosave_rest_controller + * + * @dataProvider data_get_autosave_rest_controller_should_return_correct_values + * + * @param bool $show_in_rest Enables "show_in_rest" support. + * @param string $post_type Post type. + * @param string|bool $autosave_rest_controller_class Custom autosave REST controller class. + * @param string|null $expected_value Expected value. + */ + public function test_get_autosave_rest_controller_should_return_correct_values( $show_in_rest, $post_type, $autosave_rest_controller_class, $expected_value ) { + $properties = array( + 'show_in_rest' => $show_in_rest, + 'autosave_rest_controller_class' => $autosave_rest_controller_class, + ); + register_post_type( $post_type, $properties ); + $post_type = get_post_type_object( $post_type ); + + $controller = $post_type->get_autosave_rest_controller(); + if ( $expected_value ) { + $this->assertInstanceOf( $expected_value, $controller ); + + return; + } + + $this->assertSame( $expected_value, $controller ); + } + + /** + * Data provider for test_get_autosave_rest_controller_should_return_correct_values. + * + * @return array[] Arguments { + * @type bool $show_in_rest Enables "show_in_rest" support. + * @type string $post_type Post type. + * @type string|bool $autosave_rest_controller_class Custom autosave REST controller class. + * @type string|null $expected_value Expected value. + * } + */ + public function data_get_autosave_rest_controller_should_return_correct_values() { + return array( + 'disable show_in_rest' => array( + false, + 'attachment', + false, + null, + ), + 'invalid post type' => array( + true, + 'attachment', + false, + null, + ), + 'default rest autosave controller' => array( + true, + 'test_post_type', + false, + WP_REST_Autosaves_Controller::class, + ), + 'incorrect rest autosave controller' => array( + true, + 'test_post_type', + stdClass::class, + null, + ), + 'correct rest autosave controller' => array( + true, + 'test_post_type', + WP_REST_Template_Autosaves_Controller::class, + WP_REST_Template_Autosaves_Controller::class, + ), + ); + } } diff --git a/tests/phpunit/tests/rest-api/rest-schema-setup.php b/tests/phpunit/tests/rest-api/rest-schema-setup.php index dfd98877d8ed3..c53f887bc822c 100644 --- a/tests/phpunit/tests/rest-api/rest-schema-setup.php +++ b/tests/phpunit/tests/rest-api/rest-schema-setup.php @@ -144,18 +144,18 @@ public function test_expected_routes_in_schema() { '/wp/v2/block-types/(?P[a-zA-Z0-9_-]+)/(?P[a-zA-Z0-9_-]+)', '/wp/v2/settings', '/wp/v2/template-parts', - '/wp/v2/template-parts/(?P[\d]+)/autosaves', '/wp/v2/template-parts/(?P([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)[\/\w%-]+)', - '/wp/v2/template-parts/(?P[\d]+)/autosaves/(?P[\d]+)', - '/wp/v2/template-parts/(?P[\d]+)/revisions', - '/wp/v2/template-parts/(?P[\d]+)/revisions/(?P[\d]+)', + '/wp/v2/template-parts/(?P([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)[\/\w%-]+)/autosaves', + '/wp/v2/template-parts/(?P([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)[\/\w%-]+)/autosaves/(?P[\d]+)', + '/wp/v2/template-parts/(?P([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)[\/\w%-]+)/revisions', + '/wp/v2/template-parts/(?P([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)[\/\w%-]+)/revisions/(?P[\d]+)', '/wp/v2/template-parts/lookup', '/wp/v2/templates', - '/wp/v2/templates/(?P[\d]+)/autosaves', '/wp/v2/templates/(?P([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)[\/\w%-]+)', - '/wp/v2/templates/(?P[\d]+)/autosaves/(?P[\d]+)', - '/wp/v2/templates/(?P[\d]+)/revisions', - '/wp/v2/templates/(?P[\d]+)/revisions/(?P[\d]+)', + '/wp/v2/templates/(?P([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)[\/\w%-]+)/autosaves', + '/wp/v2/templates/(?P([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)[\/\w%-]+)/autosaves/(?P[\d]+)', + '/wp/v2/templates/(?P([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)[\/\w%-]+)/revisions', + '/wp/v2/templates/(?P([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)[\/\w%-]+)/revisions/(?P[\d]+)', '/wp/v2/templates/lookup', '/wp/v2/themes', '/wp/v2/themes/(?P[^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)', diff --git a/tests/phpunit/tests/rest-api/wpRestTemplateAutosavesController.php b/tests/phpunit/tests/rest-api/wpRestTemplateAutosavesController.php new file mode 100644 index 0000000000000..9452a188433be --- /dev/null +++ b/tests/phpunit/tests/rest-api/wpRestTemplateAutosavesController.php @@ -0,0 +1,413 @@ +user->create( + array( + 'role' => 'contributor', + ) + ); + + self::$admin_id = $factory->user->create( + array( + 'role' => 'administrator', + ) + ); + wp_set_current_user( self::$admin_id ); + + // Set up template post. + self::$template_post = $factory->post->create_and_get( + array( + 'post_type' => self::PARENT_POST_TYPE, + 'post_name' => self::TEMPLATE_NAME, + 'post_title' => 'My Template', + 'post_content' => 'Content', + 'post_excerpt' => 'Description of my template', + 'tax_input' => array( + 'wp_theme' => array( + self::TEST_THEME, + ), + ), + ) + ); + wp_set_post_terms( self::$template_post->ID, self::TEST_THEME, 'wp_theme' ); + } + + /** + * @covers WP_REST_Template_Autosaves_Controller::register_routes + * @ticket 56922 + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( + '/wp/v2/templates/(?P([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)[\/\w%-]+)/autosaves', + $routes, + 'Template autosaves route does not exist.' + ); + $this->assertArrayHasKey( + '/wp/v2/templates/(?P([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)[\/\w%-]+)/autosaves/(?P[\d]+)', + $routes, + 'Single template autosave based on the given ID route does not exist.' + ); + $this->assertArrayHasKey( + '/wp/v2/template-parts/(?P([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)[\/\w%-]+)/autosaves', + $routes, + 'Template part autosaves route does not exist.' + ); + $this->assertArrayHasKey( + '/wp/v2/template-parts/(?P([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)[\/\w%-]+)/autosaves/(?P[\d]+)', + $routes, + 'Single template part autosave based on the given ID route does not exist.' + ); + } + + /** + * @covers WP_REST_Template_Autosaves_Controller::get_context_param + * @ticket 56922 + */ + public function test_context_param() { + // Collection. + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/templates/' . self::TEST_THEME . '/' . self::TEMPLATE_NAME . '/autosaves' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + // Collection. + $this->assertCount( + 2, + $data['endpoints'], + 'Failed to assert that the collection autosave endpoints count is 2.' + ); + $this->assertSame( + 'view', + $data['endpoints'][0]['args']['context']['default'], + 'Failed to assert that the default context for the GET collection endpoint is "view".' + ); + $this->assertSame( + array( 'view', 'embed', 'edit' ), + $data['endpoints'][0]['args']['context']['enum'], + "Failed to assert that the enum values for the GET collection endpoint are 'view', 'embed', and 'edit'." + ); + + // Single. + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/templates/' . self::TEST_THEME . '/' . self::TEMPLATE_NAME . '/autosaves/1' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertCount( + 1, + $data['endpoints'], + 'Failed to assert that the single autosave endpoints count is 1.' + ); + $this->assertSame( + 'view', + $data['endpoints'][0]['args']['context']['default'], + 'Failed to assert that the default context for the single autosave endpoint is "view".' + ); + $this->assertSame( + array( 'view', 'embed', 'edit' ), + $data['endpoints'][0]['args']['context']['enum'], + "Failed to assert that the enum values for the single autosave endpoint are 'view', 'embed', and 'edit'." + ); + } + + /** + * @covers WP_REST_Template_Autosaves_Controller::get_items + * @ticket 56922 + */ + public function test_get_items() { + wp_set_current_user( self::$admin_id ); + $autosave_post_id = wp_create_post_autosave( + array( + 'post_content' => 'Autosave content.', + 'post_ID' => self::$template_post->ID, + 'post_type' => self::PARENT_POST_TYPE, + ) + ); + + $request = new WP_REST_Request( + 'GET', + '/wp/v2/templates/' . self::TEST_THEME . '/' . self::TEMPLATE_NAME . '/autosaves' + ); + $response = rest_get_server()->dispatch( $request ); + $autosaves = $response->get_data(); + + $this->assertCount( + 1, + $autosaves, + 'Failed asserting that the response data contains exactly 1 item.' + ); + + $this->assertSame( + $autosave_post_id, + $autosaves[0]['wp_id'], + 'Failed asserting that the ID of the autosave matches the expected autosave post ID.' + ); + $this->assertSame( + self::$template_post->ID, + $autosaves[0]['parent'], + 'Failed asserting that the parent ID of the autosave matches the template post ID.' + ); + $this->assertSame( + 'Autosave content.', + $autosaves[0]['content']['raw'], + 'Failed asserting that the content of the autosave is "Autosave content.".' + ); + } + + /** + * @covers WP_REST_Template_Autosaves_Controller::get_item + * @ticket 56922 + */ + public function test_get_item() { + wp_set_current_user( self::$admin_id ); + + $autosave_post_id = wp_create_post_autosave( + array( + 'post_content' => 'Autosave content.', + 'post_ID' => self::$template_post->ID, + 'post_type' => self::PARENT_POST_TYPE, + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/templates/' . self::TEST_THEME . '/' . self::TEMPLATE_NAME . '/autosaves/' . $autosave_post_id ); + $response = rest_get_server()->dispatch( $request ); + $autosave = $response->get_data(); + + $this->assertIsArray( $autosave, 'Failed asserting that the autosave is an array.' ); + $this->assertSame( + $autosave_post_id, + $autosave['wp_id'], + "Failed asserting that the autosave id is the same as $autosave_post_id." + ); + $this->assertSame( + self::$template_post->ID, + $autosave['parent'], + sprintf( + 'Failed asserting that the parent id of the autosave is the same as %s.', + self::$template_post->ID + ) + ); + } + + /** + * @covers WP_REST_Template_Autosaves_Controller::prepare_item_for_response + * @ticket 56922 + */ + public function test_prepare_item() { + wp_set_current_user( self::$admin_id ); + $autosave_post_id = wp_create_post_autosave( + array( + 'post_content' => 'Autosave content.', + 'post_ID' => self::$template_post->ID, + 'post_type' => self::PARENT_POST_TYPE, + ) + ); + $autosave_db_post = get_post( $autosave_post_id ); + $template_id = self::TEST_THEME . '//' . self::TEMPLATE_NAME; + $request = new WP_REST_Request( 'GET', '/wp/v2/templates/' . $template_id . '/autosaves/' . $autosave_db_post->ID ); + $controller = new WP_REST_Template_Autosaves_Controller( self::PARENT_POST_TYPE ); + $response = $controller->prepare_item_for_response( $autosave_db_post, $request ); + $this->assertInstanceOf( + WP_REST_Response::class, + $response, + 'Failed asserting that the response object is an instance of WP_REST_Response.' + ); + + $autosave = $response->get_data(); + $this->assertIsArray( $autosave, 'Failed asserting that the autosave is an array.' ); + $this->assertSame( + $autosave_db_post->ID, + $autosave['wp_id'], + "Failed asserting that the autosave id is the same as $autosave_db_post->ID." + ); + $this->assertSame( + self::$template_post->ID, + $autosave['parent'], + sprintf( + 'Failed asserting that the parent id of the autosave is the same as %s.', + self::$template_post->ID + ) + ); + + $links = $response->get_links(); + $this->assertIsArray( $links, 'Failed asserting that the links are an array.' ); + + $this->assertStringEndsWith( + $template_id . '/autosaves/' . $autosave_db_post->ID, + $links['self'][0]['href'], + "Failed asserting that the self link ends with $template_id . '/autosaves/' . $autosave_db_post->ID." + ); + + $this->assertStringEndsWith( + $template_id, + $links['parent'][0]['href'], + "Failed asserting that the parent link ends with %$template_id." + ); + } + + /** + * @covers WP_REST_Template_Autosaves_Controller::get_item_schema + * @ticket 56922 + */ + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/templates/' . self::TEST_THEME . '/' . self::TEMPLATE_NAME . '/autosaves' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $properties = $data['schema']['properties']; + + $this->assertCount( 16, $properties ); + $this->assertArrayHasKey( 'id', $properties, 'ID key should exist in properties.' ); + $this->assertArrayHasKey( 'slug', $properties, 'Slug key should exist in properties.' ); + $this->assertArrayHasKey( 'theme', $properties, 'Theme key should exist in properties.' ); + $this->assertArrayHasKey( 'source', $properties, 'Source key should exist in properties.' ); + $this->assertArrayHasKey( 'origin', $properties, 'Origin key should exist in properties.' ); + $this->assertArrayHasKey( 'content', $properties, 'Content key should exist in properties.' ); + $this->assertArrayHasKey( 'title', $properties, 'Title key should exist in properties.' ); + $this->assertArrayHasKey( 'description', $properties, 'description key should exist in properties.' ); + $this->assertArrayHasKey( 'status', $properties, 'status key should exist in properties.' ); + $this->assertArrayHasKey( 'wp_id', $properties, 'wp_id key should exist in properties.' ); + $this->assertArrayHasKey( 'has_theme_file', $properties, 'has_theme_file key should exist in properties.' ); + $this->assertArrayHasKey( 'author', $properties, 'author key should exist in properties.' ); + $this->assertArrayHasKey( 'modified', $properties, 'modified key should exist in properties.' ); + $this->assertArrayHasKey( 'is_custom', $properties, 'is_custom key should exist in properties.' ); + $this->assertArrayHasKey( 'parent', $properties, 'Parent key should exist in properties.' ); + } + + /** + * @covers WP_REST_Template_Autosaves_Controller::create_item + * @ticket 56922 + */ + public function test_create_item() { + wp_set_current_user( self::$admin_id ); + + $template_id = self::TEST_THEME . '/' . self::TEMPLATE_NAME; + $request = new WP_REST_Request( 'POST', '/wp/v2/templates/' . $template_id . '/autosaves' ); + $request->add_header( 'Content-Type', 'application/x-www-form-urlencoded' ); + + $request_parameters = array( + 'title' => 'Post Title', + 'content' => 'Post content', + 'excerpt' => 'Post excerpt', + 'name' => 'test', + 'id' => $template_id, + ); + + $request->set_body_params( $request_parameters ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertNotWPError( $response, 'The response from this request should not return a WP_Error object' ); + $response = rest_ensure_response( $response ); + $data = $response->get_data(); + + $this->assertArrayHasKey( 'content', $data, 'Response should contain a key called content' ); + $this->assertSame( $request_parameters['content'], $data['content']['raw'], 'Response data should match for field content' ); + + $this->assertArrayHasKey( 'title', $data, 'Response should contain a key called title' ); + $this->assertSame( $request_parameters['title'], $data['title']['raw'], 'Response data should match for field title' ); + } + + /** + * @covers WP_REST_Template_Autosaves_Controller::delete_item + * @ticket 56922 + */ + public function test_create_item_incorrect_permission() { + wp_set_current_user( self::$contributor_id ); + $template_id = self::TEST_THEME . '/' . self::TEMPLATE_NAME; + $request = new WP_REST_Request( 'POST', '/wp/v2/templates/' . $template_id . '/autosaves' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_manage_templates', $response, WP_Http::FORBIDDEN ); + } + + /** + * @covers WP_REST_Template_Autosaves_Controller::delete_item + * @ticket 56922 + */ + public function test_create_item_no_permission() { + wp_set_current_user( 0 ); + $template_id = self::TEST_THEME . '/' . self::TEMPLATE_NAME; + $request = new WP_REST_Request( 'POST', '/wp/v2/templates/' . $template_id . '/autosaves' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_manage_templates', $response, WP_Http::UNAUTHORIZED ); + } + + /** + * @coversNothing + * @ticket 56922 + */ + public function test_update_item() { + $this->markTestSkipped( + sprintf( + "The '%s' controller doesn't currently support the ability to update template autosaves.", + WP_REST_Template_Autosaves_Controller::class + ) + ); + } + + /** + * @coversNothing + * @ticket 56922 + */ + public function test_delete_item() { + $this->markTestSkipped( + sprintf( + "The '%s' controller doesn't currently support the ability to delete template autosaves.", + WP_REST_Template_Autosaves_Controller::class + ) + ); + } +} diff --git a/tests/phpunit/tests/rest-api/wpRestTemplateRevisionsController.php b/tests/phpunit/tests/rest-api/wpRestTemplateRevisionsController.php new file mode 100644 index 0000000000000..91370b21c726b --- /dev/null +++ b/tests/phpunit/tests/rest-api/wpRestTemplateRevisionsController.php @@ -0,0 +1,511 @@ +user->create( + array( + 'role' => 'administrator', + ) + ); + wp_set_current_user( self::$admin_id ); + + self::$contributor_id = $factory->user->create( + array( + 'role' => 'contributor', + ) + ); + + // Set up template post. + self::$template_post = $factory->post->create_and_get( + array( + 'post_type' => self::PARENT_POST_TYPE, + 'post_name' => self::TEMPLATE_NAME, + 'post_title' => 'My Template', + 'post_content' => 'Content', + 'post_excerpt' => 'Description of my template', + 'tax_input' => array( + 'wp_theme' => array( + self::TEST_THEME, + ), + ), + ) + ); + wp_set_post_terms( self::$template_post->ID, self::TEST_THEME, 'wp_theme' ); + + // Update post to create a new revisions. + self::$revisions[] = _wp_put_post_revision( + array( + 'ID' => self::$template_post->ID, + 'post_content' => 'Content revision #2', + ) + ); + + // Update post to create a new revisions. + self::$revisions[] = _wp_put_post_revision( + array( + 'ID' => self::$template_post->ID, + 'post_content' => 'Content revision #3', + ) + ); + + // Update post to create a new revisions. + self::$revisions[] = _wp_put_post_revision( + array( + 'ID' => self::$template_post->ID, + 'post_content' => 'Content revision #4', + ) + ); + + // Update post to create a new revisions. + self::$revisions[] = _wp_put_post_revision( + array( + 'ID' => self::$template_post->ID, + 'post_content' => 'Content revision #5', + ) + ); + } + + /** + * Remove revisions when tests are complete. + */ + public static function wpTearDownAfterClass() { + // Also deletes revisions. + foreach ( self::$revisions as $revision ) { + wp_delete_post( $revision, true ); + } + } + + /** + * @covers WP_REST_Template_Revisions_Controller::register_routes + * @ticket 56922 + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( + '/wp/v2/templates/(?P([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)[\/\w%-]+)/revisions', + $routes, + 'Template revisions route does not exist.' + ); + $this->assertArrayHasKey( + '/wp/v2/templates/(?P([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)[\/\w%-]+)/revisions/(?P[\d]+)', + $routes, + 'Single template revision based on the given ID route does not exist.' + ); + $this->assertArrayHasKey( + '/wp/v2/template-parts/(?P([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)[\/\w%-]+)/revisions', + $routes, + 'Template part revisions route does not exist.' + ); + $this->assertArrayHasKey( + '/wp/v2/template-parts/(?P([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)[\/\w%-]+)/revisions/(?P[\d]+)', + $routes, + 'Single template part revision based on the given ID route does not exist.' + ); + } + + /** + * @covers WP_REST_Template_Revisions_Controller::get_context_param + * @ticket 56922 + */ + public function test_context_param() { + // Collection. + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/templates/' . self::TEST_THEME . '/' . self::TEMPLATE_NAME . '/revisions' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertSame( + 'view', + $data['endpoints'][0]['args']['context']['default'], + 'Failed to assert that the default context for the collection endpoint is "view".' + ); + $this->assertSame( + array( 'view', 'embed', 'edit' ), + $data['endpoints'][0]['args']['context']['enum'], + 'Failed to assert correct enum values for the collection endpoint.' + ); + + // Single. + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/templates/' . self::TEST_THEME . '/' . self::TEMPLATE_NAME . '/revisions/1' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertCount( + 2, + $data['endpoints'], + 'Failed to assert that the single revision endpoint count is 2.' + ); + $this->assertSame( + 'view', + $data['endpoints'][0]['args']['context']['default'], + 'Failed to assert that the default context for the single revision endpoint is "view".' + ); + $this->assertSame( + array( 'view', 'embed', 'edit' ), + $data['endpoints'][0]['args']['context']['enum'], + 'Failed to assert correct enum values for the single revision endpoint.' + ); + } + + /** + * @covers WP_REST_Template_Revisions_Controller::get_items + * @ticket 56922 + */ + public function test_get_items() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( + 'GET', + '/wp/v2/templates/' . self::TEST_THEME . '/' . self::TEMPLATE_NAME . '/revisions' + ); + $response = rest_get_server()->dispatch( $request ); + $revisions = $response->get_data(); + + $this->assertCount( + 4, + $revisions, + 'Failed asserting that the response data contains exactly 4 items.' + ); + + $this->assertSame( + self::$template_post->ID, + $revisions[0]['parent'], + 'Failed asserting that the parent ID of the revision matches the template post ID.' + ); + $this->assertSame( + 'Content revision #5', + $revisions[0]['content']['raw'], + 'Failed asserting that the content of the revision is "Content revision #5".' + ); + + $this->assertSame( + self::$template_post->ID, + $revisions[1]['parent'], + 'Failed asserting that the parent ID of the revision matches the template post ID.' + ); + $this->assertSame( + 'Content revision #4', + $revisions[1]['content']['raw'], + 'Failed asserting that the content of the revision is "Content revision #4".' + ); + + $this->assertSame( + self::$template_post->ID, + $revisions[2]['parent'], + 'Failed asserting that the parent ID of the revision matches the template post ID.' + ); + $this->assertSame( + 'Content revision #3', + $revisions[2]['content']['raw'], + 'Failed asserting that the content of the revision is "Content revision #3".' + ); + + $this->assertSame( + self::$template_post->ID, + $revisions[3]['parent'], + 'Failed asserting that the parent ID of the revision matches the template post ID.' + ); + $this->assertSame( + 'Content revision #2', + $revisions[3]['content']['raw'], + 'Failed asserting that the content of the revision is "Content revision #2".' + ); + } + + + /** + * @covers WP_REST_Template_Revisions_Controller::get_items_permissions_check + * @ticket 56922 + */ + public function test_get_items_endpoint_should_return_unauthorized_https_status_code_for_unauthorized_request() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/templates/' . self::TEST_THEME . '/' . self::TEMPLATE_NAME . '/revisions' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, WP_Http::UNAUTHORIZED ); + } + + /** + * @covers WP_REST_Template_Revisions_Controller::get_items_permissions_check + * @ticket 56922 + */ + public function test_get_items_endpoint_should_return_forbidden_https_status_code_for_users_with_insufficient_permissions() { + wp_set_current_user( self::$contributor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/templates/' . self::TEST_THEME . '/' . self::TEMPLATE_NAME . '/revisions' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, WP_Http::FORBIDDEN ); + } + + /** + * @covers WP_REST_Template_Revisions_Controller::get_item + * @ticket 56922 + */ + public function test_get_item() { + wp_set_current_user( self::$admin_id ); + + $revisions = wp_get_post_revisions( self::$template_post, array( 'fields' => 'ids' ) ); + $revision_id = array_shift( $revisions ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/templates/' . self::TEST_THEME . '/' . self::TEMPLATE_NAME . '/revisions/' . $revision_id ); + $response = rest_get_server()->dispatch( $request ); + $revision = $response->get_data(); + + $this->assertIsArray( $revision, 'Failed asserting that the revision is an array.' ); + $this->assertSame( + $revision_id, + $revision['wp_id'], + "Failed asserting that the revision id is the same as $revision_id" + ); + $this->assertSame( + self::$template_post->ID, + $revision['parent'], + sprintf( + 'Failed asserting that the parent id of the revision is the same as %s.', + self::$template_post->ID + ) + ); + } + + /** + * @covers WP_REST_Template_Revisions_Controller::get_item + * @ticket 56922 + */ + public function test_get_item_not_found() { + wp_set_current_user( self::$admin_id ); + + $revisions = wp_get_post_revisions( self::$template_post, array( 'fields' => 'ids' ) ); + $revision_id = array_shift( $revisions ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/templates/invalid//parent/revisions/' . $revision_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_parent', $response, WP_Http::NOT_FOUND ); + } + + /** + * @covers WP_REST_Template_Revisions_Controller::prepare_item_for_response + * @ticket 56922 + */ + public function test_prepare_item() { + $revisions = wp_get_post_revisions( self::$template_post, array( 'fields' => 'ids' ) ); + $revision_id = array_shift( $revisions ); + $post = get_post( $revision_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/templates/' . self::TEST_THEME . '/' . self::TEMPLATE_NAME . '/revisions/' . $revision_id ); + $controller = new WP_REST_Template_Revisions_Controller( self::PARENT_POST_TYPE ); + $response = $controller->prepare_item_for_response( $post, $request ); + $this->assertInstanceOf( + WP_REST_Response::class, + $response, + 'Failed asserting that the response object is an instance of WP_REST_Response.' + ); + + $revision = $response->get_data(); + $this->assertIsArray( $revision, 'Failed asserting that the revision is an array.' ); + $this->assertSame( + $revision_id, + $revision['wp_id'], + "Failed asserting that the revision id is the same as $revision_id." + ); + $this->assertSame( + self::$template_post->ID, + $revision['parent'], + sprintf( + 'Failed asserting that the parent id of the revision is the same as %s.', + self::$template_post->ID + ) + ); + + $links = $response->get_links(); + $this->assertIsArray( $links, 'Failed asserting that the links are an array.' ); + + $this->assertStringEndsWith( + self::TEST_THEME . '//' . self::TEMPLATE_NAME . '/revisions/' . $revision_id, + $links['self'][0]['href'], + sprintf( + 'Failed asserting that the self link ends with %s.', + self::TEST_THEME . '//' . self::TEMPLATE_NAME . '/revisions/' . $revision_id + ) + ); + + $this->assertStringEndsWith( + self::TEST_THEME . '//' . self::TEMPLATE_NAME, + $links['parent'][0]['href'], + sprintf( + 'Failed asserting that the parent link ends with %s.', + self::TEST_THEME . '//' . self::TEMPLATE_NAME + ) + ); + } + + /** + * @covers WP_REST_Template_Revisions_Controller::get_item_schema + * @ticket 56922 + */ + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/templates/' . self::TEST_THEME . '/' . self::TEMPLATE_NAME . '/revisions' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $properties = $data['schema']['properties']; + + $this->assertCount( 16, $properties ); + $this->assertArrayHasKey( 'id', $properties, 'ID key should exist in properties.' ); + $this->assertArrayHasKey( 'slug', $properties, 'Slug key should exist in properties.' ); + $this->assertArrayHasKey( 'theme', $properties, 'Theme key should exist in properties.' ); + $this->assertArrayHasKey( 'source', $properties, 'Source key should exist in properties.' ); + $this->assertArrayHasKey( 'origin', $properties, 'Origin key should exist in properties.' ); + $this->assertArrayHasKey( 'content', $properties, 'Content key should exist in properties.' ); + $this->assertArrayHasKey( 'title', $properties, 'Title key should exist in properties.' ); + $this->assertArrayHasKey( 'description', $properties, 'description key should exist in properties.' ); + $this->assertArrayHasKey( 'status', $properties, 'status key should exist in properties.' ); + $this->assertArrayHasKey( 'wp_id', $properties, 'wp_id key should exist in properties.' ); + $this->assertArrayHasKey( 'has_theme_file', $properties, 'has_theme_file key should exist in properties.' ); + $this->assertArrayHasKey( 'author', $properties, 'author key should exist in properties.' ); + $this->assertArrayHasKey( 'modified', $properties, 'modified key should exist in properties.' ); + $this->assertArrayHasKey( 'is_custom', $properties, 'is_custom key should exist in properties.' ); + $this->assertArrayHasKey( 'parent', $properties, 'Parent key should exist in properties.' ); + } + + /** + * @coversNothing + * @ticket 56922 + */ + public function test_create_item() { + $this->markTestSkipped( + sprintf( + "The '%s' controller doesn't currently support the ability to create template revisions.", + WP_REST_Template_Revisions_Controller::class + ) + ); + } + + /** + * @coversNothing + * @ticket 56922 + */ + public function test_update_item() { + $this->markTestSkipped( + sprintf( + "The '%s' controller doesn't currently support the ability to update template revisions.", + WP_REST_Template_Revisions_Controller::class + ) + ); + } + + /** + * @covers WP_REST_Templates_Controller::delete_item + * @ticket 56922 + */ + public function test_delete_item() { + wp_set_current_user( self::$admin_id ); + + $revision_id = _wp_put_post_revision( self::$template_post ); + self::$revisions[] = $revision_id; + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/templates/' . self::TEST_THEME . '/' . self::TEMPLATE_NAME . '/revisions/' . $revision_id ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Failed asserting that the response status is 200.' ); + $this->assertNull( get_post( $revision_id ), 'Failed asserting that the post with the given revision ID is deleted.' ); + } + + /** + * @covers WP_REST_Templates_Controller::delete_item + * @ticket 56922 + */ + public function test_delete_item_incorrect_permission() { + wp_set_current_user( self::$contributor_id ); + $revision_id = _wp_put_post_revision( self::$template_post ); + self::$revisions[] = $revision_id; + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/templates/' . self::TEST_THEME . '/' . self::TEMPLATE_NAME . '/revisions/' . $revision_id ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_delete', $response, WP_Http::FORBIDDEN ); + } + + /** + * @covers WP_REST_Templates_Controller::delete_item + * @ticket 56922 + */ + public function test_delete_item_no_permission() { + wp_set_current_user( 0 ); + $revision_id = _wp_put_post_revision( self::$template_post ); + self::$revisions[] = $revision_id; + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/templates/' . self::TEST_THEME . '/' . self::TEMPLATE_NAME . '/revisions/' . $revision_id ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_delete', $response, WP_Http::UNAUTHORIZED ); + } + + /** + * @covers WP_REST_Template_Revisions_Controller::get_item + * @ticket 56922 + */ + public function test_delete_item_not_found() { + wp_set_current_user( self::$admin_id ); + + $revision_id = _wp_put_post_revision( self::$template_post ); + self::$revisions[] = $revision_id; + + $request = new WP_REST_Request( 'DELETE', '/wp/v2/templates/invalid//parent/revisions/' . $revision_id ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_parent', $response, WP_Http::NOT_FOUND ); + } +} diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 8341830452a1f..d212facf26006 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -2707,7 +2707,7 @@ mockedApiResponse.Schema = { } ] }, - "/wp/v2/media": { + "/wp/v2/menu-items": { "namespace": "wp/v2", "methods": [ "GET", @@ -2718,6 +2718,9 @@ mockedApiResponse.Schema = { "methods": [ "GET" ], + "allow_batch": { + "v1": true + }, "args": { "context": { "description": "Scope under which the request is made; determines fields present in response.", @@ -2740,7 +2743,7 @@ mockedApiResponse.Schema = { "per_page": { "description": "Maximum number of items to be returned in result set.", "type": "integer", - "default": 10, + "default": 100, "minimum": 1, "maximum": 100, "required": false @@ -2762,24 +2765,6 @@ mockedApiResponse.Schema = { "format": "date-time", "required": false }, - "author": { - "description": "Limit result set to posts assigned to specific authors.", - "type": "array", - "items": { - "type": "integer" - }, - "default": [], - "required": false - }, - "author_exclude": { - "description": "Ensure result set excludes posts assigned to specific authors.", - "type": "array", - "items": { - "type": "integer" - }, - "default": [], - "required": false - }, "before": { "description": "Limit response to posts published before a given ISO8601 compliant date.", "type": "string", @@ -2818,7 +2803,7 @@ mockedApiResponse.Schema = { "order": { "description": "Order sort attribute ascending or descending.", "type": "string", - "default": "desc", + "default": "asc", "enum": [ "asc", "desc" @@ -2826,9 +2811,9 @@ mockedApiResponse.Schema = { "required": false }, "orderby": { - "description": "Sort collection by post attribute.", + "description": "Sort collection by object attribute.", "type": "string", - "default": "date", + "default": "menu_order", "enum": [ "author", "date", @@ -2839,28 +2824,11 @@ mockedApiResponse.Schema = { "relevance", "slug", "include_slugs", - "title" + "title", + "menu_order" ], "required": false }, - "parent": { - "description": "Limit result set to items with particular parent IDs.", - "type": "array", - "items": { - "type": "integer" - }, - "default": [], - "required": false - }, - "parent_exclude": { - "description": "Limit result set to all items except those of a particular parent ID.", - "type": "array", - "items": { - "type": "integer" - }, - "default": [], - "required": false - }, "search_columns": { "default": [], "description": "Array of column names to be searched.", @@ -2884,36 +2852,118 @@ mockedApiResponse.Schema = { "required": false }, "status": { - "default": "inherit", + "default": "publish", "description": "Limit result set to posts assigned one or more statuses.", "type": "array", "items": { "enum": [ - "inherit", + "publish", + "future", + "draft", + "pending", "private", - "trash" + "trash", + "auto-draft", + "inherit", + "request-pending", + "request-confirmed", + "request-failed", + "request-completed", + "any" ], "type": "string" }, "required": false }, - "media_type": { - "default": null, - "description": "Limit result set to attachments of a particular media type.", + "tax_relation": { + "description": "Limit result set based on relationship between multiple taxonomies.", "type": "string", "enum": [ - "image", - "video", - "text", - "application", - "audio" + "AND", + "OR" ], "required": false }, - "mime_type": { - "default": null, - "description": "Limit result set to attachments of a particular MIME type.", - "type": "string", + "menus": { + "description": "Limit result set to items with specific terms assigned in the menus taxonomy.", + "type": [ + "object", + "array" + ], + "oneOf": [ + { + "title": "Term ID List", + "description": "Match terms with the listed IDs.", + "type": "array", + "items": { + "type": "integer" + } + }, + { + "title": "Term ID Taxonomy Query", + "description": "Perform an advanced term query.", + "type": "object", + "properties": { + "terms": { + "description": "Term IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [] + }, + "operator": { + "description": "Whether items must be assigned all or any of the specified terms.", + "type": "string", + "enum": [ + "AND", + "OR" + ], + "default": "OR" + } + }, + "additionalProperties": false + } + ], + "required": false + }, + "menus_exclude": { + "description": "Limit result set to items except those with specific terms assigned in the menus taxonomy.", + "type": [ + "object", + "array" + ], + "oneOf": [ + { + "title": "Term ID List", + "description": "Match terms with the listed IDs.", + "type": "array", + "items": { + "type": "integer" + } + }, + { + "title": "Term ID Taxonomy Query", + "description": "Perform an advanced term query.", + "type": "object", + "properties": { + "terms": { + "description": "Term IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [] + } + }, + "additionalProperties": false + } + ], + "required": false + }, + "menu_order": { + "description": "Limit result set to posts with a specific menu_order value.", + "type": "integer", "required": false } } @@ -2922,55 +2972,26 @@ mockedApiResponse.Schema = { "methods": [ "POST" ], + "allow_batch": { + "v1": true + }, "args": { - "date": { - "description": "The date the post was published, in the site's timezone.", - "type": [ - "string", - "null" - ], - "format": "date-time", - "required": false - }, - "date_gmt": { - "description": "The date the post was published, as GMT.", + "title": { + "description": "The title for the object.", "type": [ "string", - "null" - ], - "format": "date-time", - "required": false - }, - "slug": { - "description": "An alphanumeric identifier for the post unique to its type.", - "type": "string", - "required": false - }, - "status": { - "description": "A named status for the post.", - "type": "string", - "enum": [ - "publish", - "future", - "draft", - "pending", - "private" + "object" ], - "required": false - }, - "title": { - "description": "The title for the post.", - "type": "object", "properties": { "raw": { - "description": "Title for the post, as it exists in the database.", + "description": "Title for the object, as it exists in the database.", "type": "string", "context": [ "edit" ] }, "rendered": { - "description": "HTML title for the post, transformed for display.", + "description": "HTML title for the object, transformed for display.", "type": "string", "context": [ "view", @@ -2982,817 +3003,40 @@ mockedApiResponse.Schema = { }, "required": false }, - "author": { - "description": "The ID for the author of the post.", - "type": "integer", - "required": false - }, - "comment_status": { - "description": "Whether or not comments are open on the post.", + "type": { + "default": "custom", + "description": "The family of objects originally represented, such as \"post_type\" or \"taxonomy\".", "type": "string", "enum": [ - "open", - "closed" + "taxonomy", + "post_type", + "post_type_archive", + "custom" ], "required": false }, - "ping_status": { - "description": "Whether or not the post can be pinged.", + "status": { + "default": "publish", + "description": "A named status for the object.", "type": "string", "enum": [ - "open", - "closed" + "publish", + "future", + "draft", + "pending", + "private" ], "required": false }, - "meta": { - "description": "Meta fields.", - "type": "object", - "properties": [], - "required": false - }, - "template": { - "description": "The theme file to use to display the post.", - "type": "string", + "parent": { + "default": 0, + "description": "The ID for the parent of the object.", + "type": "integer", + "minimum": 0, "required": false }, - "alt_text": { - "description": "Alternative text to display when attachment is not displayed.", - "type": "string", - "required": false - }, - "caption": { - "description": "The attachment caption.", - "type": "object", - "properties": { - "raw": { - "description": "Caption for the attachment, as it exists in the database.", - "type": "string", - "context": [ - "edit" - ] - }, - "rendered": { - "description": "HTML caption for the attachment, transformed for display.", - "type": "string", - "context": [ - "view", - "edit", - "embed" - ], - "readonly": true - } - }, - "required": false - }, - "description": { - "description": "The attachment description.", - "type": "object", - "properties": { - "raw": { - "description": "Description for the attachment, as it exists in the database.", - "type": "string", - "context": [ - "edit" - ] - }, - "rendered": { - "description": "HTML description for the attachment, transformed for display.", - "type": "string", - "context": [ - "view", - "edit" - ], - "readonly": true - } - }, - "required": false - }, - "post": { - "description": "The ID for the associated post of the attachment.", - "type": "integer", - "required": false - } - } - } - ], - "_links": { - "self": "http://example.org/index.php?rest_route=/wp/v2/media" - } - }, - "/wp/v2/media/(?P[\\d]+)": { - "namespace": "wp/v2", - "methods": [ - "GET", - "POST", - "PUT", - "PATCH", - "DELETE" - ], - "endpoints": [ - { - "methods": [ - "GET" - ], - "args": { - "id": { - "description": "Unique identifier for the post.", - "type": "integer", - "required": false - }, - "context": { - "description": "Scope under which the request is made; determines fields present in response.", - "type": "string", - "enum": [ - "view", - "embed", - "edit" - ], - "default": "view", - "required": false - } - } - }, - { - "methods": [ - "POST", - "PUT", - "PATCH" - ], - "args": { - "id": { - "description": "Unique identifier for the post.", - "type": "integer", - "required": false - }, - "date": { - "description": "The date the post was published, in the site's timezone.", - "type": [ - "string", - "null" - ], - "format": "date-time", - "required": false - }, - "date_gmt": { - "description": "The date the post was published, as GMT.", - "type": [ - "string", - "null" - ], - "format": "date-time", - "required": false - }, - "slug": { - "description": "An alphanumeric identifier for the post unique to its type.", - "type": "string", - "required": false - }, - "status": { - "description": "A named status for the post.", - "type": "string", - "enum": [ - "publish", - "future", - "draft", - "pending", - "private" - ], - "required": false - }, - "title": { - "description": "The title for the post.", - "type": "object", - "properties": { - "raw": { - "description": "Title for the post, as it exists in the database.", - "type": "string", - "context": [ - "edit" - ] - }, - "rendered": { - "description": "HTML title for the post, transformed for display.", - "type": "string", - "context": [ - "view", - "edit", - "embed" - ], - "readonly": true - } - }, - "required": false - }, - "author": { - "description": "The ID for the author of the post.", - "type": "integer", - "required": false - }, - "comment_status": { - "description": "Whether or not comments are open on the post.", - "type": "string", - "enum": [ - "open", - "closed" - ], - "required": false - }, - "ping_status": { - "description": "Whether or not the post can be pinged.", - "type": "string", - "enum": [ - "open", - "closed" - ], - "required": false - }, - "meta": { - "description": "Meta fields.", - "type": "object", - "properties": [], - "required": false - }, - "template": { - "description": "The theme file to use to display the post.", - "type": "string", - "required": false - }, - "alt_text": { - "description": "Alternative text to display when attachment is not displayed.", - "type": "string", - "required": false - }, - "caption": { - "description": "The attachment caption.", - "type": "object", - "properties": { - "raw": { - "description": "Caption for the attachment, as it exists in the database.", - "type": "string", - "context": [ - "edit" - ] - }, - "rendered": { - "description": "HTML caption for the attachment, transformed for display.", - "type": "string", - "context": [ - "view", - "edit", - "embed" - ], - "readonly": true - } - }, - "required": false - }, - "description": { - "description": "The attachment description.", - "type": "object", - "properties": { - "raw": { - "description": "Description for the attachment, as it exists in the database.", - "type": "string", - "context": [ - "edit" - ] - }, - "rendered": { - "description": "HTML description for the attachment, transformed for display.", - "type": "string", - "context": [ - "view", - "edit" - ], - "readonly": true - } - }, - "required": false - }, - "post": { - "description": "The ID for the associated post of the attachment.", - "type": "integer", - "required": false - } - } - }, - { - "methods": [ - "DELETE" - ], - "args": { - "id": { - "description": "Unique identifier for the post.", - "type": "integer", - "required": false - }, - "force": { - "type": "boolean", - "default": false, - "description": "Whether to bypass Trash and force deletion.", - "required": false - } - } - } - ] - }, - "/wp/v2/media/(?P[\\d]+)/post-process": { - "namespace": "wp/v2", - "methods": [ - "POST" - ], - "endpoints": [ - { - "methods": [ - "POST" - ], - "args": { - "id": { - "description": "Unique identifier for the attachment.", - "type": "integer", - "required": false - }, - "action": { - "type": "string", - "enum": [ - "create-image-subsizes" - ], - "required": true - } - } - } - ] - }, - "/wp/v2/media/(?P[\\d]+)/edit": { - "namespace": "wp/v2", - "methods": [ - "POST" - ], - "endpoints": [ - { - "methods": [ - "POST" - ], - "args": { - "src": { - "description": "URL to the edited image file.", - "type": "string", - "format": "uri", - "required": true - }, - "modifiers": { - "description": "Array of image edits.", - "type": "array", - "minItems": 1, - "items": { - "description": "Image edit.", - "type": "object", - "required": [ - "type", - "args" - ], - "oneOf": [ - { - "title": "Rotation", - "properties": { - "type": { - "description": "Rotation type.", - "type": "string", - "enum": [ - "rotate" - ] - }, - "args": { - "description": "Rotation arguments.", - "type": "object", - "required": [ - "angle" - ], - "properties": { - "angle": { - "description": "Angle to rotate clockwise in degrees.", - "type": "number" - } - } - } - } - }, - { - "title": "Crop", - "properties": { - "type": { - "description": "Crop type.", - "type": "string", - "enum": [ - "crop" - ] - }, - "args": { - "description": "Crop arguments.", - "type": "object", - "required": [ - "left", - "top", - "width", - "height" - ], - "properties": { - "left": { - "description": "Horizontal position from the left to begin the crop as a percentage of the image width.", - "type": "number" - }, - "top": { - "description": "Vertical position from the top to begin the crop as a percentage of the image height.", - "type": "number" - }, - "width": { - "description": "Width of the crop as a percentage of the image width.", - "type": "number" - }, - "height": { - "description": "Height of the crop as a percentage of the image height.", - "type": "number" - } - } - } - } - } - ] - }, - "required": false - }, - "rotation": { - "description": "The amount to rotate the image clockwise in degrees. DEPRECATED: Use `modifiers` instead.", - "type": "integer", - "minimum": 0, - "exclusiveMinimum": true, - "maximum": 360, - "exclusiveMaximum": true, - "required": false - }, - "x": { - "description": "As a percentage of the image, the x position to start the crop from. DEPRECATED: Use `modifiers` instead.", - "type": "number", - "minimum": 0, - "maximum": 100, - "required": false - }, - "y": { - "description": "As a percentage of the image, the y position to start the crop from. DEPRECATED: Use `modifiers` instead.", - "type": "number", - "minimum": 0, - "maximum": 100, - "required": false - }, - "width": { - "description": "As a percentage of the image, the width to crop the image to. DEPRECATED: Use `modifiers` instead.", - "type": "number", - "minimum": 0, - "maximum": 100, - "required": false - }, - "height": { - "description": "As a percentage of the image, the height to crop the image to. DEPRECATED: Use `modifiers` instead.", - "type": "number", - "minimum": 0, - "maximum": 100, - "required": false - } - } - } - ] - }, - "/wp/v2/menu-items": { - "namespace": "wp/v2", - "methods": [ - "GET", - "POST" - ], - "endpoints": [ - { - "methods": [ - "GET" - ], - "allow_batch": { - "v1": true - }, - "args": { - "context": { - "description": "Scope under which the request is made; determines fields present in response.", - "type": "string", - "enum": [ - "view", - "embed", - "edit" - ], - "default": "view", - "required": false - }, - "page": { - "description": "Current page of the collection.", - "type": "integer", - "default": 1, - "minimum": 1, - "required": false - }, - "per_page": { - "description": "Maximum number of items to be returned in result set.", - "type": "integer", - "default": 100, - "minimum": 1, - "maximum": 100, - "required": false - }, - "search": { - "description": "Limit results to those matching a string.", - "type": "string", - "required": false - }, - "after": { - "description": "Limit response to posts published after a given ISO8601 compliant date.", - "type": "string", - "format": "date-time", - "required": false - }, - "modified_after": { - "description": "Limit response to posts modified after a given ISO8601 compliant date.", - "type": "string", - "format": "date-time", - "required": false - }, - "before": { - "description": "Limit response to posts published before a given ISO8601 compliant date.", - "type": "string", - "format": "date-time", - "required": false - }, - "modified_before": { - "description": "Limit response to posts modified before a given ISO8601 compliant date.", - "type": "string", - "format": "date-time", - "required": false - }, - "exclude": { - "description": "Ensure result set excludes specific IDs.", - "type": "array", - "items": { - "type": "integer" - }, - "default": [], - "required": false - }, - "include": { - "description": "Limit result set to specific IDs.", - "type": "array", - "items": { - "type": "integer" - }, - "default": [], - "required": false - }, - "offset": { - "description": "Offset the result set by a specific number of items.", - "type": "integer", - "required": false - }, - "order": { - "description": "Order sort attribute ascending or descending.", - "type": "string", - "default": "asc", - "enum": [ - "asc", - "desc" - ], - "required": false - }, - "orderby": { - "description": "Sort collection by object attribute.", - "type": "string", - "default": "menu_order", - "enum": [ - "author", - "date", - "id", - "include", - "modified", - "parent", - "relevance", - "slug", - "include_slugs", - "title", - "menu_order" - ], - "required": false - }, - "search_columns": { - "default": [], - "description": "Array of column names to be searched.", - "type": "array", - "items": { - "enum": [ - "post_title", - "post_content", - "post_excerpt" - ], - "type": "string" - }, - "required": false - }, - "slug": { - "description": "Limit result set to posts with one or more specific slugs.", - "type": "array", - "items": { - "type": "string" - }, - "required": false - }, - "status": { - "default": "publish", - "description": "Limit result set to posts assigned one or more statuses.", - "type": "array", - "items": { - "enum": [ - "publish", - "future", - "draft", - "pending", - "private", - "trash", - "auto-draft", - "inherit", - "request-pending", - "request-confirmed", - "request-failed", - "request-completed", - "any" - ], - "type": "string" - }, - "required": false - }, - "tax_relation": { - "description": "Limit result set based on relationship between multiple taxonomies.", - "type": "string", - "enum": [ - "AND", - "OR" - ], - "required": false - }, - "menus": { - "description": "Limit result set to items with specific terms assigned in the menus taxonomy.", - "type": [ - "object", - "array" - ], - "oneOf": [ - { - "title": "Term ID List", - "description": "Match terms with the listed IDs.", - "type": "array", - "items": { - "type": "integer" - } - }, - { - "title": "Term ID Taxonomy Query", - "description": "Perform an advanced term query.", - "type": "object", - "properties": { - "terms": { - "description": "Term IDs.", - "type": "array", - "items": { - "type": "integer" - }, - "default": [] - }, - "operator": { - "description": "Whether items must be assigned all or any of the specified terms.", - "type": "string", - "enum": [ - "AND", - "OR" - ], - "default": "OR" - } - }, - "additionalProperties": false - } - ], - "required": false - }, - "menus_exclude": { - "description": "Limit result set to items except those with specific terms assigned in the menus taxonomy.", - "type": [ - "object", - "array" - ], - "oneOf": [ - { - "title": "Term ID List", - "description": "Match terms with the listed IDs.", - "type": "array", - "items": { - "type": "integer" - } - }, - { - "title": "Term ID Taxonomy Query", - "description": "Perform an advanced term query.", - "type": "object", - "properties": { - "terms": { - "description": "Term IDs.", - "type": "array", - "items": { - "type": "integer" - }, - "default": [] - } - }, - "additionalProperties": false - } - ], - "required": false - }, - "menu_order": { - "description": "Limit result set to posts with a specific menu_order value.", - "type": "integer", - "required": false - } - } - }, - { - "methods": [ - "POST" - ], - "allow_batch": { - "v1": true - }, - "args": { - "title": { - "description": "The title for the object.", - "type": [ - "string", - "object" - ], - "properties": { - "raw": { - "description": "Title for the object, as it exists in the database.", - "type": "string", - "context": [ - "edit" - ] - }, - "rendered": { - "description": "HTML title for the object, transformed for display.", - "type": "string", - "context": [ - "view", - "edit", - "embed" - ], - "readonly": true - } - }, - "required": false - }, - "type": { - "default": "custom", - "description": "The family of objects originally represented, such as \"post_type\" or \"taxonomy\".", - "type": "string", - "enum": [ - "taxonomy", - "post_type", - "post_type_archive", - "custom" - ], - "required": false - }, - "status": { - "default": "publish", - "description": "A named status for the object.", - "type": "string", - "enum": [ - "publish", - "future", - "draft", - "pending", - "private" - ], - "required": false - }, - "parent": { - "default": 0, - "description": "The ID for the parent of the object.", - "type": "integer", - "minimum": 0, - "required": false - }, - "attr_title": { - "description": "Text for the title attribute of the link element for this menu item.", + "attr_title": { + "description": "Text for the title attribute of the link element for this menu item.", "type": "string", "required": false }, @@ -4631,33 +3875,316 @@ mockedApiResponse.Schema = { "properties": [], "required": false }, - "template": { - "description": "The theme file to use to display the post.", - "type": "string", + "template": { + "description": "The theme file to use to display the post.", + "type": "string", + "required": false + }, + "wp_pattern_category": { + "description": "The terms assigned to the post in the wp_pattern_category taxonomy.", + "type": "array", + "items": { + "type": "integer" + }, + "required": false + } + } + } + ], + "_links": { + "self": "http://example.org/index.php?rest_route=/wp/v2/blocks" + } + }, + "/wp/v2/blocks/(?P[\\d]+)": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "allow_batch": { + "v1": true + }, + "args": { + "id": { + "description": "Unique identifier for the post.", + "type": "integer", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + }, + "password": { + "description": "The password for the post if it is password protected.", + "type": "string", + "required": false + } + } + }, + { + "methods": [ + "POST", + "PUT", + "PATCH" + ], + "allow_batch": { + "v1": true + }, + "args": { + "id": { + "description": "Unique identifier for the post.", + "type": "integer", + "required": false + }, + "date": { + "description": "The date the post was published, in the site's timezone.", + "type": [ + "string", + "null" + ], + "format": "date-time", + "required": false + }, + "date_gmt": { + "description": "The date the post was published, as GMT.", + "type": [ + "string", + "null" + ], + "format": "date-time", + "required": false + }, + "slug": { + "description": "An alphanumeric identifier for the post unique to its type.", + "type": "string", + "required": false + }, + "status": { + "description": "A named status for the post.", + "type": "string", + "enum": [ + "publish", + "future", + "draft", + "pending", + "private" + ], + "required": false + }, + "password": { + "description": "A password to protect access to the content and excerpt.", + "type": "string", + "required": false + }, + "title": { + "description": "The title for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Title for the post, as it exists in the database.", + "type": "string", + "context": [ + "view", + "edit" + ] + } + }, + "required": false + }, + "content": { + "description": "The content for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Content for the post, as it exists in the database.", + "type": "string", + "context": [ + "view", + "edit" + ] + }, + "block_version": { + "description": "Version of the content block format used by the post.", + "type": "integer", + "context": [ + "edit" + ], + "readonly": true + }, + "protected": { + "description": "Whether the content is protected with a password.", + "type": "boolean", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "meta": { + "description": "Meta fields.", + "type": "object", + "properties": [], + "required": false + }, + "template": { + "description": "The theme file to use to display the post.", + "type": "string", + "required": false + }, + "wp_pattern_category": { + "description": "The terms assigned to the post in the wp_pattern_category taxonomy.", + "type": "array", + "items": { + "type": "integer" + }, + "required": false + } + } + }, + { + "methods": [ + "DELETE" + ], + "allow_batch": { + "v1": true + }, + "args": { + "id": { + "description": "Unique identifier for the post.", + "type": "integer", + "required": false + }, + "force": { + "type": "boolean", + "default": false, + "description": "Whether to bypass Trash and force deletion.", + "required": false + } + } + } + ] + }, + "/wp/v2/blocks/(?P[\\d]+)/revisions": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "parent": { + "description": "The ID for the parent of the revision.", + "type": "integer", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + }, + "page": { + "description": "Current page of the collection.", + "type": "integer", + "default": 1, + "minimum": 1, + "required": false + }, + "per_page": { + "description": "Maximum number of items to be returned in result set.", + "type": "integer", + "minimum": 1, + "maximum": 100, + "required": false + }, + "search": { + "description": "Limit results to those matching a string.", + "type": "string", + "required": false + }, + "exclude": { + "description": "Ensure result set excludes specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], "required": false }, - "wp_pattern_category": { - "description": "The terms assigned to the post in the wp_pattern_category taxonomy.", + "include": { + "description": "Limit result set to specific IDs.", "type": "array", "items": { "type": "integer" }, + "default": [], + "required": false + }, + "offset": { + "description": "Offset the result set by a specific number of items.", + "type": "integer", + "required": false + }, + "order": { + "description": "Order sort attribute ascending or descending.", + "type": "string", + "default": "desc", + "enum": [ + "asc", + "desc" + ], + "required": false + }, + "orderby": { + "description": "Sort collection by object attribute.", + "type": "string", + "default": "date", + "enum": [ + "date", + "id", + "include", + "relevance", + "slug", + "include_slugs", + "title" + ], "required": false } } } - ], - "_links": { - "self": "http://example.org/index.php?rest_route=/wp/v2/blocks" - } + ] }, - "/wp/v2/blocks/(?P[\\d]+)": { + "/wp/v2/blocks/(?P[\\d]+)/revisions/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ "GET", - "POST", - "PUT", - "PATCH", "DELETE" ], "endpoints": [ @@ -4665,12 +4192,14 @@ mockedApiResponse.Schema = { "methods": [ "GET" ], - "allow_batch": { - "v1": true - }, "args": { + "parent": { + "description": "The ID for the parent of the revision.", + "type": "integer", + "required": false + }, "id": { - "description": "Unique identifier for the post.", + "description": "Unique identifier for the revision.", "type": "integer", "required": false }, @@ -4684,26 +4213,71 @@ mockedApiResponse.Schema = { ], "default": "view", "required": false + } + } + }, + { + "methods": [ + "DELETE" + ], + "args": { + "parent": { + "description": "The ID for the parent of the revision.", + "type": "integer", + "required": false }, - "password": { - "description": "The password for the post if it is password protected.", + "id": { + "description": "Unique identifier for the revision.", + "type": "integer", + "required": false + }, + "force": { + "type": "boolean", + "default": false, + "description": "Required to be true, as revisions do not support trashing.", + "required": false + } + } + } + ] + }, + "/wp/v2/blocks/(?P[\\d]+)/autosaves": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "parent": { + "description": "The ID for the parent of the autosave.", + "type": "integer", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", "required": false } } }, { "methods": [ - "POST", - "PUT", - "PATCH" + "POST" ], - "allow_batch": { - "v1": true - }, "args": { - "id": { - "description": "Unique identifier for the post.", + "parent": { + "description": "The ID for the parent of the autosave.", "type": "integer", "required": false }, @@ -4815,31 +4389,46 @@ mockedApiResponse.Schema = { "required": false } } - }, + } + ] + }, + "/wp/v2/blocks/(?P[\\d]+)/autosaves/(?P[\\d]+)": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ { "methods": [ - "DELETE" + "GET" ], - "allow_batch": { - "v1": true - }, "args": { + "parent": { + "description": "The ID for the parent of the autosave.", + "type": "integer", + "required": false + }, "id": { - "description": "Unique identifier for the post.", + "description": "The ID for the autosave.", "type": "integer", "required": false }, - "force": { - "type": "boolean", - "default": false, - "description": "Whether to bypass Trash and force deletion.", + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", "required": false } } } ] }, - "/wp/v2/blocks/(?P[\\d]+)/revisions": { + "/wp/v2/templates/(?P([^\\/:<>\\*\\?\"\\|]+(?:\\/[^\\/:<>\\*\\?\"\\|]+)?)[\\/\\w%-]+)/revisions": { "namespace": "wp/v2", "methods": [ "GET" @@ -4851,8 +4440,8 @@ mockedApiResponse.Schema = { ], "args": { "parent": { - "description": "The ID for the parent of the revision.", - "type": "integer", + "description": "The id of a template", + "type": "string", "required": false }, "context": { @@ -4937,7 +4526,7 @@ mockedApiResponse.Schema = { } ] }, - "/wp/v2/blocks/(?P[\\d]+)/revisions/(?P[\\d]+)": { + "/wp/v2/templates/(?P([^\\/:<>\\*\\?\"\\|]+(?:\\/[^\\/:<>\\*\\?\"\\|]+)?)[\\/\\w%-]+)/revisions/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ "GET", @@ -4950,8 +4539,8 @@ mockedApiResponse.Schema = { ], "args": { "parent": { - "description": "The ID for the parent of the revision.", - "type": "integer", + "description": "The id of a template", + "type": "string", "required": false }, "id": { @@ -4978,8 +4567,8 @@ mockedApiResponse.Schema = { ], "args": { "parent": { - "description": "The ID for the parent of the revision.", - "type": "integer", + "description": "The id of a template", + "type": "string", "required": false }, "id": { @@ -4997,7 +4586,7 @@ mockedApiResponse.Schema = { } ] }, - "/wp/v2/blocks/(?P[\\d]+)/autosaves": { + "/wp/v2/templates/(?P([^\\/:<>\\*\\?\"\\|]+(?:\\/[^\\/:<>\\*\\?\"\\|]+)?)[\\/\\w%-]+)/autosaves": { "namespace": "wp/v2", "methods": [ "GET", @@ -5009,9 +4598,9 @@ mockedApiResponse.Schema = { "GET" ], "args": { - "parent": { - "description": "The ID for the parent of the autosave.", - "type": "integer", + "id": { + "description": "The id of a template", + "type": "string", "required": false }, "context": { @@ -5032,89 +4621,73 @@ mockedApiResponse.Schema = { "POST" ], "args": { - "parent": { - "description": "The ID for the parent of the autosave.", - "type": "integer", - "required": false - }, - "date": { - "description": "The date the post was published, in the site's timezone.", - "type": [ - "string", - "null" - ], - "format": "date-time", - "required": false - }, - "date_gmt": { - "description": "The date the post was published, as GMT.", - "type": [ - "string", - "null" - ], - "format": "date-time", + "id": { + "description": "The id of a template", + "type": "string", "required": false }, "slug": { - "description": "An alphanumeric identifier for the post unique to its type.", + "description": "Unique slug identifying the template.", "type": "string", + "minLength": 1, + "pattern": "[a-zA-Z0-9_\\%-]+", "required": false }, - "status": { - "description": "A named status for the post.", + "theme": { + "description": "Theme identifier for the template.", "type": "string", - "enum": [ - "publish", - "future", - "draft", - "pending", - "private" - ], "required": false }, - "password": { - "description": "A password to protect access to the content and excerpt.", + "type": { + "description": "Type of template.", "type": "string", "required": false }, - "title": { - "description": "The title for the post.", - "type": "object", + "content": { + "description": "Content of template.", + "type": [ + "object", + "string" + ], "properties": { "raw": { - "description": "Title for the post, as it exists in the database.", + "description": "Content for the template, as it exists in the database.", "type": "string", "context": [ "view", "edit" ] + }, + "block_version": { + "description": "Version of the content block format used by the template.", + "type": "integer", + "context": [ + "edit" + ], + "readonly": true } }, "required": false }, - "content": { - "description": "The content for the post.", - "type": "object", + "title": { + "description": "Title of template.", + "type": [ + "object", + "string" + ], "properties": { "raw": { - "description": "Content for the post, as it exists in the database.", + "description": "Title for the template, as it exists in the database.", "type": "string", "context": [ "view", - "edit" + "edit", + "embed" ] }, - "block_version": { - "description": "Version of the content block format used by the post.", - "type": "integer", - "context": [ - "edit" - ], - "readonly": true - }, - "protected": { - "description": "Whether the content is protected with a password.", - "type": "boolean", + "rendered": { + "description": "HTML title for the template, transformed for display.", + "type": "string", "context": [ "view", "edit", @@ -5125,30 +4698,33 @@ mockedApiResponse.Schema = { }, "required": false }, - "meta": { - "description": "Meta fields.", - "type": "object", - "properties": [], + "description": { + "description": "Description of template.", + "type": "string", "required": false }, - "template": { - "description": "The theme file to use to display the post.", + "status": { + "description": "Status of template.", "type": "string", + "enum": [ + "publish", + "future", + "draft", + "pending", + "private" + ], "required": false }, - "wp_pattern_category": { - "description": "The terms assigned to the post in the wp_pattern_category taxonomy.", - "type": "array", - "items": { - "type": "integer" - }, + "author": { + "description": "The ID for the author of the template.", + "type": "integer", "required": false } } } ] }, - "/wp/v2/blocks/(?P[\\d]+)/autosaves/(?P[\\d]+)": { + "/wp/v2/templates/(?P([^\\/:<>\\*\\?\"\\|]+(?:\\/[^\\/:<>\\*\\?\"\\|]+)?)[\\/\\w%-]+)/autosaves/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ "GET" @@ -5160,8 +4736,8 @@ mockedApiResponse.Schema = { ], "args": { "parent": { - "description": "The ID for the parent of the autosave.", - "type": "integer", + "description": "The id of a template", + "type": "string", "required": false }, "id": { @@ -5536,7 +5112,7 @@ mockedApiResponse.Schema = { } ] }, - "/wp/v2/templates/(?P[\\d]+)/revisions": { + "/wp/v2/template-parts/(?P([^\\/:<>\\*\\?\"\\|]+(?:\\/[^\\/:<>\\*\\?\"\\|]+)?)[\\/\\w%-]+)/revisions": { "namespace": "wp/v2", "methods": [ "GET" @@ -5548,8 +5124,8 @@ mockedApiResponse.Schema = { ], "args": { "parent": { - "description": "The ID for the parent of the revision.", - "type": "integer", + "description": "The id of a template", + "type": "string", "required": false }, "context": { @@ -5634,7 +5210,7 @@ mockedApiResponse.Schema = { } ] }, - "/wp/v2/templates/(?P[\\d]+)/revisions/(?P[\\d]+)": { + "/wp/v2/template-parts/(?P([^\\/:<>\\*\\?\"\\|]+(?:\\/[^\\/:<>\\*\\?\"\\|]+)?)[\\/\\w%-]+)/revisions/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ "GET", @@ -5647,8 +5223,8 @@ mockedApiResponse.Schema = { ], "args": { "parent": { - "description": "The ID for the parent of the revision.", - "type": "integer", + "description": "The id of a template", + "type": "string", "required": false }, "id": { @@ -5675,8 +5251,8 @@ mockedApiResponse.Schema = { ], "args": { "parent": { - "description": "The ID for the parent of the revision.", - "type": "integer", + "description": "The id of a template", + "type": "string", "required": false }, "id": { @@ -5694,7 +5270,7 @@ mockedApiResponse.Schema = { } ] }, - "/wp/v2/templates/(?P[\\d]+)/autosaves": { + "/wp/v2/template-parts/(?P([^\\/:<>\\*\\?\"\\|]+(?:\\/[^\\/:<>\\*\\?\"\\|]+)?)[\\/\\w%-]+)/autosaves": { "namespace": "wp/v2", "methods": [ "GET", @@ -5706,204 +5282,20 @@ mockedApiResponse.Schema = { "GET" ], "args": { - "parent": { - "description": "The ID for the parent of the autosave.", - "type": "integer", - "required": false - }, - "context": { - "description": "Scope under which the request is made; determines fields present in response.", - "type": "string", - "enum": [ - "view", - "embed", - "edit" - ], - "default": "view", - "required": false - } - } - }, - { - "methods": [ - "POST" - ], - "args": { - "parent": { - "description": "The ID for the parent of the autosave.", - "type": "integer", - "required": false - }, - "slug": { - "description": "Unique slug identifying the template.", - "type": "string", - "minLength": 1, - "pattern": "[a-zA-Z0-9_\\%-]+", - "required": false - }, - "theme": { - "description": "Theme identifier for the template.", - "type": "string", - "required": false - }, - "type": { - "description": "Type of template.", - "type": "string", - "required": false - }, - "content": { - "description": "Content of template.", - "type": [ - "object", - "string" - ], - "properties": { - "raw": { - "description": "Content for the template, as it exists in the database.", - "type": "string", - "context": [ - "view", - "edit" - ] - }, - "block_version": { - "description": "Version of the content block format used by the template.", - "type": "integer", - "context": [ - "edit" - ], - "readonly": true - } - }, - "required": false - }, - "title": { - "description": "Title of template.", - "type": [ - "object", - "string" - ], - "properties": { - "raw": { - "description": "Title for the template, as it exists in the database.", - "type": "string", - "context": [ - "view", - "edit", - "embed" - ] - }, - "rendered": { - "description": "HTML title for the template, transformed for display.", - "type": "string", - "context": [ - "view", - "edit", - "embed" - ], - "readonly": true - } - }, - "required": false - }, - "description": { - "description": "Description of template.", - "type": "string", - "required": false - }, - "status": { - "description": "Status of template.", - "type": "string", - "enum": [ - "publish", - "future", - "draft", - "pending", - "private" - ], - "required": false - }, - "author": { - "description": "The ID for the author of the template.", - "type": "integer", - "required": false - } - } - } - ] - }, - "/wp/v2/templates/(?P[\\d]+)/autosaves/(?P[\\d]+)": { - "namespace": "wp/v2", - "methods": [ - "GET" - ], - "endpoints": [ - { - "methods": [ - "GET" - ], - "args": { - "parent": { - "description": "The ID for the parent of the autosave.", - "type": "integer", - "required": false - }, "id": { - "description": "The ID for the autosave.", - "type": "integer", - "required": false - }, - "context": { - "description": "Scope under which the request is made; determines fields present in response.", + "description": "The id of a template", "type": "string", - "enum": [ - "view", - "embed", - "edit" - ], - "default": "view", "required": false - } - } - } - ] - }, - "/wp/v2/template-parts": { - "namespace": "wp/v2", - "methods": [ - "GET", - "POST" - ], - "endpoints": [ - { - "methods": [ - "GET" - ], - "args": { + }, "context": { "description": "Scope under which the request is made; determines fields present in response.", "type": "string", "enum": [ "view", "embed", - "edit" - ], - "default": "view", - "required": false - }, - "wp_id": { - "description": "Limit to the specified post id.", - "type": "integer", - "required": false - }, - "area": { - "description": "Limit to the specified template part area.", - "type": "string", - "required": false - }, - "post_type": { - "description": "Post type to get the templates for.", - "type": "string", + "edit" + ], + "default": "view", "required": false } } @@ -5913,12 +5305,17 @@ mockedApiResponse.Schema = { "POST" ], "args": { + "id": { + "description": "The id of a template", + "type": "string", + "required": false + }, "slug": { "description": "Unique slug identifying the template.", "type": "string", "minLength": 1, "pattern": "[a-zA-Z0-9_\\%-]+", - "required": true + "required": false }, "theme": { "description": "Theme identifier for the template.", @@ -5931,7 +5328,6 @@ mockedApiResponse.Schema = { "required": false }, "content": { - "default": "", "description": "Content of template.", "type": [ "object", @@ -5958,7 +5354,6 @@ mockedApiResponse.Schema = { "required": false }, "title": { - "default": "", "description": "Title of template.", "type": [ "object", @@ -5988,13 +5383,11 @@ mockedApiResponse.Schema = { "required": false }, "description": { - "default": "", "description": "Description of template.", "type": "string", "required": false }, "status": { - "default": "publish", "description": "Status of template.", "type": "string", "enum": [ @@ -6018,16 +5411,9 @@ mockedApiResponse.Schema = { } } } - ], - "_links": { - "self": [ - { - "href": "http://example.org/index.php?rest_route=/wp/v2/template-parts" - } - ] - } + ] }, - "/wp/v2/template-parts/lookup": { + "/wp/v2/template-parts/(?P([^\\/:<>\\*\\?\"\\|]+(?:\\/[^\\/:<>\\*\\?\"\\|]+)?)[\\/\\w%-]+)/autosaves/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ "GET" @@ -6038,40 +5424,36 @@ mockedApiResponse.Schema = { "GET" ], "args": { - "slug": { - "description": "The slug of the template to get the fallback for", + "parent": { + "description": "The id of a template", "type": "string", - "required": true + "required": false }, - "is_custom": { - "description": "Indicates if a template is custom or part of the template hierarchy", - "type": "boolean", + "id": { + "description": "The ID for the autosave.", + "type": "integer", "required": false }, - "template_prefix": { - "description": "The template prefix for the created template. This is used to extract the main template type, e.g. in `taxonomy-books` extracts the `taxonomy`", + "context": { + "description": "Scope under which the request is made; determines fields present in response.", "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", "required": false } } } - ], - "_links": { - "self": [ - { - "href": "http://example.org/index.php?rest_route=/wp/v2/template-parts/lookup" - } - ] - } + ] }, - "/wp/v2/template-parts/(?P([^\\/:<>\\*\\?\"\\|]+(?:\\/[^\\/:<>\\*\\?\"\\|]+)?)[\\/\\w%-]+)": { + "/wp/v2/template-parts": { "namespace": "wp/v2", "methods": [ "GET", - "POST", - "PUT", - "PATCH", - "DELETE" + "POST" ], "endpoints": [ { @@ -6079,11 +5461,6 @@ mockedApiResponse.Schema = { "GET" ], "args": { - "id": { - "description": "The id of a template", - "type": "string", - "required": false - }, "context": { "description": "Scope under which the request is made; determines fields present in response.", "type": "string", @@ -6094,27 +5471,35 @@ mockedApiResponse.Schema = { ], "default": "view", "required": false + }, + "wp_id": { + "description": "Limit to the specified post id.", + "type": "integer", + "required": false + }, + "area": { + "description": "Limit to the specified template part area.", + "type": "string", + "required": false + }, + "post_type": { + "description": "Post type to get the templates for.", + "type": "string", + "required": false } } }, { "methods": [ - "POST", - "PUT", - "PATCH" + "POST" ], "args": { - "id": { - "description": "The id of a template", - "type": "string", - "required": false - }, "slug": { "description": "Unique slug identifying the template.", "type": "string", "minLength": 1, "pattern": "[a-zA-Z0-9_\\%-]+", - "required": false + "required": true }, "theme": { "description": "Theme identifier for the template.", @@ -6127,6 +5512,7 @@ mockedApiResponse.Schema = { "required": false }, "content": { + "default": "", "description": "Content of template.", "type": [ "object", @@ -6153,6 +5539,7 @@ mockedApiResponse.Schema = { "required": false }, "title": { + "default": "", "description": "Title of template.", "type": [ "object", @@ -6182,11 +5569,13 @@ mockedApiResponse.Schema = { "required": false }, "description": { + "default": "", "description": "Description of template.", "type": "string", "required": false }, "status": { + "default": "publish", "description": "Status of template.", "type": "string", "enum": [ @@ -6209,190 +5598,61 @@ mockedApiResponse.Schema = { "required": false } } - }, - { - "methods": [ - "DELETE" - ], - "args": { - "id": { - "description": "The id of a template", - "type": "string", - "required": false - }, - "force": { - "type": "boolean", - "default": false, - "description": "Whether to bypass Trash and force deletion.", - "required": false - } - } } - ] - }, - "/wp/v2/template-parts/(?P[\\d]+)/revisions": { - "namespace": "wp/v2", - "methods": [ - "GET" ], - "endpoints": [ - { - "methods": [ - "GET" - ], - "args": { - "parent": { - "description": "The ID for the parent of the revision.", - "type": "integer", - "required": false - }, - "context": { - "description": "Scope under which the request is made; determines fields present in response.", - "type": "string", - "enum": [ - "view", - "embed", - "edit" - ], - "default": "view", - "required": false - }, - "page": { - "description": "Current page of the collection.", - "type": "integer", - "default": 1, - "minimum": 1, - "required": false - }, - "per_page": { - "description": "Maximum number of items to be returned in result set.", - "type": "integer", - "minimum": 1, - "maximum": 100, - "required": false - }, - "search": { - "description": "Limit results to those matching a string.", - "type": "string", - "required": false - }, - "exclude": { - "description": "Ensure result set excludes specific IDs.", - "type": "array", - "items": { - "type": "integer" - }, - "default": [], - "required": false - }, - "include": { - "description": "Limit result set to specific IDs.", - "type": "array", - "items": { - "type": "integer" - }, - "default": [], - "required": false - }, - "offset": { - "description": "Offset the result set by a specific number of items.", - "type": "integer", - "required": false - }, - "order": { - "description": "Order sort attribute ascending or descending.", - "type": "string", - "default": "desc", - "enum": [ - "asc", - "desc" - ], - "required": false - }, - "orderby": { - "description": "Sort collection by object attribute.", - "type": "string", - "default": "date", - "enum": [ - "date", - "id", - "include", - "relevance", - "slug", - "include_slugs", - "title" - ], - "required": false - } + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/template-parts" } - } - ] + ] + } }, - "/wp/v2/template-parts/(?P[\\d]+)/revisions/(?P[\\d]+)": { + "/wp/v2/template-parts/lookup": { "namespace": "wp/v2", "methods": [ - "GET", - "DELETE" + "GET" ], "endpoints": [ { "methods": [ - "GET" - ], - "args": { - "parent": { - "description": "The ID for the parent of the revision.", - "type": "integer", - "required": false - }, - "id": { - "description": "Unique identifier for the revision.", - "type": "integer", - "required": false - }, - "context": { - "description": "Scope under which the request is made; determines fields present in response.", - "type": "string", - "enum": [ - "view", - "embed", - "edit" - ], - "default": "view", - "required": false - } - } - }, - { - "methods": [ - "DELETE" - ], - "args": { - "parent": { - "description": "The ID for the parent of the revision.", - "type": "integer", - "required": false + "GET" + ], + "args": { + "slug": { + "description": "The slug of the template to get the fallback for", + "type": "string", + "required": true }, - "id": { - "description": "Unique identifier for the revision.", - "type": "integer", + "is_custom": { + "description": "Indicates if a template is custom or part of the template hierarchy", + "type": "boolean", "required": false }, - "force": { - "type": "boolean", - "default": false, - "description": "Required to be true, as revisions do not support trashing.", + "template_prefix": { + "description": "The template prefix for the created template. This is used to extract the main template type, e.g. in `taxonomy-books` extracts the `taxonomy`", + "type": "string", "required": false } } } - ] + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/template-parts/lookup" + } + ] + } }, - "/wp/v2/template-parts/(?P[\\d]+)/autosaves": { + "/wp/v2/template-parts/(?P([^\\/:<>\\*\\?\"\\|]+(?:\\/[^\\/:<>\\*\\?\"\\|]+)?)[\\/\\w%-]+)": { "namespace": "wp/v2", "methods": [ "GET", - "POST" + "POST", + "PUT", + "PATCH", + "DELETE" ], "endpoints": [ { @@ -6400,9 +5660,9 @@ mockedApiResponse.Schema = { "GET" ], "args": { - "parent": { - "description": "The ID for the parent of the autosave.", - "type": "integer", + "id": { + "description": "The id of a template", + "type": "string", "required": false }, "context": { @@ -6420,12 +5680,14 @@ mockedApiResponse.Schema = { }, { "methods": [ - "POST" + "POST", + "PUT", + "PATCH" ], "args": { - "parent": { - "description": "The ID for the parent of the autosave.", - "type": "integer", + "id": { + "description": "The id of a template", + "type": "string", "required": false }, "slug": { @@ -6528,39 +5790,21 @@ mockedApiResponse.Schema = { "required": false } } - } - ] - }, - "/wp/v2/template-parts/(?P[\\d]+)/autosaves/(?P[\\d]+)": { - "namespace": "wp/v2", - "methods": [ - "GET" - ], - "endpoints": [ + }, { "methods": [ - "GET" + "DELETE" ], "args": { - "parent": { - "description": "The ID for the parent of the autosave.", - "type": "integer", - "required": false - }, "id": { - "description": "The ID for the autosave.", - "type": "integer", + "description": "The id of a template", + "type": "string", "required": false }, - "context": { - "description": "Scope under which the request is made; determines fields present in response.", - "type": "string", - "enum": [ - "view", - "embed", - "edit" - ], - "default": "view", + "force": { + "type": "boolean", + "default": false, + "description": "Whether to bypass Trash and force deletion.", "required": false } } @@ -6702,48 +5946,229 @@ mockedApiResponse.Schema = { }, "required": false }, - "slug": { - "description": "Limit result set to posts with one or more specific slugs.", - "type": "array", - "items": { - "type": "string" - }, + "slug": { + "description": "Limit result set to posts with one or more specific slugs.", + "type": "array", + "items": { + "type": "string" + }, + "required": false + }, + "status": { + "default": "publish", + "description": "Limit result set to posts assigned one or more statuses.", + "type": "array", + "items": { + "enum": [ + "publish", + "future", + "draft", + "pending", + "private", + "trash", + "auto-draft", + "inherit", + "request-pending", + "request-confirmed", + "request-failed", + "request-completed", + "any" + ], + "type": "string" + }, + "required": false + } + } + }, + { + "methods": [ + "POST" + ], + "allow_batch": { + "v1": true + }, + "args": { + "date": { + "description": "The date the post was published, in the site's timezone.", + "type": [ + "string", + "null" + ], + "format": "date-time", + "required": false + }, + "date_gmt": { + "description": "The date the post was published, as GMT.", + "type": [ + "string", + "null" + ], + "format": "date-time", + "required": false + }, + "slug": { + "description": "An alphanumeric identifier for the post unique to its type.", + "type": "string", + "required": false + }, + "status": { + "description": "A named status for the post.", + "type": "string", + "enum": [ + "publish", + "future", + "draft", + "pending", + "private" + ], + "required": false + }, + "password": { + "description": "A password to protect access to the content and excerpt.", + "type": "string", + "required": false + }, + "title": { + "description": "The title for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Title for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit", + "embed" + ] + }, + "rendered": { + "description": "HTML title for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "content": { + "description": "The content for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Content for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit", + "embed" + ] + }, + "rendered": { + "description": "HTML content for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + }, + "block_version": { + "description": "Version of the content block format used by the post.", + "type": "integer", + "context": [ + "edit", + "embed" + ], + "readonly": true + }, + "protected": { + "description": "Whether the content is protected with a password.", + "type": "boolean", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, + "required": false + }, + "template": { + "description": "The theme file to use to display the post.", + "type": "string", + "required": false + } + } + } + ], + "_links": { + "self": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/navigation" + } + ] + } + }, + "/wp/v2/navigation/(?P[\\d]+)": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "allow_batch": { + "v1": true + }, + "args": { + "id": { + "description": "Unique identifier for the post.", + "type": "integer", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", "required": false }, - "status": { - "default": "publish", - "description": "Limit result set to posts assigned one or more statuses.", - "type": "array", - "items": { - "enum": [ - "publish", - "future", - "draft", - "pending", - "private", - "trash", - "auto-draft", - "inherit", - "request-pending", - "request-confirmed", - "request-failed", - "request-completed", - "any" - ], - "type": "string" - }, + "password": { + "description": "The password for the post if it is password protected.", + "type": "string", "required": false } } }, { "methods": [ - "POST" + "POST", + "PUT", + "PATCH" ], "allow_batch": { "v1": true }, "args": { + "id": { + "description": "Unique identifier for the post.", + "type": "integer", + "required": false + }, "date": { "description": "The date the post was published, in the site's timezone.", "type": [ @@ -6859,23 +6284,132 @@ mockedApiResponse.Schema = { "required": false } } + }, + { + "methods": [ + "DELETE" + ], + "allow_batch": { + "v1": true + }, + "args": { + "id": { + "description": "Unique identifier for the post.", + "type": "integer", + "required": false + }, + "force": { + "type": "boolean", + "default": false, + "description": "Whether to bypass Trash and force deletion.", + "required": false + } + } } + ] + }, + "/wp/v2/navigation/(?P[\\d]+)/revisions": { + "namespace": "wp/v2", + "methods": [ + "GET" ], - "_links": { - "self": [ - { - "href": "http://example.org/index.php?rest_route=/wp/v2/navigation" + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "parent": { + "description": "The ID for the parent of the revision.", + "type": "integer", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", + "required": false + }, + "page": { + "description": "Current page of the collection.", + "type": "integer", + "default": 1, + "minimum": 1, + "required": false + }, + "per_page": { + "description": "Maximum number of items to be returned in result set.", + "type": "integer", + "minimum": 1, + "maximum": 100, + "required": false + }, + "search": { + "description": "Limit results to those matching a string.", + "type": "string", + "required": false + }, + "exclude": { + "description": "Ensure result set excludes specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "include": { + "description": "Limit result set to specific IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "offset": { + "description": "Offset the result set by a specific number of items.", + "type": "integer", + "required": false + }, + "order": { + "description": "Order sort attribute ascending or descending.", + "type": "string", + "default": "desc", + "enum": [ + "asc", + "desc" + ], + "required": false + }, + "orderby": { + "description": "Sort collection by object attribute.", + "type": "string", + "default": "date", + "enum": [ + "date", + "id", + "include", + "relevance", + "slug", + "include_slugs", + "title" + ], + "required": false + } } - ] - } + } + ] }, - "/wp/v2/navigation/(?P[\\d]+)": { + "/wp/v2/navigation/(?P[\\d]+)/revisions/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ "GET", - "POST", - "PUT", - "PATCH", "DELETE" ], "endpoints": [ @@ -6883,12 +6417,14 @@ mockedApiResponse.Schema = { "methods": [ "GET" ], - "allow_batch": { - "v1": true - }, "args": { + "parent": { + "description": "The ID for the parent of the revision.", + "type": "integer", + "required": false + }, "id": { - "description": "Unique identifier for the post.", + "description": "Unique identifier for the revision.", "type": "integer", "required": false }, @@ -6902,26 +6438,71 @@ mockedApiResponse.Schema = { ], "default": "view", "required": false + } + } + }, + { + "methods": [ + "DELETE" + ], + "args": { + "parent": { + "description": "The ID for the parent of the revision.", + "type": "integer", + "required": false }, - "password": { - "description": "The password for the post if it is password protected.", + "id": { + "description": "Unique identifier for the revision.", + "type": "integer", + "required": false + }, + "force": { + "type": "boolean", + "default": false, + "description": "Required to be true, as revisions do not support trashing.", + "required": false + } + } + } + ] + }, + "/wp/v2/navigation/(?P[\\d]+)/autosaves": { + "namespace": "wp/v2", + "methods": [ + "GET", + "POST" + ], + "endpoints": [ + { + "methods": [ + "GET" + ], + "args": { + "parent": { + "description": "The ID for the parent of the autosave.", + "type": "integer", + "required": false + }, + "context": { + "description": "Scope under which the request is made; determines fields present in response.", "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", "required": false } } }, { "methods": [ - "POST", - "PUT", - "PATCH" + "POST" ], - "allow_batch": { - "v1": true - }, "args": { - "id": { - "description": "Unique identifier for the post.", + "parent": { + "description": "The ID for the parent of the autosave.", "type": "integer", "required": false }, @@ -7040,34 +6621,50 @@ mockedApiResponse.Schema = { "required": false } } - }, + } + ] + }, + "/wp/v2/navigation/(?P[\\d]+)/autosaves/(?P[\\d]+)": { + "namespace": "wp/v2", + "methods": [ + "GET" + ], + "endpoints": [ { "methods": [ - "DELETE" + "GET" ], - "allow_batch": { - "v1": true - }, "args": { + "parent": { + "description": "The ID for the parent of the autosave.", + "type": "integer", + "required": false + }, "id": { - "description": "Unique identifier for the post.", + "description": "The ID for the autosave.", "type": "integer", "required": false }, - "force": { - "type": "boolean", - "default": false, - "description": "Whether to bypass Trash and force deletion.", + "context": { + "description": "Scope under which the request is made; determines fields present in response.", + "type": "string", + "enum": [ + "view", + "embed", + "edit" + ], + "default": "view", "required": false } } } ] }, - "/wp/v2/navigation/(?P[\\d]+)/revisions": { + "/wp/v2/media": { "namespace": "wp/v2", "methods": [ - "GET" + "GET", + "POST" ], "endpoints": [ { @@ -7075,11 +6672,6 @@ mockedApiResponse.Schema = { "GET" ], "args": { - "parent": { - "description": "The ID for the parent of the revision.", - "type": "integer", - "required": false - }, "context": { "description": "Scope under which the request is made; determines fields present in response.", "type": "string", @@ -7101,6 +6693,7 @@ mockedApiResponse.Schema = { "per_page": { "description": "Maximum number of items to be returned in result set.", "type": "integer", + "default": 10, "minimum": 1, "maximum": 100, "required": false @@ -7110,6 +6703,48 @@ mockedApiResponse.Schema = { "type": "string", "required": false }, + "after": { + "description": "Limit response to posts published after a given ISO8601 compliant date.", + "type": "string", + "format": "date-time", + "required": false + }, + "modified_after": { + "description": "Limit response to posts modified after a given ISO8601 compliant date.", + "type": "string", + "format": "date-time", + "required": false + }, + "author": { + "description": "Limit result set to posts assigned to specific authors.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "author_exclude": { + "description": "Ensure result set excludes posts assigned to specific authors.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "before": { + "description": "Limit response to posts published before a given ISO8601 compliant date.", + "type": "string", + "format": "date-time", + "required": false + }, + "modified_before": { + "description": "Limit response to posts modified before a given ISO8601 compliant date.", + "type": "string", + "format": "date-time", + "required": false + }, "exclude": { "description": "Ensure result set excludes specific IDs.", "type": "array", @@ -7125,108 +6760,287 @@ mockedApiResponse.Schema = { "items": { "type": "integer" }, - "default": [], + "default": [], + "required": false + }, + "offset": { + "description": "Offset the result set by a specific number of items.", + "type": "integer", + "required": false + }, + "order": { + "description": "Order sort attribute ascending or descending.", + "type": "string", + "default": "desc", + "enum": [ + "asc", + "desc" + ], + "required": false + }, + "orderby": { + "description": "Sort collection by post attribute.", + "type": "string", + "default": "date", + "enum": [ + "author", + "date", + "id", + "include", + "modified", + "parent", + "relevance", + "slug", + "include_slugs", + "title" + ], + "required": false + }, + "parent": { + "description": "Limit result set to items with particular parent IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "parent_exclude": { + "description": "Limit result set to all items except those of a particular parent ID.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [], + "required": false + }, + "search_columns": { + "default": [], + "description": "Array of column names to be searched.", + "type": "array", + "items": { + "enum": [ + "post_title", + "post_content", + "post_excerpt" + ], + "type": "string" + }, + "required": false + }, + "slug": { + "description": "Limit result set to posts with one or more specific slugs.", + "type": "array", + "items": { + "type": "string" + }, + "required": false + }, + "status": { + "default": "inherit", + "description": "Limit result set to posts assigned one or more statuses.", + "type": "array", + "items": { + "enum": [ + "inherit", + "private", + "trash" + ], + "type": "string" + }, + "required": false + }, + "media_type": { + "default": null, + "description": "Limit result set to attachments of a particular media type.", + "type": "string", + "enum": [ + "image", + "video", + "text", + "application", + "audio" + ], + "required": false + }, + "mime_type": { + "default": null, + "description": "Limit result set to attachments of a particular MIME type.", + "type": "string", + "required": false + } + } + }, + { + "methods": [ + "POST" + ], + "args": { + "date": { + "description": "The date the post was published, in the site's timezone.", + "type": [ + "string", + "null" + ], + "format": "date-time", + "required": false + }, + "date_gmt": { + "description": "The date the post was published, as GMT.", + "type": [ + "string", + "null" + ], + "format": "date-time", + "required": false + }, + "slug": { + "description": "An alphanumeric identifier for the post unique to its type.", + "type": "string", + "required": false + }, + "status": { + "description": "A named status for the post.", + "type": "string", + "enum": [ + "publish", + "future", + "draft", + "pending", + "private" + ], + "required": false + }, + "title": { + "description": "The title for the post.", + "type": "object", + "properties": { + "raw": { + "description": "Title for the post, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML title for the post, transformed for display.", + "type": "string", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, "required": false }, - "offset": { - "description": "Offset the result set by a specific number of items.", + "author": { + "description": "The ID for the author of the post.", "type": "integer", "required": false }, - "order": { - "description": "Order sort attribute ascending or descending.", + "comment_status": { + "description": "Whether or not comments are open on the post.", "type": "string", - "default": "desc", "enum": [ - "asc", - "desc" + "open", + "closed" ], "required": false }, - "orderby": { - "description": "Sort collection by object attribute.", + "ping_status": { + "description": "Whether or not the post can be pinged.", "type": "string", - "default": "date", "enum": [ - "date", - "id", - "include", - "relevance", - "slug", - "include_slugs", - "title" + "open", + "closed" ], "required": false - } - } - } - ] - }, - "/wp/v2/navigation/(?P[\\d]+)/revisions/(?P[\\d]+)": { - "namespace": "wp/v2", - "methods": [ - "GET", - "DELETE" - ], - "endpoints": [ - { - "methods": [ - "GET" - ], - "args": { - "parent": { - "description": "The ID for the parent of the revision.", - "type": "integer", + }, + "meta": { + "description": "Meta fields.", + "type": "object", + "properties": [], "required": false }, - "id": { - "description": "Unique identifier for the revision.", - "type": "integer", + "template": { + "description": "The theme file to use to display the post.", + "type": "string", "required": false }, - "context": { - "description": "Scope under which the request is made; determines fields present in response.", + "alt_text": { + "description": "Alternative text to display when attachment is not displayed.", "type": "string", - "enum": [ - "view", - "embed", - "edit" - ], - "default": "view", "required": false - } - } - }, - { - "methods": [ - "DELETE" - ], - "args": { - "parent": { - "description": "The ID for the parent of the revision.", - "type": "integer", + }, + "caption": { + "description": "The attachment caption.", + "type": "object", + "properties": { + "raw": { + "description": "Caption for the attachment, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML caption for the attachment, transformed for display.", + "type": "string", + "context": [ + "view", + "edit", + "embed" + ], + "readonly": true + } + }, "required": false }, - "id": { - "description": "Unique identifier for the revision.", - "type": "integer", + "description": { + "description": "The attachment description.", + "type": "object", + "properties": { + "raw": { + "description": "Description for the attachment, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] + }, + "rendered": { + "description": "HTML description for the attachment, transformed for display.", + "type": "string", + "context": [ + "view", + "edit" + ], + "readonly": true + } + }, "required": false }, - "force": { - "type": "boolean", - "default": false, - "description": "Required to be true, as revisions do not support trashing.", + "post": { + "description": "The ID for the associated post of the attachment.", + "type": "integer", "required": false } } } - ] + ], + "_links": { + "self": "http://example.org/index.php?rest_route=/wp/v2/media" + } }, - "/wp/v2/navigation/(?P[\\d]+)/autosaves": { + "/wp/v2/media/(?P[\\d]+)": { "namespace": "wp/v2", "methods": [ "GET", - "POST" + "POST", + "PUT", + "PATCH", + "DELETE" ], "endpoints": [ { @@ -7234,8 +7048,8 @@ mockedApiResponse.Schema = { "GET" ], "args": { - "parent": { - "description": "The ID for the parent of the autosave.", + "id": { + "description": "Unique identifier for the post.", "type": "integer", "required": false }, @@ -7254,11 +7068,13 @@ mockedApiResponse.Schema = { }, { "methods": [ - "POST" + "POST", + "PUT", + "PATCH" ], "args": { - "parent": { - "description": "The ID for the parent of the autosave.", + "id": { + "description": "Unique identifier for the post.", "type": "integer", "required": false }, @@ -7297,11 +7113,6 @@ mockedApiResponse.Schema = { ], "required": false }, - "password": { - "description": "A password to protect access to the content and excerpt.", - "type": "string", - "required": false - }, "title": { "description": "The title for the post.", "type": "object", @@ -7310,8 +7121,7 @@ mockedApiResponse.Schema = { "description": "Title for the post, as it exists in the database.", "type": "string", "context": [ - "edit", - "embed" + "edit" ] }, "rendered": { @@ -7327,20 +7137,58 @@ mockedApiResponse.Schema = { }, "required": false }, - "content": { - "description": "The content for the post.", + "author": { + "description": "The ID for the author of the post.", + "type": "integer", + "required": false + }, + "comment_status": { + "description": "Whether or not comments are open on the post.", + "type": "string", + "enum": [ + "open", + "closed" + ], + "required": false + }, + "ping_status": { + "description": "Whether or not the post can be pinged.", + "type": "string", + "enum": [ + "open", + "closed" + ], + "required": false + }, + "meta": { + "description": "Meta fields.", + "type": "object", + "properties": [], + "required": false + }, + "template": { + "description": "The theme file to use to display the post.", + "type": "string", + "required": false + }, + "alt_text": { + "description": "Alternative text to display when attachment is not displayed.", + "type": "string", + "required": false + }, + "caption": { + "description": "The attachment caption.", "type": "object", "properties": { "raw": { - "description": "Content for the post, as it exists in the database.", + "description": "Caption for the attachment, as it exists in the database.", "type": "string", "context": [ - "edit", - "embed" + "edit" ] }, "rendered": { - "description": "HTML content for the post, transformed for display.", + "description": "HTML caption for the attachment, transformed for display.", "type": "string", "context": [ "view", @@ -7348,68 +7196,220 @@ mockedApiResponse.Schema = { "embed" ], "readonly": true + } + }, + "required": false + }, + "description": { + "description": "The attachment description.", + "type": "object", + "properties": { + "raw": { + "description": "Description for the attachment, as it exists in the database.", + "type": "string", + "context": [ + "edit" + ] }, - "block_version": { - "description": "Version of the content block format used by the post.", - "type": "integer", - "context": [ - "edit", - "embed" - ], - "readonly": true - }, - "protected": { - "description": "Whether the content is protected with a password.", - "type": "boolean", + "rendered": { + "description": "HTML description for the attachment, transformed for display.", + "type": "string", "context": [ "view", - "edit", - "embed" + "edit" ], "readonly": true } }, "required": false }, - "template": { - "description": "The theme file to use to display the post.", - "type": "string", + "post": { + "description": "The ID for the associated post of the attachment.", + "type": "integer", + "required": false + } + } + }, + { + "methods": [ + "DELETE" + ], + "args": { + "id": { + "description": "Unique identifier for the post.", + "type": "integer", + "required": false + }, + "force": { + "type": "boolean", + "default": false, + "description": "Whether to bypass Trash and force deletion.", "required": false } } } ] }, - "/wp/v2/navigation/(?P[\\d]+)/autosaves/(?P[\\d]+)": { + "/wp/v2/media/(?P[\\d]+)/post-process": { "namespace": "wp/v2", "methods": [ - "GET" + "POST" ], "endpoints": [ { "methods": [ - "GET" + "POST" ], "args": { - "parent": { - "description": "The ID for the parent of the autosave.", - "type": "integer", - "required": false - }, "id": { - "description": "The ID for the autosave.", + "description": "Unique identifier for the attachment.", "type": "integer", "required": false }, - "context": { - "description": "Scope under which the request is made; determines fields present in response.", + "action": { "type": "string", "enum": [ - "view", - "embed", - "edit" + "create-image-subsizes" ], - "default": "view", + "required": true + } + } + } + ] + }, + "/wp/v2/media/(?P[\\d]+)/edit": { + "namespace": "wp/v2", + "methods": [ + "POST" + ], + "endpoints": [ + { + "methods": [ + "POST" + ], + "args": { + "src": { + "description": "URL to the edited image file.", + "type": "string", + "format": "uri", + "required": true + }, + "modifiers": { + "description": "Array of image edits.", + "type": "array", + "minItems": 1, + "items": { + "description": "Image edit.", + "type": "object", + "required": [ + "type", + "args" + ], + "oneOf": [ + { + "title": "Rotation", + "properties": { + "type": { + "description": "Rotation type.", + "type": "string", + "enum": [ + "rotate" + ] + }, + "args": { + "description": "Rotation arguments.", + "type": "object", + "required": [ + "angle" + ], + "properties": { + "angle": { + "description": "Angle to rotate clockwise in degrees.", + "type": "number" + } + } + } + } + }, + { + "title": "Crop", + "properties": { + "type": { + "description": "Crop type.", + "type": "string", + "enum": [ + "crop" + ] + }, + "args": { + "description": "Crop arguments.", + "type": "object", + "required": [ + "left", + "top", + "width", + "height" + ], + "properties": { + "left": { + "description": "Horizontal position from the left to begin the crop as a percentage of the image width.", + "type": "number" + }, + "top": { + "description": "Vertical position from the top to begin the crop as a percentage of the image height.", + "type": "number" + }, + "width": { + "description": "Width of the crop as a percentage of the image width.", + "type": "number" + }, + "height": { + "description": "Height of the crop as a percentage of the image height.", + "type": "number" + } + } + } + } + } + ] + }, + "required": false + }, + "rotation": { + "description": "The amount to rotate the image clockwise in degrees. DEPRECATED: Use `modifiers` instead.", + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "maximum": 360, + "exclusiveMaximum": true, + "required": false + }, + "x": { + "description": "As a percentage of the image, the x position to start the crop from. DEPRECATED: Use `modifiers` instead.", + "type": "number", + "minimum": 0, + "maximum": 100, + "required": false + }, + "y": { + "description": "As a percentage of the image, the y position to start the crop from. DEPRECATED: Use `modifiers` instead.", + "type": "number", + "minimum": 0, + "maximum": 100, + "required": false + }, + "width": { + "description": "As a percentage of the image, the width to crop the image to. DEPRECATED: Use `modifiers` instead.", + "type": "number", + "minimum": 0, + "maximum": 100, + "required": false + }, + "height": { + "description": "As a percentage of the image, the height to crop the image to. DEPRECATED: Use `modifiers` instead.", + "type": "number", + "minimum": 0, + "maximum": 100, "required": false } } @@ -8780,12 +8780,12 @@ mockedApiResponse.Schema = { "enum": { "post": "post", "page": "page", - "attachment": "attachment", "nav_menu_item": "nav_menu_item", "wp_block": "wp_block", "wp_template": "wp_template", "wp_template_part": "wp_template_part", - "wp_navigation": "wp_navigation" + "wp_navigation": "wp_navigation", + "attachment": "attachment" } }, "required": false @@ -12352,36 +12352,6 @@ mockedApiResponse.TypesCollection = { ] } }, - "attachment": { - "description": "", - "hierarchical": false, - "has_archive": false, - "name": "Media", - "slug": "attachment", - "icon": "dashicons-admin-media", - "taxonomies": [], - "rest_base": "media", - "rest_namespace": "wp/v2", - "_links": { - "collection": [ - { - "href": "http://example.org/index.php?rest_route=/wp/v2/types" - } - ], - "wp:items": [ - { - "href": "http://example.org/index.php?rest_route=/wp/v2/media" - } - ], - "curies": [ - { - "name": "wp", - "href": "https://api.w.org/{rel}", - "templated": true - } - ] - } - }, "nav_menu_item": { "description": "", "hierarchical": false, @@ -12535,6 +12505,36 @@ mockedApiResponse.TypesCollection = { } ] } + }, + "attachment": { + "description": "", + "hierarchical": false, + "has_archive": false, + "name": "Media", + "slug": "attachment", + "icon": "dashicons-admin-media", + "taxonomies": [], + "rest_base": "media", + "rest_namespace": "wp/v2", + "_links": { + "collection": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/types" + } + ], + "wp:items": [ + { + "href": "http://example.org/index.php?rest_route=/wp/v2/media" + } + ], + "curies": [ + { + "name": "wp", + "href": "https://api.w.org/{rel}", + "templated": true + } + ] + } } }; From fc0e71abd0f4adc491bc8ba8e1c21eb93d208e15 Mon Sep 17 00:00:00 2001 From: Tonya Mork Date: Tue, 10 Oct 2023 15:05:34 +0000 Subject: [PATCH 43/59] Update/Install: Deactivate Gutenberg plugin version older than 16.5. This commit changes the Gutenberg minimum compatible version number from 14.1 (introduced in [54790]) to 16.5. For versions older than 16.5, the plugin will deactivate when upgrading WordPress to 6.4-beta3 or newer. Changes are done within Core's `_upgrade_core_deactivate_incompatible_plugins()` which is invoked during WordPress' upgrade process. Follow-up to [54790]. Props hellofromTonya, spacedmonkey. Fixes #59584. git-svn-id: https://develop.svn.wordpress.org/trunk@56820 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/update-core.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/includes/update-core.php b/src/wp-admin/includes/update-core.php index 51d786fa6d795..2214b1b66852b 100644 --- a/src/wp-admin/includes/update-core.php +++ b/src/wp-admin/includes/update-core.php @@ -1845,13 +1845,14 @@ function _upgrade_440_force_deactivate_incompatible_plugins() { * @since 5.8.0 * @since 5.9.0 The minimum compatible version of Gutenberg is 11.9. * @since 6.1.1 The minimum compatible version of Gutenberg is 14.1. + * @since 6.4.0 The minimum compatible version of Gutenberg is 16.5. */ function _upgrade_core_deactivate_incompatible_plugins() { - if ( defined( 'GUTENBERG_VERSION' ) && version_compare( GUTENBERG_VERSION, '14.1', '<' ) ) { + if ( defined( 'GUTENBERG_VERSION' ) && version_compare( GUTENBERG_VERSION, '16.5', '<' ) ) { $deactivated_gutenberg['gutenberg'] = array( 'plugin_name' => 'Gutenberg', 'version_deactivated' => GUTENBERG_VERSION, - 'version_compatible' => '14.1', + 'version_compatible' => '16.5', ); if ( is_plugin_active_for_network( 'gutenberg/gutenberg.php' ) ) { $deactivated_plugins = get_site_option( 'wp_force_deactivated_plugins', array() ); From 6e632c15b54f53a46c0b0713263ba82747435b43 Mon Sep 17 00:00:00 2001 From: Tammie Lister Date: Tue, 10 Oct 2023 16:12:04 +0000 Subject: [PATCH 44/59] WordPress 6.4 Beta 3. git-svn-id: https://develop.svn.wordpress.org/trunk@56821 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/version.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index c55a294362b1a..bd6eb33d3337e 100644 --- a/src/wp-includes/version.php +++ b/src/wp-includes/version.php @@ -16,7 +16,7 @@ * * @global string $wp_version */ -$wp_version = '6.4-beta2-56769-src'; +$wp_version = '6.4-beta3-src'; /** * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema. From 8e3ae9a17d5ef2587609029efd4fecdb401d3338 Mon Sep 17 00:00:00 2001 From: Tammie Lister Date: Tue, 10 Oct 2023 16:34:07 +0000 Subject: [PATCH 45/59] Post WordPress 6.4 Beta 3 version bump. git-svn-id: https://develop.svn.wordpress.org/trunk@56822 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/version.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/version.php b/src/wp-includes/version.php index bd6eb33d3337e..4eb7899cb0af6 100644 --- a/src/wp-includes/version.php +++ b/src/wp-includes/version.php @@ -16,7 +16,7 @@ * * @global string $wp_version */ -$wp_version = '6.4-beta3-src'; +$wp_version = '6.4-beta3-56822-src'; /** * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema. From 8544fe33405faaa36a76744aab221c1f197cdfea Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Tue, 10 Oct 2023 17:30:13 +0000 Subject: [PATCH 46/59] Build/Test Tools: Correct variable typo. This was causing the version of WordPress being tested to be listed as `latest` even when that is not the case. Props davidbaumwald. See #58977. git-svn-id: https://develop.svn.wordpress.org/trunk@56823 602fd350-edb4-49c9-b593-d223f7449a82 --- .github/workflows/install-testing.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/install-testing.yml b/.github/workflows/install-testing.yml index bd8dccc0b8b65..d83f56b3792a6 100644 --- a/.github/workflows/install-testing.yml +++ b/.github/workflows/install-testing.yml @@ -39,7 +39,7 @@ jobs: # - Creates a `wp-config.php` file. # - Installs WordPress. install-tests-mysql: - name: WP ${{ inputs.new-version || 'latest' }} / PHP ${{ matrix.php }} / ${{ 'mariadb' == matrix.db-type && 'MariaDB' || 'MySQL' }} ${{ matrix.db-version }}${{ matrix.multisite && ' multisite' || '' }} + name: WP ${{ inputs.wp-version || 'latest' }} / PHP ${{ matrix.php }} / ${{ 'mariadb' == matrix.db-type && 'MariaDB' || 'MySQL' }} ${{ matrix.db-version }}${{ matrix.multisite && ' multisite' || '' }} permissions: contents: read runs-on: ubuntu-latest From 03bdce844ecacd4410e3e1dcf314527740a3d225 Mon Sep 17 00:00:00 2001 From: Colin Stewart Date: Wed, 11 Oct 2023 04:32:00 +0000 Subject: [PATCH 47/59] Plugins: Fix broken `sprintf()` call in plugins list table. In [56599], a `sprintf()` call was modified which resulted in an insufficient number of arguments. This caused a Fatal Error when an incompatible plugin notice was displayed. This fixes the `sprintf()` call. Follow-up to [56599]. Props petitphp, TobiasBg, sabernhardt, mukesh27. Fixes #59590. See #57791. git-svn-id: https://develop.svn.wordpress.org/trunk@56824 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/class-wp-plugins-list-table.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/wp-admin/includes/class-wp-plugins-list-table.php b/src/wp-admin/includes/class-wp-plugins-list-table.php index d16349928dabb..5c92fba7aad1c 100644 --- a/src/wp-admin/includes/class-wp-plugins-list-table.php +++ b/src/wp-admin/includes/class-wp-plugins-list-table.php @@ -1280,8 +1280,7 @@ public function single_row( $item ) { if ( ! $compatible_php || ! $compatible_wp ) { printf( - '' . - '' . + '', esc_attr( $this->get_column_count() ) ); From f2d285e1fdf2239ba1470446679e315f56c2ae3f Mon Sep 17 00:00:00 2001 From: Colin Stewart Date: Wed, 11 Oct 2023 07:03:04 +0000 Subject: [PATCH 48/59] Docs: Use US spelling and correct a typing mistake. This changes two inline comments and a docblock so that they use US spelling as advised by the Core Handbook's Best Practices. A typing mistake is also corrected. Reference: - [https://make.wordpress.org/core/handbook/best-practices/spelling/ Core Handbook - Best Practices - Spelling]. Follow-up to [18632], [38120], [44954]. Props kebbet, mukesh27. See #58833. git-svn-id: https://develop.svn.wordpress.org/trunk@56825 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/class-wp-filesystem-base.php | 4 ++-- src/wp-admin/includes/file.php | 2 +- src/wp-includes/class-wp-http-requests-response.php | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wp-admin/includes/class-wp-filesystem-base.php b/src/wp-admin/includes/class-wp-filesystem-base.php index b20a4b99e4a97..8b291279e172e 100644 --- a/src/wp-admin/includes/class-wp-filesystem-base.php +++ b/src/wp-admin/includes/class-wp-filesystem-base.php @@ -216,13 +216,13 @@ public function find_folder( $folder ) { } } } elseif ( 'direct' === $this->method ) { - $folder = str_replace( '\\', '/', $folder ); // Windows path sanitisation. + $folder = str_replace( '\\', '/', $folder ); // Windows path sanitization. return trailingslashit( $folder ); } $folder = preg_replace( '|^([a-z]{1}):|i', '', $folder ); // Strip out Windows drive letter if it's there. - $folder = str_replace( '\\', '/', $folder ); // Windows path sanitisation. + $folder = str_replace( '\\', '/', $folder ); // Windows path sanitization. if ( isset( $this->cache[ $folder ] ) ) { return $this->cache[ $folder ]; diff --git a/src/wp-admin/includes/file.php b/src/wp-admin/includes/file.php index 7ea3619e077b0..600ddc27dfd6e 100644 --- a/src/wp-admin/includes/file.php +++ b/src/wp-admin/includes/file.php @@ -1266,7 +1266,7 @@ function download_url( $url, $timeout = 300, $signature_verification = false ) { $signature_verification = in_array( parse_url( $url, PHP_URL_HOST ), $signed_hostnames, true ); } - // Perform signature valiation if supported. + // Perform signature validation if supported. if ( $signature_verification ) { $signature = wp_remote_retrieve_header( $response, 'X-Content-Signature' ); diff --git a/src/wp-includes/class-wp-http-requests-response.php b/src/wp-includes/class-wp-http-requests-response.php index 821077656c84b..8032c54d875a7 100644 --- a/src/wp-includes/class-wp-http-requests-response.php +++ b/src/wp-includes/class-wp-http-requests-response.php @@ -8,7 +8,7 @@ */ /** - * Core wrapper object for a WpOrg\Requests\Response for standardisation. + * Core wrapper object for a WpOrg\Requests\Response for standardization. * * @since 4.6.0 * From 994d2ab480b6d3209156346e0e08bea7c7bd3f68 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Wed, 11 Oct 2023 10:41:50 +0000 Subject: [PATCH 49/59] Help/About: Ensure that focus outline on the Credits screen is not cut off. Props ivanzhuck, oglekler, wildworks, dhrumilk, audrasjb, tejadev, ankit-k-gupta, sumitbagthariya16, mukesh27, marybaum. Fixes #59033. git-svn-id: https://develop.svn.wordpress.org/trunk@56826 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/about.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/css/about.css b/src/wp-admin/css/about.css index f4eec067e2bee..607e7676485a4 100644 --- a/src/wp-admin/css/about.css +++ b/src/wp-admin/css/about.css @@ -733,7 +733,7 @@ ------------------------------------------------------------------------------*/ .about__section .wp-people-group-title { - margin-bottom: calc(var(--gap) * 2); + margin-bottom: calc(var(--gap) * 2 - 10px); text-align: center; } @@ -748,7 +748,7 @@ display: inline-block; vertical-align: top; box-sizing: border-box; - margin-bottom: var(--gap); + margin-bottom: calc(var(--gap) - 10px); width: 25%; text-align: center; } @@ -780,8 +780,10 @@ } .about__section .wp-person .web { + display: block; font-size: 1.4em; font-weight: 600; + padding: 10px 10px 0; text-decoration: none; } From 889f1a7b73bf119404a9c5e7e14524b0fceb72b1 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Wed, 11 Oct 2023 12:15:18 +0000 Subject: [PATCH 50/59] =?UTF-8?q?Build/Test=20Tools:=20Don=E2=80=99t=20sen?= =?UTF-8?q?d=20Slack=20notification=20for=20workflow=20retries.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [56780] changed Slack failure notifications to not send a failure notification during the first run of a workflow. Because workflows automatically restart once, a failure during the first run can be ignored. This adjusts the workflow to also not send a “fixed” notification when the second run of a workflow succeeds after a failure. Because the original failure is no longer reported, these notifications are unnecessary. See #58867. git-svn-id: https://develop.svn.wordpress.org/trunk@56827 602fd350-edb4-49c9-b593-d223f7449a82 --- .github/workflows/slack-notifications.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/slack-notifications.yml b/.github/workflows/slack-notifications.yml index b3d8ef663f2ae..f9b0f04723495 100644 --- a/.github/workflows/slack-notifications.yml +++ b/.github/workflows/slack-notifications.yml @@ -75,8 +75,10 @@ jobs: return 'first-failure'; } - // When a workflow has been restarted to fix a failure, check the previous run attempt. - if ( workflow_run.data.run_attempt > 1 ) { + // When a workflow has been restarted, check the previous run attempt. Because workflows are automatically + // restarted once and a failure on the first run is not reported, failures on the second run should not be + // considered. + if ( workflow_run.data.run_attempt > 2 ) { const previous_run = await github.rest.actions.getWorkflowRunAttempt({ owner: context.repo.owner, repo: context.repo.repo, From c2313f99f827f0b0cb4c4bc15bc8b40455e0faeb Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Wed, 11 Oct 2023 15:14:01 +0000 Subject: [PATCH 51/59] Editor: Add further test coverage for `wp_render_elements_support()`. Props dmsnell, aaronrobertshaw. Fixes #59578. git-svn-id: https://develop.svn.wordpress.org/trunk@56828 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/block-supports/elements.php | 3 + .../wpRenderElementsSupport.php | 63 ++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/block-supports/elements.php b/src/wp-includes/block-supports/elements.php index 770e3e95c30e1..4f3de8c5b863e 100644 --- a/src/wp-includes/block-supports/elements.php +++ b/src/wp-includes/block-supports/elements.php @@ -36,6 +36,9 @@ function wp_render_elements_support( $block_content, $block ) { } $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); + if ( ! $block_type ) { + return $block_content; + } $element_color_properties = array( 'button' => array( diff --git a/tests/phpunit/tests/block-supports/wpRenderElementsSupport.php b/tests/phpunit/tests/block-supports/wpRenderElementsSupport.php index a65f53b471b35..db5307d20743f 100644 --- a/tests/phpunit/tests/block-supports/wpRenderElementsSupport.php +++ b/tests/phpunit/tests/block-supports/wpRenderElementsSupport.php @@ -18,6 +18,39 @@ public function tear_down() { parent::tear_down(); } + /** + * Tests that block supports leaves block content alone if the block type + * isn't registered. + * + * @ticket 59578 + * + * @covers ::wp_render_elements_support + * + * @return void + */ + public function test_leaves_block_content_alone_when_block_type_not_registered() { + $block = array( + 'blockName' => 'test/element-block-supports', + 'attrs' => array( + 'style' => array( + 'elements' => array( + 'button' => array( + 'color' => array( + 'text' => 'var:preset|color|vivid-red', + 'background' => '#fff', + ), + ), + ), + ), + ), + ); + + $block_markup = '

Hello WordPress!

'; + $actual = wp_render_elements_support( $block_markup, $block ); + + $this->assertSame( $block_markup, $actual, 'Expected to leave block content unmodified, but found changes.' ); + } + /** * Tests that elements block support applies the correct classname. * @@ -64,7 +97,7 @@ public function test_elements_block_support_class( $color_settings, $elements_st $this->assertMatchesRegularExpression( $expected_markup, $actual, - 'Position block wrapper markup should be correct' + 'Block wrapper markup should be correct' ); } @@ -80,6 +113,34 @@ public function data_elements_block_support_class() { ); return array( + // @ticket 59578 + 'empty block markup remains untouched' => array( + 'color_settings' => array( + 'button' => true, + ), + 'elements_styles' => array( + 'button' => array( 'color' => $color_styles ), + ), + 'block_markup' => '', + 'expected_markup' => '/^$/', + ), + 'empty block markup remains untouched when no block attributes' => array( + 'color_settings' => array( + 'button' => true, + ), + 'elements_styles' => null, + 'block_markup' => '', + 'expected_markup' => '/^$/', + ), + 'block markup remains untouched when block has no attributes' => array( + 'color_settings' => array( + 'button' => true, + ), + 'elements_styles' => null, + 'block_markup' => '

Hello WordPress!

', + 'expected_markup' => '/^

Hello WordPress<\/a>!<\/p>$/', + ), + // @ticket 5418 'button element styles with serialization skipped' => array( 'color_settings' => array( 'button' => true, From 207cdb96cd0e63c3f4473f440619affef0c51c21 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Wed, 11 Oct 2023 15:54:52 +0000 Subject: [PATCH 52/59] Build/Test Tools: Increase the number of retries when restarting a workflow. This increases the number of times to retry restarting a workflow from 2 to 10. Retries use exponential backoff to space out retries. In most cases, only 2 retries was sufficient. However, there are occasionally scenarios where the original workflow is still running and cannot be restarted, resulting in an error. This typically happens during periods of ongoing service degradation, or workflows with a significant number of jobs (GitHub Actions is sometimes very slow to mark these as finished). See #58867. git-svn-id: https://develop.svn.wordpress.org/trunk@56829 602fd350-edb4-49c9-b593-d223f7449a82 --- .github/workflows/failed-workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/failed-workflow.yml b/.github/workflows/failed-workflow.yml index ebc305568ccad..35d0e8a2e3d56 100644 --- a/.github/workflows/failed-workflow.yml +++ b/.github/workflows/failed-workflow.yml @@ -32,7 +32,7 @@ jobs: - name: Rerun a workflow uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1 with: - retries: 2 + retries: 10 retry-exempt-status-codes: 418 script: | const workflow_run = await github.rest.actions.getWorkflowRun({ From 46d4273f064f3085ee1bebbd953c4eb7db06d84f Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Wed, 11 Oct 2023 17:13:12 +0000 Subject: [PATCH 53/59] Build/Test Tools: Increase the timeout for the failed workflow. Following [56829], the previous `timeout-minutes` value of `5` is insufficient when approaching 10 retries. See #58867. git-svn-id: https://develop.svn.wordpress.org/trunk@56830 602fd350-edb4-49c9-b593-d223f7449a82 --- .github/workflows/failed-workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/failed-workflow.yml b/.github/workflows/failed-workflow.yml index 35d0e8a2e3d56..f92d4b8102e9f 100644 --- a/.github/workflows/failed-workflow.yml +++ b/.github/workflows/failed-workflow.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest permissions: actions: write - timeout-minutes: 5 + timeout-minutes: 20 steps: - name: Rerun a workflow From 27be85412e5c59754da4ec11a1d42eed5ac214f7 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Wed, 11 Oct 2023 18:07:45 +0000 Subject: [PATCH 54/59] Build/Test Tools: Remove hardcoded PHPUnit config files. The configuration file passed to the callable workflow contains the correct one to use. See #58955. git-svn-id: https://develop.svn.wordpress.org/trunk@56831 602fd350-edb4-49c9-b593-d223f7449a82 --- .github/workflows/phpunit-tests-run.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/phpunit-tests-run.yml b/.github/workflows/phpunit-tests-run.yml index 1609c7cbdbf5b..eb3eab76e7617 100644 --- a/.github/workflows/phpunit-tests-run.yml +++ b/.github/workflows/phpunit-tests-run.yml @@ -158,11 +158,11 @@ jobs: - name: Run ms-files tests as a multisite install if: ${{ inputs.multisite }} - run: node ./tools/local-env/scripts/docker.js run php ./vendor/bin/phpunit --verbose -c tests/phpunit/multisite.xml --group ms-files + run: node ./tools/local-env/scripts/docker.js run php ./vendor/bin/phpunit --verbose -c ${{ env.PHPUNIT_CONFIG }} --group ms-files - name: Run external HTTP tests if: ${{ ! inputs.multisite }} - run: node ./tools/local-env/scripts/docker.js run php ./vendor/bin/phpunit --verbose -c phpunit.xml.dist --group external-http + run: node ./tools/local-env/scripts/docker.js run php ./vendor/bin/phpunit --verbose -c ${{ env.PHPUNIT_CONFIG }} --group external-http # __fakegroup__ is excluded to force PHPUnit to ignore the settings in phpunit.xml.dist. - name: Run (Xdebug) tests From 4172c12137d1a00f3fe16a96636e2eaf2a7085ae Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Thu, 12 Oct 2023 10:15:36 +0000 Subject: [PATCH 55/59] Twenty Nineteen: Add margins to editor iframe content. At screen widths of at least 768px (tablet size), Twenty Nineteen should have a `max-width` and side margins for content in the post editor. This commit applies that style to the new `.block-editor-iframe__body` class, resolving an issue where full-width blocks had a horizontal scrollbar in the editor iframe. Props sabernhardt, smit08, nidhidhandhukiya, mukesh27, darshitrajyaguru97, oglekler, poena, shailu25, nicolefurlan. Fixes #59449. git-svn-id: https://develop.svn.wordpress.org/trunk@56832 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-content/themes/twentynineteen/style-editor.css | 2 +- src/wp-content/themes/twentynineteen/style-editor.scss | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/wp-content/themes/twentynineteen/style-editor.css b/src/wp-content/themes/twentynineteen/style-editor.css index 3dd9c1eb8ae40..54487026e231a 100644 --- a/src/wp-content/themes/twentynineteen/style-editor.css +++ b/src/wp-content/themes/twentynineteen/style-editor.css @@ -605,7 +605,7 @@ body .wp-block.aligncenter { } @media only screen and (min-width: 768px) { - body.block-editor-writing-flow, + body.block-editor-iframe__body, body.block-editor-writing-flow, body .block-editor-writing-flow { max-width: 80%; margin: 0 10%; diff --git a/src/wp-content/themes/twentynineteen/style-editor.scss b/src/wp-content/themes/twentynineteen/style-editor.scss index 2c129c809ad28..ce8a2883f8318 100644 --- a/src/wp-content/themes/twentynineteen/style-editor.scss +++ b/src/wp-content/themes/twentynineteen/style-editor.scss @@ -36,6 +36,7 @@ body { @include media(tablet) { + &.block-editor-iframe__body, &.block-editor-writing-flow, .block-editor-writing-flow { max-width: 80%; From 15049d61b0517090d545b6472e1802ec73d411d9 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 12 Oct 2023 14:15:23 +0200 Subject: [PATCH 56/59] Mention UI mode in readme --- tests/e2e/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 26f20d1bda560..1081cfbe57ed4 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -24,12 +24,15 @@ WP_BASE_URL=http://mycustomurl WP_USERNAME=username WP_PASSWORD=password npm run ``` **DO NOT run these tests in an actual production environment, as they will delete all your content.** -For debugging purposes, you might want to follow the test visually. You can do so by running the tests in an interactive mode. +For debugging purposes, you might want to follow the test visually. You can do so by running the tests in an interactive mode: ``` -npm run test:e2e -- --debug +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: ``` From eba1afa193903cf812d0655687132de3a5b0a38e Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 12 Oct 2023 15:59:16 +0200 Subject: [PATCH 57/59] Revert r56198 Partially reverts https://core.trac.wordpress.org/changeset/56198/ --- .github/workflows/end-to-end-tests.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index c37c8cf4f2a59..8266e74e26244 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -137,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 From aa9fb1c5dd2ef3e77ba9e6e291ba08131b4833ad Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 12 Oct 2023 16:00:18 +0200 Subject: [PATCH 58/59] Apply r56660 See https://core.trac.wordpress.org/changeset/56660/ --- .github/workflows/end-to-end-tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index 8266e74e26244..64001ad5cafd5 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -165,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 From ba8db30aad0618e48ad9253f2b49eb03067c472a Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 12 Oct 2023 16:00:23 +0200 Subject: [PATCH 59/59] Fix env vars --- .github/workflows/performance.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index dbd96c0b416b2..d936c62d19ae4 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -31,10 +31,10 @@ permissions: {} env: # Performance testing should be performed in an environment reflecting a standard production environment. - WP_DEBUG: false - SCRIPT_DEBUG: false - SAVEQUERIES : false - WP_DEVELOPMENT_MODE: '' + LOCAL_WP_DEBUG: false + LOCAL_SCRIPT_DEBUG: false + LOCAL_SAVEQUERIES: false + LOCAL_WP_DEVELOPMENT_MODE: "''" # This workflow takes two sets of measurements — one for the current commit, # and another against a consistent version that is used as a baseline measurement.