diff --git a/tests/e2e/cloudinary-gallery-delivery.spec.js b/tests/e2e/cloudinary-gallery-delivery.spec.js new file mode 100644 index 000000000..a301c2be0 --- /dev/null +++ b/tests/e2e/cloudinary-gallery-delivery.spec.js @@ -0,0 +1,321 @@ +/** + * External dependencies + */ +const fs = require( 'fs' ); +const path = require( 'path' ); +const { test, expect } = require( './fixtures' ); + +/** + * Internal dependencies + */ +const { ensureCloudinaryConnected } = require( './utils/connection' ); +const { expectCloudinaryUrl, srcsetUrls } = require( './utils/delivery' ); +const { wpCli } = require( './utils/wizard' ); + +const FIXTURE_PATH = path.join( __dirname, 'fixtures', 'test-image.jpg' ); + +const IMAGE_COUNT = 3; +const INITIAL_COLUMNS = 3; +const CONTAINER = 'cld-gallery-e2e'; + +// The Cloudinary Gallery Widget is loaded from product-gallery.cloudinary.com +// and renders on `window.load`, so allow for a slow third-party fetch. +const WIDGET_TIMEOUT = 30_000; + +let cloudName; + +/** + * State shared by the tests in this file. The layout test edits the post + * created in beforeAll, so the tests run in order (see `mode: 'serial'`). + * + * @type {{ postId: number, postLink: string, attachmentIds: number[], publicIds: string[] }|null} + */ +let created = null; + +/** + * Build the serialized `cloudinary/gallery` block. The inner markup must + * match the block's `save()` output exactly, or the editor flags the block + * as invalid when the layout test opens the post. + * + * @param {Object[]} selectedImages Response of the plugin's `image_data` endpoint. + * @param {Object} layout Layout attributes. + * @param {string} layout.mode `expanded` or `classic`. + * @param {number} layout.columns Column count (expanded mode only). + * @return {string} Block markup. + */ +function galleryBlock( selectedImages, { mode, columns } ) { + const attributes = { + container: CONTAINER, + selectedImages, + displayProps_mode: mode, + displayProps_columns: columns, + }; + + return ( + `\n` + + `
\n` + + `` + ); +} + +/** + * Open the published post and wait for the Gallery Widget to finish + * rendering every image in the main viewer. + * + * @param {import('@playwright/test').Page} page + * @return {Promise} The widget root. + */ +async function openGallery( page ) { + await page.goto( created.postLink ); + + const gallery = page.locator( + `.${ CONTAINER } [data-test="gallery-wrap"]` + ); + await expect( + gallery, + 'the Gallery Widget should render into the block container' + ).toBeVisible( { timeout: WIDGET_TIMEOUT } ); + + await expect( + gallery.locator( + '[data-test="gallery-viewer-wrap"] [data-test="image-wrap"][data-ready="true"]' + ) + ).toHaveCount( IMAGE_COUNT, { timeout: WIDGET_TIMEOUT } ); + + return gallery; +} + +/** + * Read the `src` and `srcset` attributes of the given images. + * + * @param {import('@playwright/test').Locator} images + * @return {Promise>} Attributes per image. + */ +async function collectImageUrls( images ) { + return images.evaluateAll( ( nodes ) => + nodes.map( ( img ) => ( { + src: img.getAttribute( 'src' ), + srcset: img.getAttribute( 'srcset' ), + } ) ) + ); +} + +// @serial: needs real credentials in `cloudinary_connect` to sync assets, +// while every analytics spec overwrites that option with fake ones. +test.describe( 'Cloudinary Gallery block delivery', { tag: '@serial' }, () => { + test.describe.configure( { mode: 'serial' } ); + + test.beforeAll( async ( { requestUtils } ) => { + ( { cloudName } = ensureCloudinaryConnected() ); + + const file = fs.readFileSync( FIXTURE_PATH ); + const attachmentIds = []; + for ( let i = 0; i < IMAGE_COUNT; i++ ) { + const media = await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/media', + headers: { + 'Content-Type': 'image/jpeg', + 'Content-Disposition': `attachment; filename="gallery-e2e-${ Date.now() }-${ i }.jpg"`, + }, + data: file, + } ); + attachmentIds.push( media.id ); + } + + // Same endpoint the block editor calls when images are picked in + // the media modal. It uploads each attachment to Cloudinary if it + // is not synced yet and returns the `selectedImages` attribute. + const selectedImages = await requestUtils.rest( { + method: 'POST', + path: '/cloudinary/v1/image_data', + data: { images: attachmentIds.map( ( id ) => ( { id } ) ) }, + } ); + + expect( + selectedImages, + 'every attachment should sync to Cloudinary' + ).toHaveLength( IMAGE_COUNT ); + + const post = await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/posts', + data: { + status: 'publish', + title: `Cloudinary gallery e2e ${ Date.now() }`, + content: galleryBlock( selectedImages, { + mode: 'expanded', + columns: INITIAL_COLUMNS, + } ), + }, + } ); + + created = { + postId: post.id, + postLink: post.link, + attachmentIds, + publicIds: selectedImages.map( ( image ) => image.publicId ), + }; + } ); + + test.afterAll( () => { + if ( ! created ) { + return; + } + const { postId, attachmentIds } = created; + created = null; + + // WP-CLI rather than REST for the same reason as the image delivery + // spec: `force=true` on a `?rest_route=` URL is fragile. + for ( const id of [ postId, ...attachmentIds ] ) { + try { + wpCli( [ 'post', 'delete', String( id ), '--force' ] ); + } catch ( e ) { + console.warn( `Cleanup of post ${ id } failed:`, e.message ); + } + } + } ); + + test( 'renders exactly N images, all served from Cloudinary', async ( { + page, + } ) => { + const gallery = await openGallery( page ); + + await expect( gallery ).toHaveAttribute( 'mode', 'expanded' ); + + const viewerImages = gallery.locator( + '[data-test="gallery-viewer-wrap"] img' + ); + await expect( + viewerImages, + `the gallery should show exactly ${ IMAGE_COUNT } images` + ).toHaveCount( IMAGE_COUNT ); + + // Every the widget renders, viewer and any thumbnails alike, + // must come from Cloudinary. + const attrs = await collectImageUrls( gallery.locator( 'img' ) ); + const urls = attrs.flatMap( ( { src, srcset } ) => [ + src, + ...srcsetUrls( srcset ), + ] ); + + expect( + attrs.every( ( { src } ) => !! src ), + 'every gallery image should have a src' + ).toBe( true ); + + for ( const url of urls ) { + expectCloudinaryUrl( url, cloudName ); + } + + // Each selected image appears once, identified by its public ID as + // the last path segment of the delivery URL. + const viewerUrls = ( await collectImageUrls( viewerImages ) ).map( + ( { src } ) => new URL( src ).pathname.split( '/' ).pop() + ); + expect( viewerUrls.sort() ).toEqual( + created.publicIds.map( ( id ) => id.split( '/' ).pop() ).sort() + ); + + // Expanded with 3 columns and 3 images: one row, no carousel or + // thumbnail strip. + const tops = await gallery + .locator( '[data-test="gallery-viewer-wrap"] button.assetWrapper' ) + .evaluateAll( ( nodes ) => + nodes.map( ( n ) => + Math.round( n.getBoundingClientRect().top ) + ) + ); + expect( + new Set( tops ).size, + `${ IMAGE_COUNT } images in ${ INITIAL_COLUMNS } columns should fit one row` + ).toBe( Math.ceil( IMAGE_COUNT / INITIAL_COLUMNS ) ); + await expect( gallery.locator( '[data-test="carousel"]' ) ).toHaveCount( + 0 + ); + await expect( + gallery.locator( '[data-test="gallery-thumbnail-wrap"]' ) + ).toHaveCount( 0 ); + } ); + + test( 'changing the layout and republishing changes the markup', async ( { + admin, + editor, + page, + } ) => { + await admin.editPost( created.postId ); + + // Select the block through the data store. Clicking it in the canvas + // would hit the widget's own buttons inside the block. + const clientId = await page.evaluate( () => { + const block = window.wp.data + .select( 'core/block-editor' ) + .getBlocks() + .find( ( b ) => b.name === 'cloudinary/gallery' ); + window.wp.data + .dispatch( 'core/block-editor' ) + .selectBlock( block.clientId ); + return block.clientId; + } ); + expect( clientId, 'the gallery block should load' ).toBeTruthy(); + + const isValid = await page.evaluate( + ( id ) => + window.wp.data.select( 'core/block-editor' ).getBlock( id ) + .isValid, + clientId + ); + expect( isValid, 'the gallery block should not be invalid' ).toBe( + true + ); + + await editor.openDocumentSettingsSidebar(); + + const settings = page.getByRole( 'region', { + name: 'Editor settings', + } ); + await settings + .locator( 'button.radio-select', { hasText: 'Classic' } ) + .click(); + await expect( + settings.locator( 'button.radio-select--active' ) + ).toContainText( 'Classic' ); + + await page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Save', exact: true } ) + .click(); + await expect( + page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'updated' } ) + ).toBeVisible(); + + const gallery = await openGallery( page ); + + await expect( gallery ).toHaveAttribute( 'mode', 'classic' ); + + // Classic shows one image at a time in a carousel with a thumbnail + // strip, and no expanded scroll viewer. + await expect( + gallery.locator( + '[data-test="gallery-viewer-wrap"] [data-test="carousel"]' + ) + ).toBeVisible(); + await expect( + gallery.locator( '[data-test="scroll-viewer-wrap"]' ) + ).toHaveCount( 0 ); + await expect( + gallery.locator( + '[data-test="gallery-thumbnail-wrap"] [data-test="thumbnails-wrap"]' + ) + ).toHaveCount( IMAGE_COUNT ); + + const attrs = await collectImageUrls( gallery.locator( 'img' ) ); + for ( const { src, srcset } of attrs ) { + for ( const url of [ src, ...srcsetUrls( srcset ) ] ) { + expectCloudinaryUrl( url, cloudName ); + } + } + } ); +} ); diff --git a/tests/e2e/cloudinary-image-delivery.spec.js b/tests/e2e/cloudinary-image-delivery.spec.js index 2f081b98f..4aa93159e 100644 --- a/tests/e2e/cloudinary-image-delivery.spec.js +++ b/tests/e2e/cloudinary-image-delivery.spec.js @@ -9,6 +9,7 @@ const { test, expect } = require( './fixtures' ); * Internal dependencies */ const { ensureCloudinaryConnected } = require( './utils/connection' ); +const { expectCloudinaryUrl } = require( './utils/delivery' ); const { wpCli } = require( './utils/wizard' ); const FIXTURE_PATH = path.join( __dirname, 'fixtures', 'test-image.jpg' ); @@ -22,29 +23,6 @@ let cloudName; */ let created = null; -/** - * Assert that a given image URL is served by Cloudinary under the - * expected cloud name. We intentionally do not assert specific - * transformations — those are an implementation detail of the plugin - * and may change. - * - * @param {string} rawUrl The src or srcset candidate. - * @param {string} expectedCloud The cloud name parsed from CLOUDINARY_E2E_URL. - */ -function expectCloudinaryUrl( rawUrl, expectedCloud ) { - let parsed; - try { - parsed = new URL( rawUrl ); - } catch ( e ) { - throw new Error( `Image URL is not parseable: ${ rawUrl }` ); - } - expect( parsed.host, `host of ${ rawUrl }` ).toBe( 'res.cloudinary.com' ); - expect( - parsed.pathname.startsWith( `/${ expectedCloud }/` ), - `pathname of ${ rawUrl } should start with /${ expectedCloud }/` - ).toBe( true ); -} - // @serial: needs real credentials in `cloudinary_connect` for `wp cloudinary // sync`, while every analytics spec overwrites that option with fake ones. test.describe( 'Cloudinary image delivery', { tag: '@serial' }, () => { diff --git a/tests/e2e/utils/delivery.js b/tests/e2e/utils/delivery.js new file mode 100644 index 000000000..7f82c3bf0 --- /dev/null +++ b/tests/e2e/utils/delivery.js @@ -0,0 +1,49 @@ +/** + * Assertions shared by the front-end delivery specs. + */ + +const { expect } = require( '../fixtures' ); + +/** + * Assert that a given image URL is served by Cloudinary under the + * expected cloud name. We intentionally do not assert specific + * transformations — those are an implementation detail of the plugin + * and may change. + * + * @param {string} rawUrl The src or srcset candidate. + * @param {string} expectedCloud The cloud name parsed from CLOUDINARY_E2E_URL. + */ +function expectCloudinaryUrl( rawUrl, expectedCloud ) { + let parsed; + try { + parsed = new URL( rawUrl ); + } catch ( e ) { + throw new Error( `Image URL is not parseable: ${ rawUrl }` ); + } + expect( parsed.host, `host of ${ rawUrl }` ).toBe( 'res.cloudinary.com' ); + expect( + parsed.pathname.startsWith( `/${ expectedCloud }/` ), + `pathname of ${ rawUrl } should start with /${ expectedCloud }/` + ).toBe( true ); +} + +/** + * Split a `srcset` attribute into its candidate URLs. + * + * @param {string|null} srcset The raw attribute value. + * @return {string[]} Candidate URLs, without width/density descriptors. + */ +function srcsetUrls( srcset ) { + if ( ! srcset ) { + return []; + } + return srcset + .split( ',' ) + .map( ( candidate ) => candidate.trim().split( /\s+/ )[ 0 ] ) + .filter( Boolean ); +} + +module.exports = { + expectCloudinaryUrl, + srcsetUrls, +};