Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
381 changes: 381 additions & 0 deletions tests/e2e/cloudinary-breakpoints-lazy-load.spec.js
Original file line number Diff line number Diff line change
@@ -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<string, string|number>|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<string, string>, 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 =
`<!-- wp:image {"id":${ media.id }} -->\n` +
`<figure class="wp-block-image"><img src="${ media.source_url }" alt="" class="wp-image-${ media.id }"/></figure>\n` +
`<!-- /wp:image -->`;

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 );

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: If there is no srcset at all, srcsetWidths() returns [] and every not.toContain below passes. That is a valid outcome, but could we also check something positive? For example, that attrs.sizes, if present, isn't the plugin's (max-width: 1200px) 100vw, 1200px, or that every width found is one of core's (1200, 1024, 768, 300).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in c90d1d6. The test now checks that WordPress still writes a srcset, and that every width in it is a registered size of the image. It reads the sizes from the attachment metadata instead of a fixed list. The check passes on CI.

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 } );

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this is the same configure() call as the "disable breakpoints" test, but the title only talks about lazy load. A short comment that breakpoints are switched off here too would make that clear.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comment in c90d1d6.


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 );
} );
}
);
Loading
Loading