diff --git a/tests/e2e/sticky-posts.spec.js b/tests/e2e/sticky-posts.spec.js new file mode 100644 index 0000000..08d6919 --- /dev/null +++ b/tests/e2e/sticky-posts.spec.js @@ -0,0 +1,286 @@ +/** + * Tests for the Sticky Posts control. + * + * Sticky posts pin a hand-picked, ordered set of posts to the front of a query + * loop. Two properties matter and are tested separately: + * + * 1. It *orders* — pinned posts lead the result set, in the editor's chosen + * order, and the rest follow in the query's own order with no duplicates. + * 2. It does *not filter* — pinning a post the query would not have returned + * does not pull that post in. "Which posts appear" stays the job of the + * query settings. + * + * The ordering is applied in SQL (`posts_orderby`), so the front end is the + * real subject here; the editor preview is fetched over REST and does not + * reflect it. The third test covers the control itself rather than the query. + */ +const { test, expect } = require( './fixtures' ); + +/** + * Build a query loop that lists post titles, optionally with pinned posts. + * + * Ordered by title so the unpinned baseline is deterministic across runs + * rather than depending on fixture post dates. + * + * @param {Object} options Block options. + * @param {number} options.queryId Query ID for the block. + * @param {number[]} options.sticky Post IDs to pin, in order. Omit for none. + * @param {string} options.postType Post type to query. + * @param {number} options.perPage Posts per page. + * @return {string} Block markup. + */ +function queryLoop( { queryId, sticky = [], postType = 'post', perPage = 5 } ) { + const hm = + sticky.length > 0 + ? `,"hmQueryLoop":{"stickyPosts":[${ sticky.join( ',' ) }]}` + : ''; + + return ` +
+ +
+`; +} + +/** + * Start a fresh page in the editor with any modals dismissed. + * + * @param {Object} page Playwright page. + * @param {Object} admin WordPress admin utils. + */ +async function startNewPage( page, admin ) { + await admin.createNewPost( { postType: 'page' } ); + await page.waitForTimeout( 1500 ); + + const closeButton = page.getByRole( 'button', { name: 'Close' } ); + if ( + await closeButton.isVisible( { timeout: 1000 } ).catch( () => false ) + ) { + await closeButton.click(); + } +} + +/** + * Fetch posts of a type ordered by title, so the test can pick real IDs + * rather than assuming anything about the database fixture's numbering. + * + * @param {Object} page Playwright page, on an admin screen. + * @param {string} postType Post type slug. + * @param {number} perPage How many to fetch. + * @return {Promise>} Posts in title order. + */ +async function getPostsByTitle( page, postType = 'posts', perPage = 5 ) { + return page.evaluate( + async ( { type, count } ) => { + const records = await window.wp.apiFetch( { + path: `/wp/v2/${ type }?per_page=${ count }&orderby=title&order=asc&_fields=id,title`, + } ); + return records.map( ( record ) => ( { + id: record.id, + title: record.title.rendered, + } ) ); + }, + { type: postType, count: perPage } + ); +} + +/** + * Read the rendered post titles from the published front end. + * + * @param {Object} page Playwright page, on the front end. + * @return {Promise} Titles in render order. + */ +async function renderedTitles( page ) { + return ( + await page.locator( '.wp-block-post-title' ).allTextContents() + ).map( ( title ) => title.trim() ); +} + +test.describe( 'Sticky Posts', () => { + test( 'pins posts to the front of the loop in the chosen order', async ( { + page, + admin, + editor, + blockEditor, + } ) => { + await startNewPage( page, admin ); + + const posts = await getPostsByTitle( page, 'posts', 5 ); + expect( posts.length ).toBeGreaterThanOrEqual( 5 ); + + // Pin the 4th and 5th posts, and pin them in reverse order. Picking + // posts that are not already at the front proves the reordering is + // real, and reversing them proves the editor's order is honoured + // rather than the pinned posts merely being hoisted as a group. + const first = posts[ 4 ]; + const second = posts[ 3 ]; + + await editor.setContent( + queryLoop( { queryId: 501, sticky: [ first.id, second.id ] } ) + ); + await blockEditor.publishAndVisit(); + + const titles = await renderedTitles( page ); + + // The pinned posts lead, in the order they were pinned. + expect( titles.slice( 0, 2 ) ).toEqual( [ first.title, second.title ] ); + + // The remainder keeps the query's own title ordering. + const remaining = posts + .filter( ( post ) => post.id !== first.id && post.id !== second.id ) + .map( ( post ) => post.title ); + expect( titles.slice( 2 ) ).toEqual( remaining ); + + // Pinning must not duplicate a post that was already in the results. + expect( new Set( titles ).size ).toBe( titles.length ); + } ); + + test( 'leaves the loop untouched when nothing is pinned', async ( { + page, + admin, + editor, + blockEditor, + } ) => { + await startNewPage( page, admin ); + + const posts = await getPostsByTitle( page, 'posts', 5 ); + + await editor.setContent( queryLoop( { queryId: 502 } ) ); + await blockEditor.publishAndVisit(); + + expect( await renderedTitles( page ) ).toEqual( + posts.map( ( post ) => post.title ) + ); + } ); + + test( 'does not pull in a post the query would not return', async ( { + page, + admin, + editor, + blockEditor, + } ) => { + await startNewPage( page, admin ); + + // A post ID pinned into a loop that queries pages. The post is not in + // the result set to begin with, so pinning it must be inert: sticky + // posts order the results, they do not add to them. + const posts = await getPostsByTitle( page, 'posts', 1 ); + const pages = await getPostsByTitle( page, 'pages', 5 ); + expect( posts.length ).toBeGreaterThan( 0 ); + + await editor.setContent( + queryLoop( { + queryId: 503, + postType: 'page', + sticky: [ posts[ 0 ].id ], + } ) + ); + await blockEditor.publishAndVisit(); + + const titles = await renderedTitles( page ); + expect( titles ).not.toContain( posts[ 0 ].title ); + + // The page loop is otherwise unaffected. The published page itself is + // now a page too, so compare against the titles that were queried + // rather than asserting an exact list. + for ( const title of titles ) { + expect( pages.map( ( p ) => p.title ).concat( titles ) ).toContain( + title + ); + } + } ); + + test( 'pins, reorders and unpins posts from the inspector control', async ( { + page, + admin, + editor, + } ) => { + await startNewPage( page, admin ); + + const posts = await getPostsByTitle( page, 'posts', 3 ); + + await editor.setContent( queryLoop( { queryId: 504 } ) ); + + // Select the query block so its inspector renders. + await page.evaluate( () => { + const [ block ] = window.wp.data + .select( 'core/block-editor' ) + .getBlocksByName( 'core/query' ); + window.wp.data.dispatch( 'core/block-editor' ).selectBlock( block ); + } ); + await editor.openDocumentSettingsSidebar(); + + const panel = page.locator( + '.components-panel__body-title:has-text("Sticky Posts")' + ); + await expect( panel ).toBeVisible( { timeout: 5000 } ); + if ( + ( await panel + .locator( 'button' ) + .getAttribute( 'aria-expanded' ) ) !== 'true' + ) { + await panel.locator( 'button' ).click(); + } + + const search = page.getByLabel( 'Pin a post' ); + await expect( search ).toBeVisible(); + + /** + * Read the pinned IDs straight off the block attribute. + * + * @return {Promise} Pinned post IDs in order. + */ + const stickyAttribute = () => + page.evaluate( () => { + const [ block ] = window.wp.data + .select( 'core/block-editor' ) + .getBlocksByName( 'core/query' ); + return ( + window.wp.data + .select( 'core/block-editor' ) + .getBlockAttributes( block )?.hmQueryLoop + ?.stickyPosts || [] + ); + } ); + + // Pin two posts by searching for them and clicking the result. + for ( const post of posts.slice( 0, 2 ) ) { + await search.fill( post.title ); + const result = page + .locator( '.hm-query-loop-sticky-result' ) + .filter( { hasText: post.title } ) + .first(); + await expect( result ).toBeVisible( { timeout: 5000 } ); + await result.click(); + } + + expect( await stickyAttribute() ).toEqual( [ + posts[ 0 ].id, + posts[ 1 ].id, + ] ); + + // Moving the second entry up swaps the stored order. + await page + .locator( '.hm-query-loop-sticky-item' ) + .nth( 1 ) + .getByRole( 'button', { name: 'Move up' } ) + .click(); + expect( await stickyAttribute() ).toEqual( [ + posts[ 1 ].id, + posts[ 0 ].id, + ] ); + + // Unpinning the first entry leaves the other behind. + await page + .locator( '.hm-query-loop-sticky-item' ) + .first() + .getByRole( 'button', { name: 'Unpin' } ) + .click(); + expect( await stickyAttribute() ).toEqual( [ posts[ 0 ].id ] ); + + // Unpinning the last entry drops the attribute rather than storing an + // empty array, so a loop with nothing pinned serialises unchanged. + await page.getByRole( 'button', { name: 'Unpin all' } ).click(); + expect( await stickyAttribute() ).toEqual( [] ); + } ); +} );