diff --git a/tests/e2e/cloudinary-image-delivery.spec.js b/tests/e2e/cloudinary-image-delivery.spec.js new file mode 100644 index 000000000..45c89dbc4 --- /dev/null +++ b/tests/e2e/cloudinary-image-delivery.spec.js @@ -0,0 +1,202 @@ +/** + * External dependencies + */ +const fs = require( 'fs' ); +const path = require( 'path' ); +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Internal dependencies + */ +const { ensureCloudinaryConnected } = require( './utils/connection' ); +const { wpCli } = require( './utils/wizard' ); + +const FIXTURE_PATH = path.join( __dirname, 'fixtures', 'test-image.jpg' ); + +let cloudName; + +/** + * Per-test scratch space populated by beforeEach. + * + * @type {{ postId: number, attachmentId: number, postLink: string }|null} + */ +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 ); +} + +test.describe( 'Cloudinary image delivery', () => { + test.beforeAll( () => { + ( { cloudName } = ensureCloudinaryConnected() ); + } ); + + test.beforeEach( async ( { requestUtils } ) => { + // Upload the fixture image via REST. + const file = fs.readFileSync( FIXTURE_PATH ); + const media = await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/media', + headers: { + 'Content-Type': 'image/jpeg', + 'Content-Disposition': 'attachment; filename="test-image.jpg"', + }, + data: file, + } ); + + const attachmentId = media.id; + const sourceUrl = media.source_url; + + // Create a published post that uses the attachment as both + // featured image and an inline image block. + const content = + `\n` + + `
\n` + + ``; + + const post = await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/posts', + data: { + status: 'publish', + title: `Cloudinary e2e ${ Date.now() }`, + content, + featured_media: attachmentId, + }, + } ); + + created = { + postId: post.id, + attachmentId, + postLink: post.link, + }; + + // The plugin's URL rewriting depends on the asset being synced + // to Cloudinary. With auto_sync enabled (wizard default), the + // first front-end render queues the sync but renders local + // URLs. Driving `wp cloudinary sync` here makes the test + // deterministic without relying on the cron-driven queue. + wpCli( [ 'cloudinary', 'sync' ] ); + } ); + + test.afterEach( async () => { + if ( ! created ) { + return; + } + const { postId, attachmentId } = created; + created = null; + + // Best-effort cleanup via WP-CLI. We do not use the REST API + // here because the test wp-env runs without pretty permalinks, + // which makes appending `force=true` to a `?rest_route=`-style + // URL fragile. WP-CLI is unambiguous and we already use it in + // other helpers (see utils/wizard.js). + try { + wpCli( [ 'post', 'delete', String( postId ), '--force' ] ); + } catch ( e ) { + // eslint-disable-next-line no-console + console.warn( 'Post cleanup failed:', e.message ); + } + try { + wpCli( [ 'post', 'delete', String( attachmentId ), '--force' ] ); + } catch ( e ) { + // eslint-disable-next-line no-console + console.warn( 'Media cleanup failed:', e.message ); + } + } ); + + test( 'serves featured image and inline image via Cloudinary', async ( { + page, + } ) => { + expect( created, 'post + attachment should be created' ).not.toBeNull(); + + await page.goto( created.postLink ); + + // Featured image: themes mark it with .wp-post-image. + const featured = page.locator( 'img.wp-post-image' ).first(); + await expect( + featured, + 'featured image should render on the post page' + ).toBeVisible(); + + // Inline image from the_content. Scope to .wp-block-image so + // the featured image (also tagged wp-image- by some themes) + // is not double-counted. + const inline = page + .locator( `.wp-block-image img.wp-image-${ created.attachmentId }` ) + .first(); + await expect( + inline, + 'inline image block should render in post content' + ).toBeVisible(); + + // With the wizard's default settings, the plugin lazy-loads + // images: the initial server-rendered markup carries a tiny + // SVG placeholder in `src` and the real Cloudinary delivery + // is encoded in `data-public-id` + `data-transformations` + + // `data-version`. The JS then constructs the Cloudinary URL + // and swaps it into `src` once the image scrolls into view. + // + // We assert two things per image: + // 1. `data-public-id` is present — proves the plugin marked + // the element for Cloudinary delivery (this is its own + // explicit signal, independent of lazyload / theme markup). + // 2. Any HTTP(S) URL attribute present (`src` after the JS + // swap, `srcset` when emitted by the theme) is served + // from `res.cloudinary.com//...`. + const httpUrls = []; + for ( const loc of [ featured, inline ] ) { + await loc.scrollIntoViewIfNeeded(); + + const publicId = await loc.getAttribute( 'data-public-id' ); + expect( + publicId, + 'plugin should mark the image with data-public-id' + ).toBeTruthy(); + + const src = await loc.getAttribute( 'src' ); + if ( src && /^https?:\/\//.test( src ) ) { + httpUrls.push( src ); + } + + const srcset = await loc.getAttribute( 'srcset' ); + if ( srcset ) { + const firstCandidate = srcset + .split( ',' )[ 0 ] + .trim() + .split( /\s+/ )[ 0 ]; + if ( firstCandidate && /^https?:\/\//.test( firstCandidate ) ) { + httpUrls.push( firstCandidate ); + } + } + } + + expect( + httpUrls.length, + 'at least one image should expose a Cloudinary URL via src or srcset' + ).toBeGreaterThan( 0 ); + + for ( const url of httpUrls ) { + expectCloudinaryUrl( url, cloudName ); + } + } ); +} ); diff --git a/tests/e2e/fixtures/test-image.jpg b/tests/e2e/fixtures/test-image.jpg new file mode 100644 index 000000000..5cf40b2f0 Binary files /dev/null and b/tests/e2e/fixtures/test-image.jpg differ diff --git a/tests/e2e/utils/connection.js b/tests/e2e/utils/connection.js new file mode 100644 index 000000000..66e27f561 --- /dev/null +++ b/tests/e2e/utils/connection.js @@ -0,0 +1,65 @@ +/** + * Helpers for putting the Cloudinary plugin into a "connected" state + * without driving the wizard UI. + * + * Setting the connection option directly via WP-CLI is faster and + * keeps this spec decoupled from the wizard spec, which exercises + * the UI path separately. + */ + +const { wpCli, getCloudinaryUrlFromEnv } = require( './wizard' ); + +/** + * Parse the cloud name out of a `cloudinary://key:secret@cloud_name` URL. + * + * @param {string} cloudinaryUrl + * @return {string} The cloud_name segment. + * @throws If the URL does not match the expected shape. + */ +function parseCloudName( cloudinaryUrl ) { + const match = /^cloudinary:\/\/[^:]+:[^@]+@([A-Za-z0-9_-]+)/.exec( + cloudinaryUrl + ); + if ( ! match ) { + throw new Error( + `Could not parse cloud name from CLOUDINARY_E2E_URL: ${ cloudinaryUrl }` + ); + } + return match[ 1 ]; +} + +/** + * Set the plugin's `cloudinary_connect` option directly so the plugin + * is "connected" for the duration of the spec. + * + * Mirrors what the wizard saves on completion. We deliberately do + * NOT pre-populate `cloudinary_connection_signature` or + * `cloudinary_status`; the plugin will populate those on first need. + * + * @return {{ cloudName: string }} The cloud name extracted from the URL. + */ +function ensureCloudinaryConnected() { + const cloudinaryUrl = getCloudinaryUrlFromEnv(); + const cloudName = parseCloudName( cloudinaryUrl ); + + // Build the JSON payload the plugin expects. + const payload = JSON.stringify( { cloudinary_url: cloudinaryUrl } ); + + // `wp option update --format=json ` requires the + // value to be a valid JSON literal. Wrap in single quotes for the + // docker-exec'd shell. + wpCli( [ + 'option', + 'update', + 'cloudinary_connect', + `'${ payload }'`, + '--format=json', + ] ); + + return { cloudName }; +} + +module.exports = { + parseCloudName, + ensureCloudinaryConnected, +};