diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index 414b2b1..d6b8638 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -70,10 +70,30 @@ jobs: version: 'WordPress/WordPress#master', number: 'trunk', } + - { + name: 'WP 7.1', + version: 'WordPress/WordPress#7.1-branch', + number: '7.1', + } + - { + name: 'WP 7.0', + version: 'WordPress/WordPress#7.0-branch', + number: '7.0', + } + - { + name: 'WP 6.9', + version: 'WordPress/WordPress#6.9-branch', + number: '6.9', + } + - { + name: 'WP 6.8', + version: 'WordPress/WordPress#6.8-branch', + number: '6.8', + } - { name: 'WP 6.7', version: 'WordPress/WordPress#6.7-branch', - number: '6.7' + number: '6.7', } - { name: 'WP 6.6', diff --git a/.gitignore b/.gitignore index 8d26efa..d11a9d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ tests/cypress/screenshots tests/cypress/videos tests/cypress/reports +tests/cypress/downloads # wp-env files .wp-env.override.json diff --git a/run-all-cores.sh b/run-all-cores.sh index 41e9eed..38cdd01 100755 --- a/run-all-cores.sh +++ b/run-all-cores.sh @@ -1,6 +1,6 @@ #!/bin/bash -MAJOR_VERSIONS="5.7 5.8 5.9 6.0 6.1 6.2 6.3 6.4 6.5 6.6 6.7" +MAJOR_VERSIONS="5.7 5.8 5.9 6.0 6.1 6.2 6.3 6.4 6.5 6.6 6.7 6.8 6.9 7.0 7.1" TRUNK="master:trunk" VERSIONS="" diff --git a/src/commands/close-welcome-guide.ts b/src/commands/close-welcome-guide.ts index e03f82a..00dcfad 100644 --- a/src/commands/close-welcome-guide.ts +++ b/src/commands/close-welcome-guide.ts @@ -1,3 +1,12 @@ +/** + * Whether an editor load has already been waited on. + * + * The guide is only ever shown on the first editor load for a user, so the wait + * below only needs to happen until one load has been given the chance to show + * it. Every load after that just needs the cheap look at the DOM. + */ +let guideWaitedFor = false; + /** * Close Welcome Guide * @@ -8,15 +17,58 @@ */ export const closeWelcomeGuide = (): void => { const titleInput = 'h1.editor-post-title__input, #post-title-0'; - const closeButtonSelector = - '.edit-post-welcome-guide .components-modal__header button'; + const guideSelector = '.edit-post-welcome-guide'; + const closeButtonSelector = `${guideSelector} .components-modal__header button`; + + // How long to give the guide to turn up, and how often to look for it. + const guideTimeout = 1000; + const pollInterval = 50; // Wait for edit page to load - cy.getBlockEditor().find(titleInput).should('exist'); + cy.getBlockEditor().find(titleInput, { timeout: 10000 }).should('exist'); + + /* + * Poll for the guide rather than checking for it once. + * + * The guide is rendered into a portal, so it can land in the DOM a moment + * after the title it covers. Reading the DOM a single time can therefore run + * too early, find nothing, and leave the modal open - which then fails + * whatever tries to interact with the editor next, well away from here. + * + * The poll returns as soon as the guide shows up, so the wait is only ever + * paid in full on the one editor load that has no guide to close. + */ + cy.document() + .then(doc => { + // Read inside the callback: a test queues all of its commands up front, + // so checking this while the queue is being built would always see the + // value it had before any of the commands ran. + const timeout = guideWaitedFor ? 0 : guideTimeout; + guideWaitedFor = true; + + return new Cypress.Promise(resolve => { + let waited = 0; + + const pollForGuide = () => { + if (doc.querySelector(closeButtonSelector)) { + resolve(true); + } else if (waited >= timeout) { + resolve(false); + } else { + waited += pollInterval; + setTimeout(pollForGuide, pollInterval); + } + }; + + pollForGuide(); + }); + }) + .then(isGuideOpen => { + if (isGuideOpen) { + cy.get(closeButtonSelector).click(); - cy.get('body').then($body => { - if ($body.find(closeButtonSelector).length > 0) { - cy.get(closeButtonSelector).click(); - } - }); + // Make sure it is gone before handing back to the caller. + cy.get(guideSelector).should('not.exist'); + } + }); }; diff --git a/src/commands/delete-all-terms.ts b/src/commands/delete-all-terms.ts index 955f48b..65991be 100644 --- a/src/commands/delete-all-terms.ts +++ b/src/commands/delete-all-terms.ts @@ -18,29 +18,28 @@ export const deleteAllTerms = (taxonomy = 'category'): void => { cy.visit(`/wp-admin/edit-tags.php?taxonomy=${taxonomy}`); + /** + * Only attempt the bulk delete when there is at least one deletable term. + * + * WP 7.1 hides the bulk actions controls (`display: none`) while the list is + * empty, so relying on the presence of `#bulk-action-selector-top` is not + * enough - it exists in the DOM but cannot be interacted with. + * + * The 'Uncategorized' item could not be deleted and does not have a checkbox, + * which makes the row checkboxes a reliable signal on every version. + */ + const deletableTerms = + '#the-list input[type="checkbox"][name="delete_tags[]"]'; + cy.get('body').then($body => { - if ($body.find('#cb-select-all-1').length !== 0) { + if ($body.find(deletableTerms).length !== 0) { cy.get('#cb-select-all-1').click(); - } - - if ($body.find('#bulk-action-selector-top').length !== 0) { cy.get('#bulk-action-selector-top').select('delete'); cy.get('#doaction').click(); - /** - * Check if the result page contain any terms - * available to delete by searching for individual - * checkboxes and perform recursive call. - * - * The 'Uncategorized' item could not be deleted - * and does not have the checkbox. - */ + // Paginated lists need another pass to clear the remaining pages. cy.get('body').then($updatedBody => { - if ( - $updatedBody.find( - '#the-list input[type="checkbox"][name="delete_tags[]"]' - ).length !== 0 - ) { + if ($updatedBody.find(deletableTerms).length !== 0) { deleteAllTerms(taxonomy); } }); diff --git a/src/commands/insert-block.ts b/src/commands/insert-block.ts index 6ad7c8f..ebfe079 100644 --- a/src/commands/insert-block.ts +++ b/src/commands/insert-block.ts @@ -25,7 +25,7 @@ export const insertBlock = (type: string, name?: string): void => { blockNames = blockNames.filter((x, i, a) => a.indexOf(x) == i); // let blockName = blockNameRest.join('/').replace( '/', '\\/' ); - let inserterBtn: Cypress.Chainable>; + let $inserterBtn: JQuery | null = null; let search = ''; if (typeof name === 'string' && name.length) { @@ -44,12 +44,14 @@ export const insertBlock = (type: string, name?: string): void => { selectors.forEach(selector => { if ($body.find(selector).length) { - cy.get(selector).then($button => { - if ($button.length) { - inserterBtn = cy.wrap($button); - inserterBtn.first().click(); - } - }); + // Keep the element around rather than the chainable, so the inserter can + // be closed again further down without replaying this command. + cy.get(selector) + .first() + .then($button => { + $inserterBtn = $button; + cy.wrap($button).click(); + }); } }); }); @@ -73,50 +75,62 @@ export const insertBlock = (type: string, name?: string): void => { }); // End of Block search logic. - blockNames.forEach(blockName => { - const blockSelector = `.editor-block-list-item-${ + const selectorsFor = (blockName: string) => [ + `.editor-block-list-item-${ + 'core' === namespace ? '' : namespace + '-' + }${blockName}`, + `.editor-block-list-item-${ 'core' === namespace ? '' : namespace + '-' - }${blockName}`; + }${blockName}\\/${blockName}`, // Briefly in 6.9 for default variants. + ]; - cy.get('body').then($body => { - if ($body.find(blockSelector).length) { - // Start of Block insertion by click logic. - cy.get(blockSelector).then($block => { - if ($block.length) { - cy.wrap($block).click(); - inserterBtn.click(); + /* + * The inserter renders its search results asynchronously, and searching also + * kicks off a block directory request that re-renders the panel afterwards. + * Reading the list synchronously therefore either finds nothing - silently + * skipping the insertion - or hands back an element that is detached by the + * time it gets clicked. Both have to be left to Cypress to retry. + */ + const blockSelectors: string[] = []; + blockNames.forEach(blockName => { + blockSelectors.push(...selectorsFor(blockName)); + }); - const [ns, rest] = type.split('/'); // namespace = ns, second namespace or block name = rest + // Start of Block insertion by click logic. + // All of the candidate spellings are queried at once so that Cypress keeps + // retrying - and re-queries the element if the panel re-renders and detaches + // it - instead of reading the list once and moving on. + cy.get(blockSelectors.join(',')).first().click(); - cy.get('body').then($body => { - if ($body.find('iframe[name="editor-canvas"]').length) { - // Works with WP 6.4 - getIframe('iframe[name="editor-canvas"]').then($iframe => { - const blockInIframe = $iframe.find( - `.wp-block[data-type="${ns}/${rest}"]` - ); - if (blockInIframe.length > 0) { - cy.wrap(blockInIframe.last().prop('id')); - } - }); - } else if ( - $body.find(`.wp-block[data-type="${ns}/${rest}"]`).length - ) { - // Works with WP 5.7 - cy.get(`.wp-block[data-type="${ns}/${rest}"]`).then( - $blockInEditor => { - expect($blockInEditor.length).to.equal(1); - cy.wrap($blockInEditor.prop('id')); - } - ); - } else { - throw new Error(`${ns}/${rest} not found.`); - } - }); - } - }); - // End of Block insertion by click logic. - } - }); + // Close the inserter again. + cy.then(() => { + if ($inserterBtn) { + cy.wrap($inserterBtn).click(); + } + }); + // End of Block insertion by click logic. + + const [ns, rest] = type.split('/'); // namespace = ns, second namespace or block name = rest + + cy.get('body').then($body => { + if ($body.find('iframe[name="editor-canvas"]').length) { + // Works with WP 6.4 + getIframe('iframe[name="editor-canvas"]').then($iframe => { + const blockInIframe = $iframe.find( + `.wp-block[data-type="${ns}/${rest}"]` + ); + if (blockInIframe.length > 0) { + cy.wrap(blockInIframe.last().prop('id')); + } + }); + } else if ($body.find(`.wp-block[data-type="${ns}/${rest}"]`).length) { + // Works with WP 5.7 + cy.get(`.wp-block[data-type="${ns}/${rest}"]`).then($blockInEditor => { + expect($blockInEditor.length).to.equal(1); + cy.wrap($blockInEditor.prop('id')); + }); + } else { + throw new Error(`${ns}/${rest} not found.`); + } }); }; diff --git a/src/commands/open-document-settings-sidebar.ts b/src/commands/open-document-settings-sidebar.ts index f88b506..c2bcce8 100644 --- a/src/commands/open-document-settings-sidebar.ts +++ b/src/commands/open-document-settings-sidebar.ts @@ -16,27 +16,33 @@ * ``` */ export const openDocumentSettingsSidebar = (tab = 'Post'): void => { - cy.get('body').then($body => { - const $settingButtonIds = [ - 'button[aria-expanded="false"][aria-label="Settings"]', - ]; + const $settingButtonIds = [ + 'button[aria-expanded="false"][aria-label="Settings"]', + ]; + + const $tabSelectors = [ + `div[role="tablist"] button:contains("${tab}")`, + `.edit-post-sidebar__panel-tabs button:contains("${tab}")`, + ]; + // Open the sidebar in its own command so the tab lookup below runs against + // the DOM as it is *after* the sidebar has been rendered. The tabs do not + // exist while the sidebar is closed, so looking them up in the same callback + // would always come up empty and leave the `selectedTab` alias unset. + cy.get('body').then($body => { $settingButtonIds.forEach($settingButtonId => { if ($body.find($settingButtonId).length) { - cy.get($settingButtonId).first().click(); - cy.wrap($body.find($settingButtonId).first()).as('sidebarButton'); + cy.get($settingButtonId).first().as('sidebarButton'); + cy.get('@sidebarButton').click(); } }); + }); - const $tabSelectors = [ - `div[role="tablist"] button:contains("${tab}")`, - `.edit-post-sidebar__panel-tabs button:contains("${tab}")`, - ]; - + cy.get('body').then($body => { $tabSelectors.forEach($tabSelector => { if ($body.find($tabSelector).length) { - cy.get($tabSelector).first().click(); - cy.wrap($body.find($tabSelector).first()).as('selectedTab'); + cy.get($tabSelector).first().as('selectedTab'); + cy.get('@selectedTab').click(); } }); }); diff --git a/src/functions/get-iframe.ts b/src/functions/get-iframe.ts index 73878fc..e82c1ad 100644 --- a/src/functions/get-iframe.ts +++ b/src/functions/get-iframe.ts @@ -111,9 +111,36 @@ export const getIframe: Cypress.Chainable['iframe'] = ( }).snapshot() : null; - return frameLoaded(selector, { ...fullOpts, log: false }).then($frame => { - log?.set('$el', $frame).end(); - const contentWindow: Window = $frame.prop('contentWindow'); - return Cypress.$(contentWindow.document.body as HTMLBodyElement); - }); + return frameLoaded(selector, { ...fullOpts, log: false }).then( + { timeout: fullOpts.timeout }, + async $frame => { + log?.set('$el', $frame).end(); + + const frameSelector = selector as string; + const getBody = ($el: JQuery) => { + const contentWindow: Window | null = $el.prop('contentWindow'); + return contentWindow?.document?.body ?? null; + }; + + /* + * Wait for the body to be parsed before handing it back. + * + * The Block Editor canvas is loaded from a `blob:` URL, so the document + * can already report itself as loaded while `document.body` is still + * null. Returning that would yield an empty jQuery collection and fail + * any following assertion. + * + * The frame is re-queried on every pass because the editor may swap it + * out while it renders, which detaches the element we started with. + */ + let body = getBody($frame); + + while (!body) { + await sleep(100); + body = getBody(Cypress.$(frameSelector)); + } + + return Cypress.$(body as HTMLBodyElement); + } + ); }; diff --git a/src/index.ts b/src/index.ts index d8fbbef..0152714 100644 --- a/src/index.ts +++ b/src/index.ts @@ -65,6 +65,17 @@ declare global { } } +// Ignore promises rejected due to missed transitions. +// These are common on CI environments due to the speed of the tests and the environment. +Cypress.on('uncaught:exception', err => { + if ( + err?.name === 'AbortError' && + err?.message?.includes('Transition was skipped') + ) { + return false; + } +}); + // Register commands Cypress.Commands.add('checkPostExists', checkPostExists); Cypress.Commands.add('classicCreatePost', classicCreatePost); diff --git a/tests/cypress/e2e/create-post.test.js b/tests/cypress/e2e/create-post.test.js index 4b5a0e0..6fd98ce 100644 --- a/tests/cypress/e2e/create-post.test.js +++ b/tests/cypress/e2e/create-post.test.js @@ -30,7 +30,9 @@ describe('Command: createPost', () => { }); cy.visit('/wp-admin/edit.php?orderby=date&order=desc'); - cy.get('#the-list td.title a.row-title').first().should('have.text', title); + cy.get('#the-list .column-title a.row-title') + .first() + .should('have.text', title); }); it('Should be able to create Draft Post', () => { @@ -42,7 +44,7 @@ describe('Command: createPost', () => { }); cy.visit('/wp-admin/edit.php?orderby=date&order=desc'); - cy.get('#the-list td.title') + cy.get('#the-list .column-title') .first() .then($row => { cy.wrap($row).find('a.row-title').should('have.text', title); @@ -59,7 +61,9 @@ describe('Command: createPost', () => { }); cy.visit('/wp-admin/edit.php?post_type=page&orderby=date&order=desc'); - cy.get('#the-list td.title a.row-title').first().should('have.text', title); + cy.get('#the-list .column-title a.row-title') + .first() + .should('have.text', title); }); it('Should be able to create Draft Page', () => { @@ -72,7 +76,7 @@ describe('Command: createPost', () => { }); cy.visit('/wp-admin/edit.php?post_type=page&orderby=date&order=desc'); - cy.get('#the-list td.title') + cy.get('#the-list .column-title') .first() .then($row => { cy.wrap($row).find('a.row-title').should('have.text', title); @@ -128,7 +132,7 @@ describe('Command: createPost', () => { }); cy.visit('/wp-admin/edit.php?post_type=post'); - cy.get('td.title') + cy.get('.column-title') .contains(postTitle) .parent() .find('.post-state') @@ -157,11 +161,15 @@ describe('Command: createPost', () => { }); cy.visit('/wp-admin/edit.php?orderby=date&order=desc'); - cy.get('#the-list td.title a.row-title') + cy.get('#the-list .column-title a.row-title') .first() .should(element => { + // WP 7.1 appends a trimmed excerpt to the placeholder title. + const $title = element.clone(); + $title.find('.trimmed-post-excerpt').remove(); + // WordPress changed the default title for posts without a title at some point. - expect(element.text()).to.be.oneOf(['(no title)', 'Untitled']); + expect($title.text().trim()).to.be.oneOf(['(no title)', 'Untitled']); }); }); diff --git a/tests/cypress/e2e/create-term.test.js b/tests/cypress/e2e/create-term.test.js index 0edbb13..8aefd26 100644 --- a/tests/cypress/e2e/create-term.test.js +++ b/tests/cypress/e2e/create-term.test.js @@ -112,7 +112,7 @@ describe('Command: createTerm', () => { }, }); - cy.get('td.name') + cy.get('.column-name') .contains(termName) .parents('tr') .find('.slug') diff --git a/tests/cypress/e2e/insert-block.test.js b/tests/cypress/e2e/insert-block.test.js index 6cf7c87..e0251ac 100644 --- a/tests/cypress/e2e/insert-block.test.js +++ b/tests/cypress/e2e/insert-block.test.js @@ -89,8 +89,9 @@ describe('Command: insertBlock', () => { it('Should be able to insert custom block', () => { if ( - 'trunk' !== Cypress.env('WORDPRESS_CORE').toString() && - compare(Cypress.env('WORDPRESS_CORE').toString(), '6.1', '<') + !Cypress.env('WORDPRESS_CORE') || + ('trunk' !== Cypress.env('WORDPRESS_CORE').toString() && + compare(Cypress.env('WORDPRESS_CORE').toString(), '6.1', '<')) ) { // WinAmp block does not support this version of WordPress. assert(true, 'Skipping test, WinAmp block does not exist');