From bc511a9cacd4ccb0b58f323d31144f0805495ca9 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Mon, 2 Nov 2026 17:47:08 +0530 Subject: [PATCH 1/6] refactor(e2e): Move gallery helpers to a shared util Move galleryBlock(), openGallery() and collectImageUrls() out of the gallery delivery spec into tests/e2e/utils/gallery.js, so the custom domain spec can render the Gallery block the same way. --- tests/e2e/cloudinary-gallery-delivery.spec.js | 74 ++------------ tests/e2e/utils/gallery.js | 98 +++++++++++++++++++ 2 files changed, 106 insertions(+), 66 deletions(-) create mode 100644 tests/e2e/utils/gallery.js diff --git a/tests/e2e/cloudinary-gallery-delivery.spec.js b/tests/e2e/cloudinary-gallery-delivery.spec.js index a301c2be0..6c5f98904 100644 --- a/tests/e2e/cloudinary-gallery-delivery.spec.js +++ b/tests/e2e/cloudinary-gallery-delivery.spec.js @@ -10,17 +10,17 @@ const { test, expect } = require( './fixtures' ); */ const { ensureCloudinaryConnected } = require( './utils/connection' ); const { expectCloudinaryUrl, srcsetUrls } = require( './utils/delivery' ); +const { + collectImageUrls, + galleryBlock, + openGallery: openGalleryAt, +} = require( './utils/gallery' ); 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; @@ -33,71 +33,13 @@ let cloudName; 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. + * Open the published post and wait for every gallery image to render. * * @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' ), - } ) ) - ); +function openGallery( page ) { + return openGalleryAt( page, created.postLink, IMAGE_COUNT ); } // @serial: needs real credentials in `cloudinary_connect` to sync assets, diff --git a/tests/e2e/utils/gallery.js b/tests/e2e/utils/gallery.js new file mode 100644 index 000000000..7948429a6 --- /dev/null +++ b/tests/e2e/utils/gallery.js @@ -0,0 +1,98 @@ +/** + * Helpers for specs that render the Cloudinary Gallery block. + */ + +const { expect } = require( '../fixtures' ); + +/** + * Class of the block container the Gallery Widget renders into. + * + * @type {string} + */ +const GALLERY_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. + * + * @type {number} + */ +const WIDGET_TIMEOUT = 30_000; + +/** + * 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 a 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: GALLERY_CONTAINER, + selectedImages, + displayProps_mode: mode, + displayProps_columns: columns, + }; + + return ( + `\n` + + `\n` + + `` + ); +} + +/** + * Open a published post and wait for the Gallery Widget to finish + * rendering every image in the main viewer. + * + * @param {import('@playwright/test').Page} page + * @param {string} postLink Post permalink. + * @param {number} imageCount Number of images in the gallery. + * @return {Promise} The widget root. + */ +async function openGallery( page, postLink, imageCount ) { + await page.goto( postLink ); + + const gallery = page.locator( + `.${ GALLERY_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( imageCount, { 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' ), + } ) ) + ); +} + +module.exports = { + GALLERY_CONTAINER, + WIDGET_TIMEOUT, + collectImageUrls, + galleryBlock, + openGallery, +}; From 0c8b2695fae3fb41197e813af2e686a56b51ae81 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Mon, 2 Nov 2026 17:47:10 +0530 Subject: [PATCH 2/6] test(e2e): Support custom domains in delivery utils - ensureCloudinaryConnected() accepts { cname, privateCdn } and adds them to the connection string. It now fails when the plugin does not save the new string. - expectCloudinaryUrl() accepts { host, cloudName, privateCdn }. - routeCnameToCloudinary() sends requests for a fake custom domain on to res.cloudinary.com, so the images still load. --- tests/e2e/utils/connection.js | 54 +++++++++++++++++++++++++++-- tests/e2e/utils/delivery.js | 64 +++++++++++++++++++++++++++++++---- 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/tests/e2e/utils/connection.js b/tests/e2e/utils/connection.js index 10950714b..a67e23547 100644 --- a/tests/e2e/utils/connection.js +++ b/tests/e2e/utils/connection.js @@ -28,6 +28,33 @@ function parseCloudName( cloudinaryUrl ) { return match[ 1 ]; } +/** + * Add custom domain parameters to a connection string. + * + * @param {string} cloudinaryUrl The `cloudinary://` connection string. + * @param {Object} options + * @param {string} [options.cname] Custom delivery domain. + * @param {boolean} [options.privateCdn] Set to false to keep the cloud name + * in the delivery path. + * @return {string} The connection string with the parameters added. + */ +function withConnectionParams( cloudinaryUrl, { cname, privateCdn } ) { + const params = new URLSearchParams(); + if ( cname ) { + params.set( 'cname', cname ); + } + if ( false === privateCdn ) { + params.set( 'private_cdn', 'false' ); + } + const query = params.toString(); + if ( ! query ) { + return cloudinaryUrl; + } + return `${ cloudinaryUrl }${ + cloudinaryUrl.includes( '?' ) ? '&' : '?' + }${ query }`; +} + /** * Set the plugin's `cloudinary_connect` option directly so the plugin * is "connected" for the duration of the spec. @@ -36,10 +63,21 @@ function parseCloudName( cloudinaryUrl ) { * NOT pre-populate `cloudinary_connection_signature` or * `cloudinary_status`; the plugin will populate those on first need. * + * With `cname`, the custom domain is added to the connection string the + * same way a user adds it in the settings. The plugin only checks that it + * looks like a domain and never resolves it, so a fake domain works. + * + * @param {Object} [options] + * @param {string} [options.cname] Custom delivery domain. + * @param {boolean} [options.privateCdn] Set to false to keep the cloud name + * in the delivery path. * @return {{ cloudName: string }} The cloud name extracted from the URL. */ -function ensureCloudinaryConnected() { - const cloudinaryUrl = getCloudinaryUrlFromEnv(); +function ensureCloudinaryConnected( options = {} ) { + const cloudinaryUrl = withConnectionParams( + getCloudinaryUrlFromEnv(), + options + ); const cloudName = parseCloudName( cloudinaryUrl ); // Build the JSON payload the plugin expects. @@ -56,6 +94,18 @@ function ensureCloudinaryConnected() { '--format=json', ] ); + // The plugin tests a changed connection string against the API before + // it saves it, and keeps the old one when the test fails. Fail here + // rather than let a spec run against the wrong connection. + const saved = JSON.parse( + wpCli( [ 'option', 'get', 'cloudinary_connect', '--format=json' ] ) + ); + if ( saved?.cloudinary_url !== cloudinaryUrl ) { + throw new Error( + 'The plugin did not save the new Cloudinary connection string. Check that CLOUDINARY_E2E_URL is valid.' + ); + } + return { cloudName }; } diff --git a/tests/e2e/utils/delivery.js b/tests/e2e/utils/delivery.js index db53d43c8..c4771c96a 100644 --- a/tests/e2e/utils/delivery.js +++ b/tests/e2e/utils/delivery.js @@ -4,27 +4,75 @@ const { expect } = require( '../fixtures' ); +/** + * Default Cloudinary delivery host. + * + * @type {string} + */ +const CLOUDINARY_HOST = 'res.cloudinary.com'; + /** * 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. + * Pass an object to check delivery through a custom domain. With + * `privateCdn` (the plugin's default for a custom domain), the path does + * not start with the cloud name. + * + * @param {string} rawUrl The src or srcset candidate. + * @param {string|{ cloudName: string, host?: string, privateCdn?: boolean }} expected The cloud name parsed from + * CLOUDINARY_E2E_URL, or the + * expected host and path form. */ -function expectCloudinaryUrl( rawUrl, expectedCloud ) { +function expectCloudinaryUrl( rawUrl, expected ) { + const { + cloudName, + host = CLOUDINARY_HOST, + privateCdn = false, + } = 'string' === typeof expected ? { cloudName: expected } : expected; + 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.host, `host of ${ rawUrl }` ).toBe( host ); expect( - parsed.pathname.startsWith( `/${ expectedCloud }/` ), - `pathname of ${ rawUrl } should start with /${ expectedCloud }/` - ).toBe( true ); + parsed.pathname.startsWith( `/${ cloudName }/` ), + `pathname of ${ rawUrl } should ${ + privateCdn ? 'not ' : '' + }start with /${ cloudName }/` + ).toBe( ! privateCdn ); +} + +/** + * Send requests for a fake custom domain on to res.cloudinary.com, so + * images delivered through it still load. + * + * Cloudinary serves a custom domain at the same path, with the cloud name + * at the start of the path only when the domain is not a private CDN. + * + * @param {import('@playwright/test').Page} page + * @param {Object} options + * @param {string} options.cname The fake custom domain. + * @param {string} options.cloudName The cloud name. + * @param {boolean} options.privateCdn Whether the path leaves out the cloud name. + * @return {Promise} + */ +async function routeCnameToCloudinary( + page, + { cname, cloudName, privateCdn } +) { + await page.route( `https://${ cname }/**`, ( route ) => { + const url = new URL( route.request().url() ); + const prefix = privateCdn ? `/${ cloudName }` : ''; + return route.continue( { + url: `https://${ CLOUDINARY_HOST }${ prefix }${ url.pathname }${ url.search }`, + } ); + } ); } /** @@ -74,7 +122,9 @@ function srcsetUrls( srcset ) { } module.exports = { + CLOUDINARY_HOST, expectCloudinaryUrl, parseSrcset, + routeCnameToCloudinary, srcsetUrls, }; From b716198f7e1de7990728cd7b0d4fb8d8b408409a Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Mon, 2 Nov 2026 17:47:12 +0530 Subject: [PATCH 3/6] test(e2e): Cover delivery through a custom domain Connect with a fake custom domain and check that the image block, the Gallery widget and the Cloudinary video player use it. Check that private_cdn=false keeps the cloud name in the path. Restore the plain connection in afterAll. Refs WPP-1240 --- tests/e2e/cloudinary-cname-delivery.spec.js | 392 ++++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 tests/e2e/cloudinary-cname-delivery.spec.js diff --git a/tests/e2e/cloudinary-cname-delivery.spec.js b/tests/e2e/cloudinary-cname-delivery.spec.js new file mode 100644 index 000000000..657708888 --- /dev/null +++ b/tests/e2e/cloudinary-cname-delivery.spec.js @@ -0,0 +1,392 @@ +/** + * External dependencies + */ +const fs = require( 'fs' ); +const path = require( 'path' ); +const { test, expect } = require( './fixtures' ); + +/** + * Internal dependencies + */ +const { ensureCloudinaryConnected } = require( './utils/connection' ); +const { + expectCloudinaryUrl, + routeCnameToCloudinary, + srcsetUrls, +} = require( './utils/delivery' ); +const { + collectImageUrls, + galleryBlock, + openGallery, +} = require( './utils/gallery' ); +const { wpCli, wpEvalFile } = require( './utils/wizard' ); + +const IMAGE_FIXTURE = path.join( __dirname, 'fixtures', 'test-image.jpg' ); +const VIDEO_FIXTURE = path.join( __dirname, 'fixtures', 'test-video.mp4' ); + +/** + * A fake custom domain. The plugin only checks that it looks like a domain + * and never resolves it. Requests to it are sent on to res.cloudinary.com + * by `routeCnameToCloudinary()`, so the images still load. + * + * @type {string} + */ +const CNAME = 'cdn.e2e-cloudinary.test'; + +const IMAGE_COUNT = 3; + +/** + * Settings pinned for the spec, restored in afterAll. Without lazy load + * the server writes the final URLs into `src` and `srcset`, and the + * breakpoints make sure there is a Cloudinary `srcset` to check. The + * Cloudinary player is what receives the custom domain for video. + * + * @type {Record} + */ +const SETTINGS = { + use_lazy_load: 'off', + enable_breakpoints: 'on', + video_player: 'cld', +}; + +let cloudName; + +/** @type {Record|null} */ +let previousSettings = null; + +/** + * IDs of every attachment and post the spec creates, removed in afterAll. + * Each ID is recorded as soon as it exists, so a setup that fails part way + * is still cleaned up. + * + * @type {number[]} + */ +let createdIds = []; + +/** + * @type {{ imagePost: { link: string, attachmentId: number }, galleryPost: { link: string }, videoPost: { link: string } }|null} + */ +let created = null; + +/** + * Save plugin settings through the settings API and return the old values. + * + * @param {Record} values Setting slug to value. + * @return {Record} Setting slug to the value before the write. + */ +function saveSettings( values ) { + return JSON.parse( + wpEvalFile( ` + $settings = get_plugin_instance()->settings; + $previous = array(); + foreach ( json_decode( '${ JSON.stringify( + values + ) }', true ) as $slug => $value ) { + $setting = $settings->get_setting( $slug ); + $previous[ $slug ] = $setting->get_value(); + $setting->save_value( $value ); + } + echo wp_json_encode( $previous ); + ` ) + ); +} + +/** + * Upload a fixture file through REST and record its ID for cleanup. + * + * The timestamp in the filename gives a fresh public ID, so no CDN cache + * from an earlier run applies. + * + * @param {Object} requestUtils RequestUtils fixture. + * @param {string} fixturePath Path to the fixture. + * @param {string} contentType MIME type of the fixture. + * @param {string} filename Filename for the upload. + * @return {Promise<{ id: number, source_url: string }>} The created attachment. + */ +async function upload( requestUtils, fixturePath, contentType, filename ) { + const media = await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/media', + headers: { + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${ filename }"`, + }, + data: fs.readFileSync( fixturePath ), + } ); + createdIds.push( media.id ); + return media; +} + +/** + * Publish a post and record its ID for cleanup. + * + * @param {Object} requestUtils RequestUtils fixture. + * @param {string} title Post title. + * @param {string} content Serialized block content. + * @return {Promise} The post permalink. + */ +async function publish( requestUtils, title, content ) { + const post = await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/posts', + data: { + status: 'publish', + title: `${ title } ${ Date.now() }`, + content, + }, + } ); + createdIds.push( post.id ); + return post.link; +} + +/** + * Open the image post and return the URLs of the inline image. + * + * @param {import('@playwright/test').Page} page + * @return {Promise} The `src` and every `srcset` URL. + */ +async function imageBlockUrls( page ) { + await page.goto( created.imagePost.link ); + + const img = page + .locator( + `.wp-block-image img.wp-image-${ created.imagePost.attachmentId }` + ) + .first(); + await expect( img ).toBeVisible(); + + // The image only loads if the delivery URL works, which proves the + // path the plugin built on the custom domain is a valid Cloudinary path. + await expect + .poll( () => img.evaluate( ( el ) => el.complete && el.naturalWidth ), { + message: 'the image should load through the custom domain', + } ) + .toBeGreaterThan( 0 ); + + const { src, srcset } = await img.evaluate( ( el ) => ( { + src: el.getAttribute( 'src' ), + srcset: el.getAttribute( 'srcset' ), + } ) ); + expect( src, 'the image should have a src' ).toBeTruthy(); + expect( srcset, 'the image should have a srcset' ).toBeTruthy(); + + return [ src, ...srcsetUrls( srcset ) ]; +} + +// @serial: needs real credentials in `cloudinary_connect` to sync assets, +// while every analytics spec overwrites that option with fake ones. It also +// changes the connection string, which every other delivery spec reads. +test.describe( + 'Cloudinary delivery through a custom domain', + { tag: '@serial' }, + () => { + test.describe.configure( { mode: 'serial' } ); + + test.beforeAll( async ( { requestUtils } ) => { + // Connect with the custom domain before the upload, as a site + // that uses one would be. + ( { cloudName } = ensureCloudinaryConnected( { cname: CNAME } ) ); + previousSettings = saveSettings( SETTINGS ); + + const stamp = Date.now(); + const images = []; + for ( let i = 0; i < IMAGE_COUNT; i++ ) { + images.push( + await upload( + requestUtils, + IMAGE_FIXTURE, + 'image/jpeg', + `cname-e2e-${ stamp }-${ i }.jpg` + ) + ); + } + const attachmentIds = images.map( ( media ) => media.id ); + + // Same endpoint the block editor calls when images are picked + // for a gallery. It syncs each attachment and returns the + // block's `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 ); + + // The local URL from the upload response, as the editor would + // insert it. The plugin replaces it with the delivery URL. + const image = images[ 0 ]; + const imageLink = await publish( + requestUtils, + 'Cloudinary CNAME image e2e', + `\n` + + `
\n` + + `` + ); + + const galleryLink = await publish( + requestUtils, + 'Cloudinary CNAME gallery e2e', + galleryBlock( selectedImages, { + mode: 'expanded', + columns: IMAGE_COUNT, + } ) + ); + + const video = await upload( + requestUtils, + VIDEO_FIXTURE, + 'video/mp4', + `cname-e2e-${ stamp }.mp4` + ); + const videoLink = await publish( + requestUtils, + 'Cloudinary CNAME video e2e', + `\n` + + `
\n` + + `` + ); + + // Same reason as the image delivery spec: sync synchronously + // instead of waiting for the auto-sync queue. + wpCli( [ 'cloudinary', 'sync' ] ); + + created = { + imagePost: { link: imageLink, attachmentId: image.id }, + galleryPost: { link: galleryLink }, + videoPost: { link: videoLink }, + }; + } ); + + test.afterAll( () => { + // Put the plain connection back first: every other delivery + // spec in the serial run expects res.cloudinary.com URLs. + try { + ensureCloudinaryConnected(); + } finally { + if ( previousSettings ) { + saveSettings( previousSettings ); + previousSettings = null; + } + created = null; + const ids = createdIds; + createdIds = []; + + // 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 ids ) { + try { + wpCli( [ 'post', 'delete', String( id ), '--force' ] ); + } catch ( e ) { + console.warn( + `Cleanup of post ${ id } failed:`, + e.message + ); + } + } + } + } ); + + test( 'image block src and srcset use the custom domain', async ( { + page, + } ) => { + await routeCnameToCloudinary( page, { + cname: CNAME, + cloudName, + privateCdn: true, + } ); + + for ( const url of await imageBlockUrls( page ) ) { + expectCloudinaryUrl( url, { + host: CNAME, + cloudName, + privateCdn: true, + } ); + } + } ); + + test( 'gallery widget images use the custom domain', async ( { + page, + } ) => { + await routeCnameToCloudinary( page, { + cname: CNAME, + cloudName, + privateCdn: true, + } ); + + // Waits until every image reports data-ready="true", which + // needs the custom domain requests to succeed. + const gallery = await openGallery( + page, + created.galleryPost.link, + IMAGE_COUNT + ); + + await expect( + gallery.locator( '[data-test="gallery-viewer-wrap"] img' ), + `the gallery should show exactly ${ IMAGE_COUNT } images` + ).toHaveCount( IMAGE_COUNT ); + + // Every the widget renders, viewer and thumbnails alike. + const attrs = await collectImageUrls( gallery.locator( 'img' ) ); + expect( + attrs.every( ( { src } ) => !! src ), + 'every gallery image should have a src' + ).toBe( true ); + for ( const { src, srcset } of attrs ) { + for ( const url of [ src, ...srcsetUrls( srcset ) ] ) { + expectCloudinaryUrl( url, { + host: CNAME, + cloudName, + privateCdn: true, + } ); + } + } + } ); + + test( 'Cloudinary video player receives the custom domain', async ( { + page, + } ) => { + await page.goto( created.videoPost.link ); + + const iframe = page + .locator( 'figure.wp-block-embed.is-type-video iframe' ) + .first(); + await expect( + iframe, + 'Cloudinary player iframe should be emitted in place of