diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index c3ffa1604f..8e9efae158 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -150,5 +150,7 @@ jobs: if: failure() uses: actions/upload-artifact@v4 with: + name: traces-${{ matrix.specs }}-${{ matrix.envs }} path: ./test-results/** + if-no-files-found: ignore retention-days: 1 diff --git a/bin/ss-pages.test.stub b/bin/ss-pages.test.stub index c761c2303a..16477c07c3 100755 --- a/bin/ss-pages.test.stub +++ b/bin/ss-pages.test.stub @@ -4,7 +4,7 @@ describe( 'Starter Sites VR - {{site}}', () => { let frontpage = "{{site}}" cy.visit(frontpage ); cy.captureDocument(); - cy.get( '#nv-primary-navigation-main' ).then( $headerMenu => { + cy.get( '[id^="nv-primary-navigation"]' ).then( $headerMenu => { [ ...$headerMenu.find( '.menu-item a' ) ].forEach( $url => { let url = $url.href; if(url.includes("#")){ @@ -13,8 +13,11 @@ describe( 'Starter Sites VR - {{site}}', () => { if(frontpage.replace(/\/*$/, "") === url.replace(/\/*$/, "")){ return; } + if(pages.includes(url)){ + return; + } - pages.push( $url.href ); + pages.push( url ); } ); } ); } ); diff --git a/e2e-tests/fixtures/customizer/hfg/footer-menu-both-devices-setup.json b/e2e-tests/fixtures/customizer/hfg/footer-menu-both-devices-setup.json new file mode 100644 index 0000000000..7845b249f7 --- /dev/null +++ b/e2e-tests/fixtures/customizer/hfg/footer-menu-both-devices-setup.json @@ -0,0 +1,4 @@ +{ + "hfg_footer_layout_v2": "{\"desktop\":{\"top\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]},\"main\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]},\"bottom\":{\"left\":[],\"c-left\":[],\"center\":[{\"id\":\"footer-menu\"}],\"c-right\":[],\"right\":[]}},\"mobile\":{\"top\":{\"left\":[],\"c-left\":[],\"center\":[{\"id\":\"footer-menu\"}],\"c-right\":[],\"right\":[]},\"main\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]},\"bottom\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]}}}", + "nav_menu_locations[footer]": 177 +} diff --git a/e2e-tests/fixtures/customizer/hfg/primary-menu-both-devices-setup.json b/e2e-tests/fixtures/customizer/hfg/primary-menu-both-devices-setup.json new file mode 100644 index 0000000000..2ea0e0f511 --- /dev/null +++ b/e2e-tests/fixtures/customizer/hfg/primary-menu-both-devices-setup.json @@ -0,0 +1,4 @@ +{ + "hfg_header_layout_v2": "{\"desktop\":{\"top\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]},\"main\":{\"left\":[{\"id\":\"logo\"}],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[{\"id\":\"primary-menu\"}]},\"bottom\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]}},\"mobile\":{\"top\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]},\"main\":{\"left\":[{\"id\":\"logo\"}],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[{\"id\":\"primary-menu\"},{\"id\":\"nav-icon\"}]},\"bottom\":{\"left\":[],\"c-left\":[],\"center\":[],\"c-right\":[],\"right\":[]},\"sidebar\":[{\"id\":\"primary-menu\"}]}}", + "nav_menu_locations[primary]": 177 +} diff --git a/e2e-tests/playwright.config.ts b/e2e-tests/playwright.config.ts index a60069d819..4dfdc0a77e 100644 --- a/e2e-tests/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -1,11 +1,18 @@ import { defineConfig, devices } from '@playwright/test'; +// Plain `|| fallback` would discard an explicit 0. +const envInt = (name: string, fallback: number) => { + const parsed = parseInt(process.env[name] || '', 10); + return Number.isNaN(parsed) ? fallback : parsed; +}; + export default defineConfig({ reporter: process.env.CI ? 'github' : 'list', forbidOnly: !!process.env.CI, - workers: process.env.CI ? 6 : undefined, - retries: 0, - timeout: parseInt(process.env.TIMEOUT || '', 10) || 150_000, // Defaults to 100 seconds. + // Same values locally and in CI, so a race reproduces in both. + workers: envInt('PW_WORKERS', 2), + retries: envInt('PW_RETRIES', 2), + timeout: envInt('TIMEOUT', 150_000), // Defaults to 100 seconds. fullyParallel: true, projects: [ // Setup project @@ -26,5 +33,7 @@ export default defineConfig({ headless: true, ignoreHTTPSErrors: true, trace: 'retain-on-failure', + actionTimeout: 20_000, + navigationTimeout: 45_000, }, }); diff --git a/e2e-tests/specs/accessibility/aria.spec.ts b/e2e-tests/specs/accessibility/aria.spec.ts index f146bea5f3..60d3a10bae 100644 --- a/e2e-tests/specs/accessibility/aria.spec.ts +++ b/e2e-tests/specs/accessibility/aria.spec.ts @@ -16,9 +16,12 @@ const runMenuARIATest = (deviceType = 'mobile') => { ).toHaveAttribute('aria-expanded', 'true'); // Close the menu from the overlay. Check ARIA attribute for expanded state is false when menu is closed. + // The overlay spans the viewport and the open sidebar covers its centre, so + // a real click there lands on a menu link and navigates away. Dispatching + // hits the same handler without depending on which side the sidebar is on. await page .locator('.header-menu-sidebar-overlay') - .click({ force: true }); + .dispatchEvent('click'); await expect( page.getByRole('button', { name: 'Navigation Menu' }) ).toHaveAttribute('aria-expanded', 'false'); diff --git a/e2e-tests/specs/admin/tpc-notice-install.spec.ts b/e2e-tests/specs/admin/tpc-notice-install.spec.ts index 74e29eb3fa..178cf4ddc9 100644 --- a/e2e-tests/specs/admin/tpc-notice-install.spec.ts +++ b/e2e-tests/specs/admin/tpc-notice-install.spec.ts @@ -1,11 +1,36 @@ import { test, expect } from '@playwright/test'; -import { visitAdminPage } from '../../utils'; +import { setCustomizeSettings, visitAdminPage } from '../../utils'; + +const TPC_PLUGIN = + 'templates-patterns-collection/templates-patterns-collection'; +const TEST_NAME = 'tpcNotice'; test.describe('Dashboard Notice', () => { + test.beforeAll(async ({ request, baseURL }) => { + const endpoint = `${baseURL}/wp-json/wp/v2/plugins/${TPC_PLUGIN}`; + + await request + .put(endpoint, { data: { status: 'inactive' } }) + .catch(() => null); + await request.delete(endpoint).catch(() => null); + + // Overridden per-request via ?test_name=, not written to the options table. + await setCustomizeSettings( + TEST_NAME, + { + options: { + neve_notice_dismissed: 'no', + neve_install: Math.floor(Date.now() / 1000), + }, + }, + { request, baseURL } + ); + }); + test('Starter Sites Plugin install from Dashboard Notice', async ({ page, }) => { - await visitAdminPage(page, 'index.php', ''); + await visitAdminPage(page, 'index.php', `test_name=${TEST_NAME}`); await expect(page).toHaveURL(/wp-admin\/index.php/); @@ -25,9 +50,7 @@ test.describe('Dashboard Notice', () => { ); // Welcome screen - await expect(page.locator('h1')).toContainText( - 'Choose a design' - ); + await expect(page.locator('h1')).toContainText('Choose a design'); const categories = await page.locator('.ob-cat-wrap .cat'); await expect(categories).toContainText([ diff --git a/e2e-tests/specs/customizer/general/custom-global-colors.spec.ts b/e2e-tests/specs/customizer/general/custom-global-colors.spec.ts index bc0cf18b04..bccdb88f37 100644 --- a/e2e-tests/specs/customizer/general/custom-global-colors.spec.ts +++ b/e2e-tests/specs/customizer/general/custom-global-colors.spec.ts @@ -33,11 +33,37 @@ test.describe('Custom Global Color Control', () => { ); await clearWelcome(page); - await page.locator('[aria-label="Document Overview"]').click(); - await page.locator('.block-editor-list-view-leaf').first().click(); + await page.waitForFunction(() => + ( + window.wp?.data?.select('core/block-editor')?.getSettings() + ?.colors || [] + ).some((color: { slug: string }) => color.slug === 'custom-1') + ); + + // Wait for the parsed block list too, so the update below has a target. + await page.waitForFunction( + () => + ( + window.wp?.data?.select('core/block-editor')?.getBlocks() || + [] + ).length > 0 + ); - await page.getByRole('button', { name: 'Background' }).click(); - await page.getByRole('option', { name: 'Custom 1' }).click(); + // Clicking the palette swatch toggles, so a block left coloured by a + // previous run would be cleared rather than set. + await page.evaluate(() => { + const { data } = window.wp; + const [block] = data.select('core/block-editor').getBlocks(); + data.dispatch('core/block-editor').updateBlockAttributes( + block.clientId, + { backgroundColor: 'custom-1' } + ); + }); + await page.waitForFunction( + () => + window.wp?.data?.select('core/block-editor')?.getBlocks()?.[0] + ?.attributes?.backgroundColor === 'custom-1' + ); await savePost(page); }); diff --git a/e2e-tests/specs/customizer/hfg/hfg-footer-menu-component.spec.ts b/e2e-tests/specs/customizer/hfg/hfg-footer-menu-component.spec.ts index 28464f3feb..6aedc601fe 100644 --- a/e2e-tests/specs/customizer/hfg/hfg-footer-menu-component.spec.ts +++ b/e2e-tests/specs/customizer/hfg/hfg-footer-menu-component.spec.ts @@ -1,6 +1,21 @@ -import { test, expect, Page, Locator } from '@playwright/test'; -import { setCustomizeSettings } from '../../../utils'; +import { test, expect, Page, Locator, Frame } from '@playwright/test'; +import { loginWithRequest, setCustomizeSettings } from '../../../utils'; import data from '../../../fixtures/customizer/hfg/footer-menu-setup.json'; +import menuData from '../../../fixtures/customizer/hfg/footer-menu-both-devices-setup.json'; + +const PREVIEW_FRAME = 'iframe[name="customize-preview-0"]'; + +/** + * Collect the ids of every footer menu list in a document. + * + * @param {Frame} frame Frame to read from. + */ +const footerMenuIds = (frame: Frame): Promise => + frame.evaluate(() => + Array.from(document.querySelectorAll('.hfg_footer ul.footer-menu')).map( + (el) => el.id + ) + ); test.describe('Footer Menu component', function () { let page: Page; @@ -35,3 +50,137 @@ test.describe('Footer Menu component', function () { } }); }); + +test.describe( + 'Footer Menu component in both desktop and mobile layouts', + function () { + test.beforeAll(async ({ request, baseURL }) => { + await setCustomizeSettings('hfgFooterMenuBothDevices', menuData, { + request, + baseURL, + }); + }); + + test('Both layout variants are rendered', async ({ page }) => { + await page.goto('/?test_name=hfgFooterMenuBothDevices'); + + await expect( + page.locator( + '.hfg_footer .footer--row[data-show-on="desktop"] .nav-menu-footer' + ) + ).toHaveCount(1); + + await expect( + page.locator( + '.hfg_footer .footer--row[data-show-on="mobile"] .nav-menu-footer' + ) + ).toHaveCount(1); + }); + + test('Menu IDs are unique across the whole document', async ({ + page, + }) => { + await page.goto('/?test_name=hfgFooterMenuBothDevices'); + + await expect(page.locator('ul#footer-menu')).toHaveCount(0); + await expect( + page.locator('ul#footer-menu-desktop-bottom') + ).toHaveCount(1); + await expect(page.locator('ul#footer-menu-mobile-top')).toHaveCount( + 1 + ); + + const duplicated = await page.evaluate(() => { + const ids = Array.from( + document.querySelectorAll('.hfg_footer [id]') + ).map((el) => el.id); + + return ids.filter((id, index) => ids.indexOf(id) !== index); + }); + + expect(duplicated).toEqual([]); + }); + + test('The footer-menu class is kept for styling', async ({ page }) => { + await page.goto('/?test_name=hfgFooterMenuBothDevices'); + + await expect( + page.locator('.hfg_footer ul.footer-menu') + ).toHaveCount(2); + }); + + test('Menu IDs survive a selective refresh of the component', async ({ + page, + }) => { + const previewUrl = '/?test_name=hfgFooterMenuBothDevices'; + await loginWithRequest( + '/wp-admin/customize.php?url=' + encodeURIComponent(previewUrl), + page + ); + + await page.waitForSelector('.wp-full-overlay-sidebar', { + state: 'visible', + }); + + const preview = page.frameLocator(PREVIEW_FRAME); + await preview + .locator('.hfg_footer ul.footer-menu') + .first() + .waitFor(); + + const frame = page.frame({ name: 'customize-preview-0' }); + if (!frame) { + throw new Error('Customizer preview frame not found.'); + } + + // Every placement has to advertise where it sits, otherwise selective refresh + // renders the partial once and copies that markup into all of them. + const contexts = await frame.evaluate(() => + Array.from( + document.querySelectorAll('.builder-item--footer-menu') + ).map((el) => + el.getAttribute('data-customize-partial-placement-context') + ) + ); + expect(contexts).toEqual([ + '{"device":"desktop","row":"bottom"}', + '{"device":"mobile","row":"top"}', + ]); + + const before = await footerMenuIds(frame); + expect(before).toEqual([ + 'footer-menu-desktop-bottom', + 'footer-menu-mobile-top', + ]); + + // Changing a footer-menu setting refreshes the component partial. + await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).wp + .customize('footer-menu_style') + .set('style-border-bottom'); + }); + + // The style class only lands through a re-render, so this also proves the + // refresh actually ran before the ids are re-checked. + await expect( + preview + .locator( + '.footer--row[data-show-on="desktop"] .nav-menu-footer' + ) + .first() + ).toHaveClass(/style-border-bottom/); + await expect( + preview + .locator( + '.footer--row[data-show-on="mobile"] .nav-menu-footer' + ) + .first() + ).toHaveClass(/style-border-bottom/); + + const after = await footerMenuIds(frame); + expect(after).toEqual(before); + await expect(preview.locator('ul#footer-menu')).toHaveCount(0); + }); + } +); diff --git a/e2e-tests/specs/customizer/hfg/hfg-header-menu-component.spec.ts b/e2e-tests/specs/customizer/hfg/hfg-header-menu-component.spec.ts new file mode 100644 index 0000000000..afdeb1c5a4 --- /dev/null +++ b/e2e-tests/specs/customizer/hfg/hfg-header-menu-component.spec.ts @@ -0,0 +1,127 @@ +import { test, expect, Frame } from '@playwright/test'; +import { loginWithRequest, setCustomizeSettings } from '../../../utils'; +import data from '../../../fixtures/customizer/hfg/primary-menu-both-devices-setup.json'; + +const PREVIEW_FRAME = 'iframe[name="customize-preview-0"]'; + +/** + * Collect the ids of every primary menu list in a document. + * + * @param {Frame} frame Frame to read from. + */ +const primaryMenuIds = (frame: Frame): Promise => + frame.evaluate(() => + Array.from( + document.querySelectorAll('.hfg_header ul.primary-menu-ul') + ).map((el) => el.id) + ); + +test.describe( + 'Primary Menu component in both desktop and mobile layouts', + function () { + test.beforeAll(async ({ request, baseURL }) => { + await setCustomizeSettings('hfgPrimaryMenuBothDevices', data, { + request, + baseURL, + }); + }); + + test('Menu IDs are unique across the whole document', async ({ + page, + }) => { + await page.goto('/?test_name=hfgPrimaryMenuBothDevices'); + + // The row alone is not enough to tell the placements apart: both layouts + // use the same row names. + await expect( + page.locator('ul#nv-primary-navigation-main') + ).toHaveCount(0); + + await expect( + page.locator('ul#nv-primary-navigation-desktop-main') + ).toHaveCount(1); + await expect( + page.locator('ul#nv-primary-navigation-mobile-main') + ).toHaveCount(1); + await expect( + page.locator('ul#nv-primary-navigation-mobile-sidebar') + ).toHaveCount(1); + + const duplicated = await page.evaluate(() => { + const ids = Array.from( + document.querySelectorAll('.hfg_header [id]') + ).map((el) => el.id); + + return ids.filter((id, index) => ids.indexOf(id) !== index); + }); + + expect(duplicated).toEqual([]); + }); + + test('Menu IDs survive a selective refresh of the component', async ({ + page, + }) => { + const previewUrl = '/?test_name=hfgPrimaryMenuBothDevices'; + await loginWithRequest( + '/wp-admin/customize.php?url=' + encodeURIComponent(previewUrl), + page + ); + + await page.waitForSelector('.wp-full-overlay-sidebar', { + state: 'visible', + }); + + const preview = page.frameLocator(PREVIEW_FRAME); + await preview + .locator('.hfg_header ul.primary-menu-ul') + .first() + .waitFor(); + + const frame = page.frame({ name: 'customize-preview-0' }); + if (!frame) { + throw new Error('Customizer preview frame not found.'); + } + + const before = await primaryMenuIds(frame); + expect(before).toEqual([ + 'nv-primary-navigation-desktop-main', + 'nv-primary-navigation-mobile-main', + 'nv-primary-navigation-mobile-sidebar', + ]); + + // Every placement has to advertise where it sits, otherwise selective refresh + // renders the partial once and copies that markup into all of them. + const contexts = await frame.evaluate(() => + Array.from( + document.querySelectorAll('.builder-item--primary-menu') + ).map((el) => + el.getAttribute('data-customize-partial-placement-context') + ) + ); + expect(contexts).toEqual([ + '{"device":"desktop","row":"main"}', + '{"device":"mobile","row":"main"}', + '{"device":"mobile","row":"sidebar"}', + ]); + + // Refresh the partial and check that all placements were refreshed and that the menu ids are still unique. + const refreshed = await frame.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sr = (window as any).wp.customize.selectiveRefresh; + const partial = sr.partial('primary-menu_partial'); + if (!partial) { + throw new Error('primary-menu_partial is not registered.'); + } + const placements = await partial.refresh(); + + return placements.length; + }); + expect(refreshed).toBe(3); + + expect(await primaryMenuIds(frame)).toEqual(before); + await expect( + preview.locator('ul#nv-primary-navigation-main') + ).toHaveCount(0); + }); + } +); diff --git a/e2e-tests/specs/customizer/hfg/hfg-logo-component.spec.ts b/e2e-tests/specs/customizer/hfg/hfg-logo-component.spec.ts index ff8b6d5d29..a91e8cad2b 100644 --- a/e2e-tests/specs/customizer/hfg/hfg-logo-component.spec.ts +++ b/e2e-tests/specs/customizer/hfg/hfg-logo-component.spec.ts @@ -1,9 +1,5 @@ import { test, expect, APIRequestContext, Page } from '@playwright/test'; -import { - setCustomizeSettings, - testForViewport, - visitAdminPage, -} from '../../../utils'; +import { setCustomizeSettings, testForViewport } from '../../../utils'; import data from '../../../fixtures/customizer/hfg/hfg-logo-component.json'; interface TestOptions { @@ -87,23 +83,24 @@ test.describe('Logo Component palette', function () { test.beforeAll(async ({ browser, request, baseURL }) => { page = await browser.newPage(); - await visitAdminPage(page, 'upload.php', ''); - await page.waitForSelector('.attachment'); - const imageLocators = await page.locator('.attachment').count(); - - for (let i = 0; i < Math.min(imageLocators, 2); i++) { - const imageLocator = await page.locator( - `.attachment:nth-child(${i + 1})` - ); - await imageLocator.click(); - const urlString = page.url(); - const url = new URL(urlString); - const imageId = url.searchParams.get('item') || ''; - const imageUrl = await page - .locator('#attachment-details-two-column-copy-link') - .getAttribute('value'); - logos.push({ id: imageId, url: imageUrl }); - await page.goBack(); // Go back to the previous page to select the next image + + // Queried instead of walked through the media grid: the newest sample-data + // attachment is a video, and grid order differs between environments. + // orderby=id keeps the pick stable for a given database. + const response = await request.get( + baseURL + + '/wp-json/wp/v2/media?media_type=image&per_page=2&orderby=id&order=asc' + ); + expect(response.ok()).toBeTruthy(); + + const attachments = await response.json(); + expect(attachments.length).toBeGreaterThan(0); + + for (const attachment of attachments.slice(0, 2)) { + logos.push({ + id: String(attachment.id), + url: attachment.source_url, + }); } const { palette } = data; diff --git a/e2e-tests/specs/customizer/hfg/hfg-menu-item-alignment.spec.ts b/e2e-tests/specs/customizer/hfg/hfg-menu-item-alignment.spec.ts index d05b9ac90d..0cc59c7b23 100644 --- a/e2e-tests/specs/customizer/hfg/hfg-menu-item-alignment.spec.ts +++ b/e2e-tests/specs/customizer/hfg/hfg-menu-item-alignment.spec.ts @@ -17,18 +17,23 @@ test.describe('Menu item alignment', function () { await page.locator('.mobile-left .navbar-toggle').click(); await expect( page.locator( - '#nv-primary-navigation-sidebar .menu-item-title-wrap:has-text("About The Tests")' + '#nv-primary-navigation-mobile-sidebar .menu-item-title-wrap:has-text("About The Tests")' ) ).toHaveCSS('text-align', 'left'); + // Located by label: menu item IDs are DB auto-increments and differ + // between a fresh CI import and a long-lived local database. await page .locator( - '#nv-primary-navigation-sidebar > .menu-item-1643 > .wrap > .caret-wrap' + '#nv-primary-navigation-mobile-sidebar > li.menu-item-has-children' ) + .filter({ hasText: 'Level 1' }) + .locator('.caret-wrap') + .first() .click(); await expect( page.locator( - '#nv-primary-navigation-sidebar .menu-item-title-wrap:has-text("Level 2")' + '#nv-primary-navigation-mobile-sidebar .menu-item-title-wrap:has-text("Level 2")' ) ).toHaveCSS('text-align', 'left'); }); diff --git a/e2e-tests/specs/customizer/hfg/hfg-menu-item-wrap.spec.ts b/e2e-tests/specs/customizer/hfg/hfg-menu-item-wrap.spec.ts index 7132e6a303..bc614638b9 100644 --- a/e2e-tests/specs/customizer/hfg/hfg-menu-item-wrap.spec.ts +++ b/e2e-tests/specs/customizer/hfg/hfg-menu-item-wrap.spec.ts @@ -11,7 +11,7 @@ test.describe('Menu item alignment', function () { .click(); const firstLevelItem = page - .locator('#nv-primary-navigation-sidebar') + .locator('#nv-primary-navigation-mobile-sidebar') .getByRole('link', { name: 'Page Markup And Formatting', }); @@ -23,7 +23,7 @@ test.describe('Menu item alignment', function () { await page.getByRole('button', { name: 'Toggle Level 2' }).click(); const secondLevelItem = page - .locator('#nv-primary-navigation-sidebar') + .locator('#nv-primary-navigation-mobile-sidebar') .getByRole('link', { name: 'Level 3b', }); diff --git a/e2e-tests/specs/customizer/layout/blog-archive-settings.spec.ts b/e2e-tests/specs/customizer/layout/blog-archive-settings.spec.ts index a8fb3386a1..17df82fc77 100755 --- a/e2e-tests/specs/customizer/layout/blog-archive-settings.spec.ts +++ b/e2e-tests/specs/customizer/layout/blog-archive-settings.spec.ts @@ -265,14 +265,16 @@ test.describe('Blog/Archive 3 / Covers Layout', () => { test.describe('Blog/Archive 4 / Default Layout', () => { test.beforeAll(async ({ request, baseURL }) => { - await setCustomizeSettings('defaultLayout', data.archive4, { + // Distinct scoped name: sharing 'defaultLayout' with Archive 1 meant + // whichever beforeAll ran last decided both describes' settings. + await setCustomizeSettings('defaultLayoutNoThumbnail', data.archive4, { request, baseURL, }); }); test('Tests If Post Thumbnail Class Is Removed', async ({ page }) => { - await page.goto('/?test_name=defaultLayout'); + await page.goto('/?test_name=defaultLayoutNoThumbnail'); await page.waitForSelector('article.post'); diff --git a/e2e-tests/specs/customizer/layout/single-post-settings.spec.ts b/e2e-tests/specs/customizer/layout/single-post-settings.spec.ts index fe3c000132..50181408c0 100644 --- a/e2e-tests/specs/customizer/layout/single-post-settings.spec.ts +++ b/e2e-tests/specs/customizer/layout/single-post-settings.spec.ts @@ -121,7 +121,6 @@ test.describe('Single Post Check', function () { 'nv-content-wrap', 'comments-area', 'entry-header', - 'nv-thumb-wrap', ]; await page.goto( '/template-comments/?test_name=layoutElementsReordered' diff --git a/e2e-tests/specs/customizer/scroll-to-top/scroll-to-top.spec.ts b/e2e-tests/specs/customizer/scroll-to-top/scroll-to-top.spec.ts index 8f53409880..6331169d29 100644 --- a/e2e-tests/specs/customizer/scroll-to-top/scroll-to-top.spec.ts +++ b/e2e-tests/specs/customizer/scroll-to-top/scroll-to-top.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { setCustomizeSettings, scrollTo, visitAdminPage } from '../../../utils'; +import { setCustomizeSettings, scrollTo } from '../../../utils'; import data from '../../../fixtures/customizer/scroll-to-top/scroll-to-top-setup.json'; test.describe( 'Scroll to top', function () { @@ -99,15 +99,20 @@ test.describe( 'Scroll to top', function () { } ) { const iconTypeData = Object.assign( {}, data.general ); - // Get the id of the first image to be able to apply it. - await visitAdminPage( page, 'upload.php', '' ); - await page.locator( '.attachment' ).first().click(); - const urlString = page.url(); - const url = new URL( urlString ); - const imageId = url.searchParams.get( 'item' ) || ''; + // Queried instead of taking the first attachment in the media grid: the + // newest sample-data attachment is a video, and the set differs between + // a fresh CI import and a long-lived local database. + const mediaResponse = await request.get( + baseURL + + '/wp-json/wp/v2/media?media_type=image&per_page=1&orderby=id&order=asc' + ); + expect( mediaResponse.ok() ).toBeTruthy(); + + const [ attachment ] = await mediaResponse.json(); + expect( attachment ).toBeTruthy(); iconTypeData.neve_scroll_to_top_type = 'image'; - iconTypeData.neve_scroll_to_top_image = parseInt( imageId ); + iconTypeData.neve_scroll_to_top_image = attachment.id; await setCustomizeSettings( 'stt-icon-check', iconTypeData, { request, @@ -117,14 +122,20 @@ test.describe( 'Scroll to top', function () { await page.goto( '/hello-world/?test_name=stt-icon-check' ); await scrollTo( page, 'bottom' ); - const scrollToTopImage = await page.locator( + const scrollToTopImage = page.locator( '#scroll-to-top .scroll-to-top-image' ); - await expect( await scrollToTopImage.count() ).toBeGreaterThan( 0 ); + await expect( scrollToTopImage ).not.toHaveCount( 0 ); + // Asserted against the image we selected above. The theme renders it with + // wp_get_attachment_url(), so this is the full URL, unsized. + const escapedUrl = attachment.source_url.replace( + /[.*+?^${}()|[\]\\]/g, + '\\$&' + ); await expect( scrollToTopImage ).toHaveCSS( 'background-image', - /spectacles.gif/ + new RegExp( `url\\(["']?${ escapedUrl }["']?\\)` ) ); await setCustomizeSettings( 'stt-icon-check2', data.general, { diff --git a/e2e-tests/specs/customizer/style-book/style-book.spec.ts b/e2e-tests/specs/customizer/style-book/style-book.spec.ts index 8650b37cdd..0c3721f6be 100644 --- a/e2e-tests/specs/customizer/style-book/style-book.spec.ts +++ b/e2e-tests/specs/customizer/style-book/style-book.spec.ts @@ -8,11 +8,14 @@ test.describe('Style Book Modal', () => { // Wait for customizer to fully load await page.waitForSelector('.wp-full-overlay-sidebar', { state: 'visible' }); - // Wait a bit more for all scripts to initialize - await page.waitForTimeout(1000); + // The controls bundle injects this button at runtime, Booting the + // customizer plus its React bundle is the slowest step in the suite and can + // pass the default action timeout on a loaded machine, hence the override. + const styleBookButton = page.locator('#neve-style-book'); + await styleBookButton.waitFor({ state: 'visible', timeout: 60_000 }); // Open Style Book for all tests - await page.getByRole('button', { name: ' Style Book' }).click(); + await styleBookButton.click(); // Wait for Style Book to appear in the iframe await page diff --git a/e2e-tests/specs/customizer/typography/font-family.spec.ts b/e2e-tests/specs/customizer/typography/font-family.spec.ts index 32899fb172..d80a6c15fb 100755 --- a/e2e-tests/specs/customizer/typography/font-family.spec.ts +++ b/e2e-tests/specs/customizer/typography/font-family.spec.ts @@ -21,15 +21,18 @@ test.describe('Font Family', () => { '/markup-html-tags-and-formatting/?test_name=fontFamily' ); - await expect(await page.locator('body')).toHaveCSS( + await expect(page.locator('body')).toHaveCSS( 'font-family', new RegExp(`${fonts.general}`) ); for (const heading of headingSelectors) { - const headings = await page.locator(heading); - for (let index = 0; index < (await headings.count()); index++) { - await expect(await headings.nth(index)).toHaveCSS( + const headings = page.locator(heading); + await expect(headings).not.toHaveCount(0); + + const count = await headings.count(); + for (let index = 0; index < count; index++) { + await expect(headings.nth(index)).toHaveCSS( 'font-family', new RegExp(`${fonts.headings}`) ); diff --git a/e2e-tests/utils.ts b/e2e-tests/utils.ts index 3ca0cf3298..d3ef35bd60 100644 --- a/e2e-tests/utils.ts +++ b/e2e-tests/utils.ts @@ -155,16 +155,28 @@ export async function getPageError(page: Page) { * @param {Page} page - A playwright Page object representing the web page */ export const clearWelcome = async (page: Page) => { + // The editor registers its stores asynchronously, so selecting one straight + // after navigation returns undefined and the guard below would throw. + await page + .waitForFunction( + () => !!window.wp?.data?.select('core/edit-post'), + null, + { + timeout: 15_000, + } + ) + .catch(() => { + // Nothing to clear if the editor never came up; let the test report that. + }); + await page.evaluate(() => { - // eslint-disable-next-line no-unused-expressions - window.wp && - window.wp.data && - window.wp.data - .select('core/edit-post') - .isFeatureActive('welcomeGuide') && + const editPost = window.wp?.data?.select('core/edit-post'); + + if (editPost?.isFeatureActive?.('welcomeGuide')) { window.wp.data .dispatch('core/edit-post') .toggleFeature('welcomeGuide'); + } }); }; @@ -267,12 +279,13 @@ export const testForViewport = async ( } ) => { await page.setViewportSize(viewPort); - const elements = await page.locator(selector); - const count = await elements.count(); - await expect(count).toBeGreaterThan(0); + const elements = page.locator(selector); + // Retrying assertion: lets the layout settle after the resize. + await expect(elements).not.toHaveCount(0); - for (let index = 0; index < (await elements.count()); index++) { - const element = await elements.nth(index); + const count = await elements.count(); + for (let index = 0; index < count; index++) { + const element = elements.nth(index); for (const cssProperty of viewportData.cssProperties) { await expect(element).toHaveCSS( @@ -288,8 +301,13 @@ export const checkElementsOrder = async ( containerSelector: string, expectedOrder: string[] ) => { - const elements = await page.locator(containerSelector + ' > *'); - for (let i = 0; i < (await elements.count()); i++) { + const elements = page.locator(containerSelector + ' > *'); + // Exact count, so extra or missing children are reported as such rather than + // as a confusing class mismatch part-way through the loop — or, when the + // container is empty, not reported at all. + await expect(elements).toHaveCount(expectedOrder.length); + + for (let i = 0; i < expectedOrder.length; i++) { await expect(elements.nth(i)).toHaveClass( new RegExp(`${expectedOrder[i]}`) ); diff --git a/globals/migrations.php b/globals/migrations.php index c74c608a68..e7f4b8a8bb 100644 --- a/globals/migrations.php +++ b/globals/migrations.php @@ -127,6 +127,9 @@ function neve_migrate_blog_columns() { * @return void */ function neve_run_migration_flags() { + if ( ! class_exists( Migration_Flags::class ) ) { + return; + } $migrator = new Migration_Flags( NEVE_VERSION ); $migrator->run(); } diff --git a/header-footer-grid/Core/Builder/Abstract_Builder.php b/header-footer-grid/Core/Builder/Abstract_Builder.php index 7c8c0e4936..ce82a65b12 100644 --- a/header-footer-grid/Core/Builder/Abstract_Builder.php +++ b/header-footer-grid/Core/Builder/Abstract_Builder.php @@ -826,7 +826,7 @@ public function get_current_slot_index() { public function render() { $layout = $this->get_layout_data(); self::$current_builder = $this->get_id(); - if ( is_customize_preview() ) { + if ( is_customize_preview() && class_exists( Css_Generator::class ) ) { $style = $this->add_style( [] ); $generator = new Css_Generator(); $generator->set( $style ); diff --git a/header-footer-grid/Core/Components/Abstract_Component.php b/header-footer-grid/Core/Components/Abstract_Component.php index a4287b0819..3e88c8d310 100644 --- a/header-footer-grid/Core/Components/Abstract_Component.php +++ b/header-footer-grid/Core/Components/Abstract_Component.php @@ -22,6 +22,7 @@ use Neve\Core\Styles\Dynamic_Selector; use Neve\Views\Font_Manager; use WP_Customize_Manager; +use WP_Customize_Partial; /** * Class Abstract_Component @@ -617,16 +618,37 @@ public function customize_register( WP_Customize_Manager $wp_customize ) { /** * Render component markup. * - * @param string $device Current device. + * @param string|WP_Customize_Partial $device Current device. + * @param array $container_context Placement context, when rendering a partial. */ - public function render( $device = '' ) { + public function render( $device = '', $container_context = array() ) { + $is_partial_render = $device instanceof WP_Customize_Partial; + $previous_device = Abstract_Builder::$current_device; + $previous_row = Abstract_Builder::$current_row; + + if ( $is_partial_render ) { + $device = isset( $container_context['device'] ) ? (string) $container_context['device'] : ''; + + if ( $device !== '' ) { + Abstract_Builder::$current_device = $device; + } + if ( isset( $container_context['row'] ) && $container_context['row'] !== '' ) { + Abstract_Builder::$current_row = (string) $container_context['row']; + } + } + $args = []; - if ( ! empty( $device ) && in_array( $device, [ 'desktop', 'tablet', 'mobile' ] ) ) { + if ( ! empty( $device ) && in_array( $device, [ 'desktop', 'tablet', 'mobile' ], true ) ) { $args['device'] = $device; } self::$current_component = $this->get_id(); Abstract_Builder::$current_builder = $this->get_builder_id(); Main::get_instance()->load( 'component-wrapper', '', $args ); + + if ( $is_partial_render ) { + Abstract_Builder::$current_device = $previous_device; + Abstract_Builder::$current_row = $previous_row; + } } /** diff --git a/header-footer-grid/Core/Components/Nav.php b/header-footer-grid/Core/Components/Nav.php index 9e1a1e4958..924a1fbf24 100644 --- a/header-footer-grid/Core/Components/Nav.php +++ b/header-footer-grid/Core/Components/Nav.php @@ -11,6 +11,7 @@ namespace HFG\Core\Components; +use HFG\Core\Builder\Header; use HFG\Core\Settings\Manager as SettingsManager; use HFG\Main; use Neve\Core\Settings\Mods; @@ -35,6 +36,27 @@ class Nav extends Abstract_Component { const EXPAND_DROPDOWNS = 'expand_dropdowns'; const DROPDOWNS_EXPANDED_CLASS = 'dropdowns-expanded'; + /** + * Build the menu id for the placement currently being rendered. + * + * @return string + */ + public static function get_menu_id() { + $menu_id = self::NAV_MENU_ID; + + $parts = [ + \HFG\current_device( Header::BUILDER_NAME ), + \HFG\current_row( Header::BUILDER_NAME ), + ]; + foreach ( $parts as $part ) { + if ( ! empty( $part ) ) { + $menu_id .= '-' . $part; + } + } + + return $menu_id; + } + /** * Nav constructor. * diff --git a/header-footer-grid/Core/Components/NavFooter.php b/header-footer-grid/Core/Components/NavFooter.php index 927136b7c6..839eea3488 100644 --- a/header-footer-grid/Core/Components/NavFooter.php +++ b/header-footer-grid/Core/Components/NavFooter.php @@ -124,7 +124,7 @@ public function add_settings() { 'fallback' => 'inherit', ], [ - 'selector' => '.builder-item--' . $this->get_id() . ' .nav-menu-footer:not(.style-full-height) #footer-menu li:hover > a', + 'selector' => '.builder-item--' . $this->get_id() . ' .nav-menu-footer:not(.style-full-height) .footer-menu li:hover > a', 'prop' => 'color', 'fallback' => 'inherit', ], diff --git a/header-footer-grid/templates/component-wrapper.php b/header-footer-grid/templates/component-wrapper.php index dba1171754..7ca9034eea 100644 --- a/header-footer-grid/templates/component-wrapper.php +++ b/header-footer-grid/templates/component-wrapper.php @@ -28,10 +28,24 @@ $item_classes = join( ' ', $item_classes ); +// The placement context is used to restore the device and row +// when rendering a component in a selective refresh partial. +$placement_context = array(); +if ( is_customize_preview() ) { + $placement_device = current_device(); + $placement_row = current_row(); + if ( ! empty( $placement_device ) ) { + $placement_context['device'] = $placement_device; + } + if ( ! empty( $placement_row ) ) { + $placement_context['row'] = $placement_row; + } +} + ?>
+ data-item-id="get_id() ); ?>"> render_css(); current_component()->render_component(); diff --git a/header-footer-grid/templates/components/component-nav-footer.php b/header-footer-grid/templates/components/component-nav-footer.php index 8e05102679..f9c40ae9a5 100644 --- a/header-footer-grid/templates/components/component-nav-footer.php +++ b/header-footer-grid/templates/components/component-nav-footer.php @@ -19,6 +19,13 @@ $container_classes[] = 'm-style'; } +$menu_id = NavFooter::COMPONENT_ID; +$device = current_device(); +$row = current_row(); +if ( ! empty( $device ) && ! empty( $row ) ) { + $menu_id .= '-' . $device . '-' . $row; +} + ?>