Repository navigation
test(e2e): Add breakpoints and lazy load spec #1342
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
65d6116
107b3d3
63da419
c90d1d6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 ); | ||
| 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 } ); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: this is the same
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ); | ||
| } ); | ||
| } | ||
| ); | ||
There was a problem hiding this comment.
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
srcsetat all,srcsetWidths()returns[]and everynot.toContainbelow passes. That is a valid outcome, but could we also check something positive? For example, thatattrs.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).There was a problem hiding this comment.
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.