diff --git a/tests/e2e/cloudinary-breakpoints-lazy-load.spec.js b/tests/e2e/cloudinary-breakpoints-lazy-load.spec.js new file mode 100644 index 000000000..364e920ef --- /dev/null +++ b/tests/e2e/cloudinary-breakpoints-lazy-load.spec.js @@ -0,0 +1,381 @@ +/** + * External dependencies + */ +const fs = require( 'fs' ); +const path = require( 'path' ); +const { test, expect } = require( './fixtures' ); + +/** + * Internal dependencies + */ +const { ensureCloudinaryConnected } = require( './utils/connection' ); +const { + expectCloudinaryUrl, + parseSrcset, + srcsetUrls, +} = require( './utils/delivery' ); +const { readSettings, saveSettings } = require( './utils/settings' ); +const { wpCli, wpEvalFile } = require( './utils/wizard' ); + +const FIXTURE_PATH = path.join( __dirname, 'fixtures', 'test-image.jpg' ); + +/** + * Intrinsic width of the fixture image. + * + * @type {number} + */ +const FIXTURE_WIDTH = 1200; + +/** + * Breakpoint settings that give widths core WordPress never produces for a + * 1200x900 image (its sizes are 1200, 1024, 768 and 300). A width from this + * set in `srcset` can only come from the plugin's breakpoints. + * + * @type {{ max_width: number, min_width: number, pixel_step: number }} + */ +const BREAKPOINTS = { + max_width: 1100, + min_width: 500, + pixel_step: 300, +}; + +/** + * Widths `Responsive_Breakpoints::apply_breakpoints()` builds for + * BREAKPOINTS: the image's own width, then max down to min in steps. + * + * @type {number[]} + */ +const EXPECTED_WIDTHS = [ FIXTURE_WIDTH, 1100, 800, 500 ]; + +/** + * Every setting the spec writes. Pinned in each test so the result does not + * depend on the order the tests run in or on state left by other specs. + * + * @type {string[]} + */ +const MANAGED_SETTINGS = [ + 'enable_breakpoints', + 'use_lazy_load', + 'lazy_placeholder', + 'max_width', + 'min_width', + 'pixel_step', +]; + +let cloudName; + +/** @type {Record|null} */ +let previousSettings = null; + +/** + * Objects created in beforeAll and removed in afterAll. + * + * @type {{ postId: number, attachmentId: number, postLink: string }|null} + */ +let created = null; + +/** + * Apply the breakpoint and lazy-load toggles, with the fixed breakpoint + * range and the blur placeholder. + * + * @param {Object} options + * @param {boolean} options.breakpoints Enable responsive breakpoints. + * @param {boolean} options.lazyLoad Enable lazy loading. + */ +function configure( { breakpoints, lazyLoad } ) { + saveSettings( { + ...BREAKPOINTS, + enable_breakpoints: breakpoints ? 'on' : 'off', + use_lazy_load: lazyLoad ? 'on' : 'off', + lazy_placeholder: 'blur', + } ); +} + +/** + * Parse the width descriptors out of a `srcset` attribute. + * + * @param {string|null} srcset The raw attribute value. + * @return {number[]} Widths in source order. + */ +function srcsetWidths( srcset ) { + return parseSrcset( srcset ) + .map( ( candidate ) => candidate.descriptor ) + .filter( ( descriptor ) => /^\d+w$/.test( descriptor ) ) + .map( ( descriptor ) => parseInt( descriptor, 10 ) ); +} + +/** + * Widths of the image sizes WordPress registered for an attachment, + * including the full size. + * + * @param {number} attachmentId Attachment ID. + * @return {number[]} Widths in pixels. + */ +function attachmentWidths( attachmentId ) { + return JSON.parse( + wpEvalFile( ` + $meta = wp_get_attachment_metadata( ${ Number( attachmentId ) } ); + $widths = array( (int) $meta['width'] ); + foreach ( $meta['sizes'] as $size ) { + $widths[] = (int) $size['width']; + } + echo wp_json_encode( $widths ); + ` ) + ); +} + +/** + * Load the post with JavaScript disabled and return the attributes of the + * inline image as the server rendered them. + * + * The lazy-load script rewrites `src` and `srcset` once it runs, so the + * server markup can only be read reliably without it. + * + * @param {import('@playwright/test').Browser} browser + * @param {string} baseURL + * @return {Promise<{ attrs: Record, html: string }>} Image attributes and the page HTML. + */ +async function serverMarkup( browser, baseURL ) { + const context = await browser.newContext( { + baseURL, + javaScriptEnabled: false, + } ); + try { + const page = await context.newPage(); + await page.goto( created.postLink ); + const img = page + .locator( `.wp-block-image img.wp-image-${ created.attachmentId }` ) + .first(); + await expect( + img, + 'inline image block should render in post content' + ).toBeAttached(); + + const attrs = await img.evaluate( ( el ) => + Object.fromEntries( + [ ...el.attributes ].map( ( a ) => [ a.name, a.value ] ) + ) + ); + return { attrs, html: await page.content() }; + } finally { + await context.close(); + } +} + +// @serial: needs real credentials in `cloudinary_connect` for `wp cloudinary +// sync`, while every analytics spec overwrites that option with fake ones. +test.describe( + 'Cloudinary responsive breakpoints and lazy load', + { tag: '@serial' }, + () => { + test.beforeAll( async ( { requestUtils } ) => { + ( { cloudName } = ensureCloudinaryConnected() ); + previousSettings = readSettings( MANAGED_SETTINGS ); + + // A timestamped filename gives every run a fresh public ID, + // so no CDN cache from an earlier run can interfere. + const media = await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/media', + headers: { + 'Content-Type': 'image/jpeg', + 'Content-Disposition': `attachment; filename="breakpoints-${ Date.now() }.jpg"`, + }, + data: fs.readFileSync( FIXTURE_PATH ), + } ); + + const content = + `\n` + + `
\n` + + ``; + + const post = await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/posts', + data: { + status: 'publish', + title: `Cloudinary breakpoints e2e ${ Date.now() }`, + content, + }, + } ); + + created = { + postId: post.id, + attachmentId: media.id, + postLink: post.link, + }; + + // Same reason as the image delivery spec: sync synchronously + // instead of waiting for the auto-sync queue. + wpCli( [ 'cloudinary', 'sync' ] ); + } ); + + test.afterAll( () => { + if ( previousSettings ) { + saveSettings( previousSettings ); + previousSettings = null; + } + if ( ! created ) { + return; + } + const { postId, attachmentId } = 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, attachmentId ] ) { + try { + wpCli( [ 'post', 'delete', String( id ), '--force' ] ); + } catch ( e ) { + console.warn( + `Cleanup of post ${ id } failed:`, + e.message + ); + } + } + } ); + + test( 'configured min/max/step produce matching srcset widths', async ( { + browser, + baseURL, + } ) => { + // Breakpoints are only written to `srcset` on the server when + // lazy loading is off; with lazy load on, the script picks + // the size at runtime instead. + configure( { breakpoints: true, lazyLoad: false } ); + + const { attrs } = await serverMarkup( browser, baseURL ); + + expect( + attrs[ 'data-responsive' ], + 'breakpoints should mark the image as responsive' + ).toBeDefined(); + expect( srcsetWidths( attrs.srcset ) ).toEqual( EXPECTED_WIDTHS ); + + const urls = srcsetUrls( attrs.srcset ); + expect( urls ).toHaveLength( EXPECTED_WIDTHS.length ); + for ( const [ i, url ] of urls.entries() ) { + expectCloudinaryUrl( url, cloudName ); + // The first candidate is the original `src`; the rest + // carry the breakpoint width as a transformation. + if ( i > 0 ) { + expect( url, `transformation of ${ url }` ).toMatch( + new RegExp( `[/,]w_${ EXPECTED_WIDTHS[ i ] }[,/]` ) + ); + } + } + + expect( attrs.sizes ).toBe( + `(max-width: ${ FIXTURE_WIDTH }px) 100vw, ${ FIXTURE_WIDTH }px` + ); + } ); + + test( 'disabling breakpoints removes the breakpoint srcset', async ( { + browser, + baseURL, + } ) => { + configure( { breakpoints: false, lazyLoad: false } ); + + const { attrs } = await serverMarkup( browser, baseURL ); + + expect( + attrs[ 'data-responsive' ], + 'the responsive marker should be gone' + ).toBeUndefined(); + + // WordPress still writes its own srcset from the registered + // image sizes. Every width must be one of those sizes, so none + // of the breakpoint-only widths can appear. + const widths = srcsetWidths( attrs.srcset ); + expect( + widths.length, + 'WordPress should still write its own srcset' + ).toBeGreaterThan( 0 ); + const sizeWidths = attachmentWidths( created.attachmentId ); + for ( const width of widths ) { + expect( + sizeWidths, + `srcset width ${ width }w should be a registered image size` + ).toContain( width ); + } + for ( const width of EXPECTED_WIDTHS.slice( 1 ) ) { + expect( + widths, + `srcset should not contain breakpoint width ${ width }w` + ).not.toContain( width ); + } + } ); + + test( 'lazy load renders the placeholder markup', async ( { + browser, + baseURL, + page, + } ) => { + configure( { breakpoints: true, lazyLoad: true } ); + + const { attrs, html } = await serverMarkup( browser, baseURL ); + + expect( attrs[ 'data-cloudinary' ] ).toBe( 'lazy' ); + expect( + attrs.src, + 'src should be the inline SVG placeholder' + ).toMatch( /^data:image\/svg\+xml/ ); + expect( attrs[ 'data-size' ] ).toMatch( /^\d+ \d+$/ ); + expect( attrs.onload ).toContain( 'CLDBind' ); + expect( attrs[ 'data-public-id' ] ).toBeTruthy(); + expect( attrs[ 'data-delivery' ] ).toBe( 'upload' ); + + // The breakpoint srcset is not rendered on the server; the + // script sizes the image at runtime from data-responsive. + expect( attrs.srcset ).toBeUndefined(); + expect( attrs[ 'data-responsive' ] ).toBeDefined(); + + // The inline loader config carries the blur placeholder + // transformation. + const config = /var CLDLB = (\{[^\n]*?\});/.exec( html ); + expect( + config, + 'the CLDLB loader config should be printed' + ).not.toBeNull(); + const parsed = JSON.parse( config[ 1 ] ); + expect( parsed.use_lazy_load ).toBe( 'on' ); + expect( parsed.lazy_placeholder ).toBe( 'blur' ); + expect( parsed.placeholder ).toContain( 'e_blur:2000' ); + + // With JavaScript on, the script swaps in the Cloudinary URL + // once the image is in view. + await page.goto( created.postLink ); + const img = page + .locator( + `.wp-block-image img.wp-image-${ created.attachmentId }` + ) + .first(); + await img.scrollIntoViewIfNeeded(); + await expect( img ).toHaveAttribute( + 'src', + /^https:\/\/res\.cloudinary\.com\// + ); + expectCloudinaryUrl( await img.getAttribute( 'src' ), cloudName ); + } ); + + test( 'disabling lazy load removes the placeholder markup', async ( { + browser, + baseURL, + } ) => { + // Breakpoints are off too, so `src` is the plain delivery URL + // rather than one the breakpoints feature sized. + configure( { breakpoints: false, lazyLoad: false } ); + + const { attrs, html } = await serverMarkup( browser, baseURL ); + + expect( attrs[ 'data-cloudinary' ] ).toBeUndefined(); + expect( attrs[ 'data-size' ] ).toBeUndefined(); + expect( attrs.onload || '' ).not.toContain( 'CLDBind' ); + expect( attrs[ 'data-srcset' ] ).toBeUndefined(); + expect( html ).not.toContain( 'var CLDLB' ); + + // The real Cloudinary URL is in `src` straight from the server. + expectCloudinaryUrl( attrs.src, cloudName ); + } ); + } +); diff --git a/tests/e2e/cloudinary-global-transformations.spec.js b/tests/e2e/cloudinary-global-transformations.spec.js index 705a14e9a..6cf8caf8e 100644 --- a/tests/e2e/cloudinary-global-transformations.spec.js +++ b/tests/e2e/cloudinary-global-transformations.spec.js @@ -10,7 +10,8 @@ const { test, expect } = require( './fixtures' ); */ const { ensureCloudinaryConnected } = require( './utils/connection' ); const { expectCloudinaryUrl } = require( './utils/delivery' ); -const { wpCli, wpEvalFile } = require( './utils/wizard' ); +const { readSettings, saveSettings } = require( './utils/settings' ); +const { wpCli } = require( './utils/wizard' ); const FIXTURE_PATH = path.join( __dirname, 'fixtures', 'test-image.jpg' ); @@ -59,42 +60,6 @@ let created = null; */ let createdIds = []; -/** - * Save plugin settings through the settings API. - * - * @param {Record} values Setting slug to value. - */ -function saveSettings( values ) { - wpEvalFile( ` - $settings = get_plugin_instance()->settings; - foreach ( json_decode( '${ JSON.stringify( - values - ) }', true ) as $slug => $value ) { - $settings->get_setting( $slug )->save_value( $value ); - } - ` ); -} - -/** - * Read the current value of the managed settings. - * - * @return {Record} Setting slug to value. - */ -function readSettings() { - return JSON.parse( - wpEvalFile( ` - $settings = get_plugin_instance()->settings; - $values = array(); - foreach ( json_decode( '${ JSON.stringify( - MANAGED_SETTINGS - ) }', true ) as $slug ) { - $values[ $slug ] = $settings->get_setting( $slug )->get_value(); - } - echo wp_json_encode( $values ); - ` ) - ); -} - /** * Upload the fixture, embed it in a published post and sync it. * @@ -214,7 +179,7 @@ test.describe( () => { test.beforeAll( async ( { requestUtils } ) => { ( { cloudName } = ensureCloudinaryConnected() ); - previousSettings = readSettings(); + previousSettings = readSettings( MANAGED_SETTINGS ); saveSettings( BASE_SETTINGS ); created = await uploadSyncedImagePost( requestUtils ); } ); diff --git a/tests/e2e/cloudinary-svg-delivery.spec.js b/tests/e2e/cloudinary-svg-delivery.spec.js index 7d86990ef..40df62a7a 100644 --- a/tests/e2e/cloudinary-svg-delivery.spec.js +++ b/tests/e2e/cloudinary-svg-delivery.spec.js @@ -10,6 +10,7 @@ const { test, expect } = require( './fixtures' ); */ const { ensureCloudinaryConnected } = require( './utils/connection' ); const { expectCloudinaryUrl } = require( './utils/delivery' ); +const { saveSettings } = require( './utils/settings' ); const { wpCli, wpEvalFile } = require( './utils/wizard' ); const VALID_FIXTURE = path.join( __dirname, 'fixtures', 'svg-valid.svg' ); @@ -44,32 +45,6 @@ let previousSettings = null; */ let createdIds = []; -/** - * Save plugin settings and return their previous values. - * - * Goes through the settings API rather than `wp option patch` so the values - * land in the same place the settings screen saves them. - * - * @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 an SVG fixture, embed it in a published post and sync it. * diff --git a/tests/e2e/utils/delivery.js b/tests/e2e/utils/delivery.js index 7f82c3bf0..db53d43c8 100644 --- a/tests/e2e/utils/delivery.js +++ b/tests/e2e/utils/delivery.js @@ -27,6 +27,42 @@ function expectCloudinaryUrl( rawUrl, expectedCloud ) { ).toBe( true ); } +/** + * Parse a `srcset` attribute into its candidates. + * + * Follows the HTML parsing rule: a URL is a run of non-whitespace + * characters, and trailing commas on it end the candidate. Otherwise the + * descriptor runs up to the next comma. Commas inside a URL, such as in a + * Cloudinary transformation segment (`w_800,h_600,c_scale`), stay part of + * the URL. + * + * @param {string|null} srcset The raw attribute value. + * @return {{ url: string, descriptor: string }[]} Candidates in source order. + */ +function parseSrcset( srcset ) { + const candidates = []; + const input = srcset || ''; + const urlPattern = /[\s,]*(\S+)/y; + const descriptorPattern = /\s*([^,]*?)\s*(?:,|$)/y; + for ( + let urlMatch = urlPattern.exec( input ); + urlMatch; + urlMatch = urlPattern.exec( input ) + ) { + let url = urlMatch[ 1 ]; + let descriptor = ''; + if ( url.endsWith( ',' ) ) { + url = url.replace( /,+$/, '' ); + } else { + descriptorPattern.lastIndex = urlPattern.lastIndex; + descriptor = descriptorPattern.exec( input )[ 1 ]; + urlPattern.lastIndex = descriptorPattern.lastIndex; + } + candidates.push( { url, descriptor } ); + } + return candidates; +} + /** * Split a `srcset` attribute into its candidate URLs. * @@ -34,16 +70,11 @@ function expectCloudinaryUrl( rawUrl, expectedCloud ) { * @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 ); + return parseSrcset( srcset ).map( ( candidate ) => candidate.url ); } module.exports = { expectCloudinaryUrl, + parseSrcset, srcsetUrls, }; diff --git a/tests/e2e/utils/settings.js b/tests/e2e/utils/settings.js new file mode 100644 index 000000000..3c6720d29 --- /dev/null +++ b/tests/e2e/utils/settings.js @@ -0,0 +1,67 @@ +/** + * Read and write plugin settings from e2e specs. + * + * Goes through the plugin's settings API rather than `wp option patch`, so + * the values land in the same place the settings screen saves them. + */ + +const { wpEvalFile } = require( './wizard' ); + +/** + * Encode a value as a PHP expression that decodes it back. + * + * The JSON is base64-encoded, so quotes and backslashes in the values + * cannot break out of the PHP string. + * + * @param {unknown} value Any JSON-serializable value. + * @return {string} PHP expression. + */ +function phpJson( value ) { + const encoded = Buffer.from( JSON.stringify( value ) ).toString( 'base64' ); + return `json_decode( base64_decode( '${ encoded }' ), true )`; +} + +/** + * Save plugin settings and return their previous 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 ( ${ phpJson( values ) } as $slug => $value ) { + $setting = $settings->get_setting( $slug ); + $previous[ $slug ] = $setting->get_value(); + $setting->save_value( $value ); + } + echo wp_json_encode( $previous ); + ` ) + ); +} + +/** + * Read the current value of plugin settings. + * + * @param {string[]} slugs Setting slugs. + * @return {Record} Setting slug to value. + */ +function readSettings( slugs ) { + return JSON.parse( + wpEvalFile( ` + $settings = get_plugin_instance()->settings; + $values = array(); + foreach ( ${ phpJson( slugs ) } as $slug ) { + $values[ $slug ] = $settings->get_setting( $slug )->get_value(); + } + echo wp_json_encode( $values ); + ` ) + ); +} + +module.exports = { + readSettings, + saveSettings, +};