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..70843277b --- /dev/null +++ b/tests/e2e/cloudinary-breakpoints-lazy-load.spec.js @@ -0,0 +1,389 @@ +/** + * 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 { 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; + +/** + * 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 ); + ` ) + ); +} + +/** + * 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 ); + ` ) + ); +} + +/** + * 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 ) ); +} + +/** + * 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(); + + // 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 }` ).toContain( + `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(); + + // Core may still emit its own srcset from the registered + // image sizes. None of the breakpoint-only widths may appear. + const widths = srcsetWidths( attrs.srcset ); + 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 = (\{.*?\});/.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, + } ) => { + 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/utils/delivery.js b/tests/e2e/utils/delivery.js index 7f82c3bf0..0616278ed 100644 --- a/tests/e2e/utils/delivery.js +++ b/tests/e2e/utils/delivery.js @@ -28,22 +28,45 @@ function expectCloudinaryUrl( rawUrl, expectedCloud ) { } /** - * Split a `srcset` attribute into its candidate URLs. + * Parse a `srcset` attribute into its candidates. + * + * Cloudinary URLs carry commas inside the transformation segment + * (`w_800,h_600,c_scale`), so splitting on `,` breaks them apart. A + * candidate ends at its descriptor, or at a comma followed by whitespace + * when it has none. * * @param {string|null} srcset The raw attribute value. - * @return {string[]} Candidate URLs, without width/density descriptors. + * @return {{ url: string, descriptor: string }[]} Candidates in source order. */ -function srcsetUrls( srcset ) { +function parseSrcset( srcset ) { if ( ! srcset ) { return []; } - return srcset - .split( ',' ) - .map( ( candidate ) => candidate.trim().split( /\s+/ )[ 0 ] ) - .filter( Boolean ); + const candidates = []; + const pattern = /\s*(\S+?)(?:\s+(\d+(?:\.\d+)?[wx]))?\s*(?:,(?=\s)|,?$)/gy; + let match; + while ( pattern.lastIndex < srcset.length ) { + match = pattern.exec( srcset ); + if ( ! match || ! match[ 0 ] ) { + break; + } + candidates.push( { url: match[ 1 ], descriptor: match[ 2 ] || '' } ); + } + return candidates; +} + +/** + * 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 ) { + return parseSrcset( srcset ).map( ( candidate ) => candidate.url ); } module.exports = { expectCloudinaryUrl, + parseSrcset, srcsetUrls, };