diff --git a/packages/block-editor/src/components/provider/use-media-upload-settings.js b/packages/block-editor/src/components/provider/use-media-upload-settings.js index b92ed33b7e0cea..451b821e7f37dc 100644 --- a/packages/block-editor/src/components/provider/use-media-upload-settings.js +++ b/packages/block-editor/src/components/provider/use-media-upload-settings.js @@ -16,6 +16,7 @@ function useMediaUploadSettings( settings = {} ) { mediaUpload: settings.mediaUpload, mediaSideload: settings.mediaSideload, mediaFinalize: settings.mediaFinalize, + mediaDelete: settings.mediaDelete, maxUploadFileSize: settings.maxUploadFileSize, allowedMimeTypes: settings.allowedMimeTypes, allImageSizes: settings.allImageSizes, diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index 3d10c78ce1795a..e8fdb3152ab337 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -27,6 +27,7 @@ import { mediaUpload } from '../../utils'; import mediaUploadOnSuccess from '../../utils/media-upload/on-success'; import { default as mediaSideload } from '../../utils/media-sideload'; import { default as mediaFinalize } from '../../utils/media-finalize'; +import { default as mediaDelete } from '../../utils/media-delete'; import { store as editorStore } from '../../store'; import { unlock } from '../../lock-unlock'; import { useGlobalStylesContext } from '../global-styles-provider'; @@ -354,6 +355,7 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) { : undefined, mediaSideload: hasUploadPermissions ? mediaSideload : undefined, mediaFinalize: hasUploadPermissions ? mediaFinalize : undefined, + mediaDelete: hasUploadPermissions ? mediaDelete : undefined, __experimentalBlockPatterns: blockPatterns, [ selectBlockPatternsKey ]: ( select ) => { const { hasFinishedResolution, getBlockPatternsForPostType } = diff --git a/packages/editor/src/utils/media-delete/index.js b/packages/editor/src/utils/media-delete/index.js new file mode 100644 index 00000000000000..3d88875383e8c9 --- /dev/null +++ b/packages/editor/src/utils/media-delete/index.js @@ -0,0 +1,11 @@ +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +export default async function mediaDelete( id ) { + await apiFetch( { + path: `/wp/v2/media/${ id }?force=true`, + method: 'DELETE', + } ); +} diff --git a/packages/upload-media/src/store/actions.ts b/packages/upload-media/src/store/actions.ts index ae6b69f425062d..76d24b48e1913f 100644 --- a/packages/upload-media/src/store/actions.ts +++ b/packages/upload-media/src/store/actions.ts @@ -7,6 +7,7 @@ import { v4 as uuidv4 } from 'uuid'; * WordPress dependencies */ import type { createRegistry } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; type WPDataRegistry = ReturnType< typeof createRegistry >; @@ -24,14 +25,15 @@ import type { RetryItemAction, State, } from './types'; -import { Type } from './types'; +import { OperationType, Type } from './types'; import type { addItem, processItem, removeItem, revokeBlobUrls, } from './private-actions'; -import { vipsCancelOperations } from './utils'; +import { maybeRecycleVipsWorker, vipsCancelOperations } from './utils'; +import { UploadError } from '../upload-error'; import { validateMimeType } from '../validate-mime-type'; import { validateMimeTypeForUser } from '../validate-mime-type-for-user'; import { validateFileSize } from '../validate-file-size'; @@ -166,13 +168,17 @@ export function cancelItem( id: QueueItemId, error: Error, silent = false ) { if ( ! silent ) { const { onError } = item; onError?.( error ?? new Error( 'Upload cancelled' ) ); - if ( ! onError && error ) { - // TODO: Find better way to surface errors with sideloads etc. + if ( ! onError && error && ! item.parentId ) { + // Log errors for top-level items without an onError handler. + // Child sideload errors are suppressed here because the + // parent will be notified and surface the error to the user. // eslint-disable-next-line no-console -- Deliberately log errors here. console.error( 'Upload cancelled', error ); } } + const { currentOperation, parentId, batchId } = item; + dispatch< CancelAction >( { type: Type.Cancel, id, @@ -181,8 +187,102 @@ export function cancelItem( id: QueueItemId, error: Error, silent = false ) { dispatch.removeItem( id ); dispatch.revokeBlobUrls( id ); + // A concurrency slot just freed up. Kick any items that were + // waiting in the queue, mirroring finishOperation's behavior. + if ( + currentOperation === OperationType.ResizeCrop || + currentOperation === OperationType.Rotate + ) { + for ( const pending of select.getPendingImageProcessing() ) { + dispatch.processItem( pending.id ); + } + } + if ( currentOperation === OperationType.Upload ) { + for ( const pending of select.getPendingUploads() ) { + dispatch.processItem( pending.id ); + } + } + + // Failed vips ops also leak WASM memory, so count them toward the + // recycle budget. Without this, a long burst of failures (e.g. a + // gallery of unsupported AVIFs) could grow memory unbounded. + if ( + currentOperation === OperationType.ResizeCrop || + currentOperation === OperationType.Rotate || + currentOperation === OperationType.TranscodeImage + ) { + maybeRecycleVipsWorker( select.getActiveImageProcessingCount() ); + } + + // If this was a child sideload item, handle the parent. + if ( parentId ) { + const parentItem = select.getItem( parentId ); + if ( parentItem ) { + if ( select.hasPendingItemsByParentId( parentId ) ) { + // Other children remain — just notify the parent so + // it can re-check the Finalize gate. + if ( + parentItem.operations && + parentItem.operations.length > 0 + ) { + dispatch.processItem( parentId ); + } + } else if ( + parentItem.subSizes && + parentItem.subSizes.length > 0 + ) { + // Partial success: at least one child sideload succeeded + // (its sub-size is already accumulated on the parent), + // but the last in-flight child failed. Keep the parent + // attachment and finalize with whichever sub-sizes did + // succeed — matching WordPress core's best-effort + // behavior when individual sub-size generations fail. + if ( + parentItem.operations && + parentItem.operations.length > 0 + ) { + dispatch.processItem( parentId ); + } + } else { + // Total failure: no child succeeded. The parent file + // already uploaded — delete the orphaned attachment + // from the server so it doesn't appear in the media + // library. + const parentAttachmentId = parentItem.attachment?.id; + const { mediaDelete } = select.getSettings(); + if ( parentAttachmentId && mediaDelete ) { + mediaDelete( parentAttachmentId ).catch( () => { + // Best-effort cleanup; surface nothing to the + // user if the delete itself fails. + } ); + } + + // Cancel the parent too so the block resets rather + // than showing a partial upload. Propagate the + // underlying error's code and message — vips + // processing failures already carry an actionable + // hint at their source; network/server failures + // surface their real cause. + dispatch.cancelItem( + parentId, + new UploadError( { + code: + ( error instanceof UploadError && + error.code ) || + 'UPLOAD_ERROR', + message: + error?.message || + __( 'The image could not be uploaded.' ), + file: parentItem.file, + cause: error instanceof Error ? error : undefined, + } ) + ); + } + } + } + // All items of this batch were cancelled or finished. - if ( item.batchId && select.isBatchUploaded( item.batchId ) ) { + if ( batchId && select.isBatchUploaded( batchId ) ) { item.onBatchSuccess?.(); } }; diff --git a/packages/upload-media/src/store/private-actions.ts b/packages/upload-media/src/store/private-actions.ts index a19823874f9b33..6cec4932ee4d7a 100644 --- a/packages/upload-media/src/store/private-actions.ts +++ b/packages/upload-media/src/store/private-actions.ts @@ -8,6 +8,7 @@ import { v4 as uuidv4 } from 'uuid'; */ import { createBlobURL, isBlobURL, revokeBlobURL } from '@wordpress/blob'; import type { createRegistry } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; type WPDataRegistry = ReturnType< typeof createRegistry >; /** @@ -25,6 +26,7 @@ import { vipsConvertImageFormat, vipsHasTransparency, terminateVipsWorker, + maybeRecycleVipsWorker, } from './utils'; import type { AccumulateSubSizeAction, @@ -562,6 +564,17 @@ export function finishOperation( dispatch.processItem( pendingItem.id ); } } + + // Track vips operations across success and failure paths so a + // burst of failures can't bypass the recycle budget; the cancel + // path calls the same helper. + if ( + previousOperation === OperationType.ResizeCrop || + previousOperation === OperationType.Rotate || + previousOperation === OperationType.TranscodeImage + ) { + maybeRecycleVipsWorker( select.getActiveImageProcessingCount() ); + } }; } @@ -725,9 +738,11 @@ export function prepareItem( id: QueueItemId ) { operations, } ); - // If the file is not processed by vips, tell the server to - // generate sub-sizes since they won't be created client-side. - let updates: Partial< QueueItem > = {}; + // Tell the server whether to generate sub-sizes. + // When vips handles processing client-side, set generate_sub_sizes + // to false so the server skips the image-type support check + // (allowing formats like AVIF that the server can't process). + let updates: Partial< QueueItem >; if ( isHeic && heicJpeg ) { // HEIC was converted to JPEG client-side. Upload the JPEG // and let the server handle it normally (threshold scaling, @@ -754,6 +769,13 @@ export function prepareItem( id: QueueItemId ) { convert_format: true, }, }; + } else { + updates = { + additionalData: { + ...item.additionalData, + generate_sub_sizes: false, + }, + }; } dispatch.finishOperation( id, updates ); @@ -896,7 +918,9 @@ export function resizeCropItem( id: QueueItemId, args?: ResizeCropItemArgs ) { id, new UploadError( { code: 'IMAGE_TRANSCODING_ERROR', - message: 'File could not be uploaded', + message: __( + 'The web server cannot generate responsive image sizes for this image. Convert it to JPEG or PNG before uploading.' + ), file: item.file, cause: error instanceof Error ? error : undefined, } ) @@ -958,7 +982,9 @@ export function rotateItem( id: QueueItemId, args?: RotateItemArgs ) { id, new UploadError( { code: 'IMAGE_ROTATION_ERROR', - message: 'Image could not be rotated', + message: __( + 'The web server cannot generate responsive image sizes for this image. Convert it to JPEG or PNG before uploading.' + ), file: item.file, cause: error instanceof Error ? error : undefined, } ) diff --git a/packages/upload-media/src/store/test/actions.ts b/packages/upload-media/src/store/test/actions.ts index 40e9dcb20df436..7a69d20872900b 100644 --- a/packages/upload-media/src/store/test/actions.ts +++ b/packages/upload-media/src/store/test/actions.ts @@ -596,6 +596,188 @@ describe( 'actions', () => { expect( onError ).not.toHaveBeenCalled(); } ); + + describe( 'parent cancellation when child sideload fails', () => { + // Helpers used by every scenario below. Set up a parent that + // has finished its primary upload (so it has an attachment.id), + // then add a sideload child that we'll cancel to trigger the + // parent-cancel branch. + const setUpParentAndChild = ( { + parentSubSizes, + parentOnError, + }: { + parentSubSizes?: { name: string; id: number }[]; + parentOnError?: jest.Mock; + } = {} ) => { + unlock( registry.dispatch( uploadStore ) ).addItem( { + file: jpegFile, + onError: parentOnError, + operations: [ OperationType.Finalize ], + } ); + const parent = unlock( + registry.select( uploadStore ) + ).getAllItems()[ 0 ]; + + // Simulate the parent's primary upload having completed: + // give it an attachment.id and (optionally) accumulated + // sub-sizes from already-successful child sideloads. + unlock( registry.dispatch( uploadStore ) ).finishOperation( + parent.id, + { + attachment: { id: 42 }, + ...( parentSubSizes + ? { subSizes: parentSubSizes } + : {} ), + } + ); + + unlock( registry.dispatch( uploadStore ) ).addSideloadItem( { + file: jpegFile, + parentId: parent.id, + additionalData: { post: 42, image_size: 'medium' }, + } ); + + const child = unlock( registry.select( uploadStore ) ) + .getAllItems() + .find( ( i ) => i.parentId === parent.id ); + + return { parent, child }; + }; + + it( 'deletes parent attachment and cancels parent for vips processing failures with no successful siblings', async () => { + const consoleErrorSpy = jest + .spyOn( console, 'error' ) + .mockImplementation( () => {} ); + const mediaDelete = jest.fn().mockResolvedValue( undefined ); + const parentOnError = jest.fn(); + unlock( registry.dispatch( uploadStore ) ).updateSettings( { + mediaDelete, + } ); + + const { parent, child } = setUpParentAndChild( { + parentOnError, + } ); + + // resizeCropItem and rotateItem already wrap vips + // failures in an UploadError that carries the + // actionable user-facing message at the source. + const vipsError = new ( jest.requireActual( + '../../upload-error' + ).UploadError )( { + code: 'IMAGE_TRANSCODING_ERROR', + message: + 'The web server cannot generate responsive image sizes for this image. Convert it to JPEG or PNG before uploading.', + file: jpegFile, + } ); + + await registry + .dispatch( uploadStore ) + .cancelItem( child!.id, vipsError ); + + expect( mediaDelete ).toHaveBeenCalledWith( 42 ); + expect( parentOnError ).toHaveBeenCalledWith( + expect.objectContaining( { + code: 'IMAGE_TRANSCODING_ERROR', + message: expect.stringContaining( + 'cannot generate responsive image sizes' + ), + } ) + ); + expect( + unlock( registry.select( uploadStore ) ).getItem( + parent.id + ) + ).toBeUndefined(); + + consoleErrorSpy.mockRestore(); + } ); + + it( 'propagates the underlying error message for non-vips sideload failures', async () => { + const mediaDelete = jest.fn().mockResolvedValue( undefined ); + const parentOnError = jest.fn(); + unlock( registry.dispatch( uploadStore ) ).updateSettings( { + mediaDelete, + } ); + + const { child } = setUpParentAndChild( { parentOnError } ); + + const networkError = new ( jest.requireActual( + '../../upload-error' + ).UploadError )( { + code: 'GENERAL', + message: 'Network request failed: 503', + file: jpegFile, + } ); + + await registry + .dispatch( uploadStore ) + .cancelItem( child!.id, networkError ); + + expect( mediaDelete ).toHaveBeenCalledWith( 42 ); + expect( parentOnError ).toHaveBeenCalledWith( + expect.objectContaining( { + code: 'GENERAL', + message: 'Network request failed: 503', + } ) + ); + } ); + + it( 'preserves the parent attachment when at least one sibling sub-size succeeded', async () => { + const mediaDelete = jest.fn().mockResolvedValue( undefined ); + const parentOnError = jest.fn(); + unlock( registry.dispatch( uploadStore ) ).updateSettings( { + mediaDelete, + } ); + + const { parent, child } = setUpParentAndChild( { + parentOnError, + parentSubSizes: [ { name: 'medium', id: 99 } ], + } ); + + const networkError = new ( jest.requireActual( + '../../upload-error' + ).UploadError )( { + code: 'GENERAL', + message: 'sideload of large size failed', + file: jpegFile, + } ); + + await registry + .dispatch( uploadStore ) + .cancelItem( child!.id, networkError ); + + // Partial success: do NOT delete the parent attachment, + // do NOT cancel the parent. The accumulated sub-sizes + // will still be sent to the finalize endpoint. + expect( mediaDelete ).not.toHaveBeenCalled(); + expect( parentOnError ).not.toHaveBeenCalled(); + expect( + unlock( registry.select( uploadStore ) ).getItem( + parent.id + ) + ).toBeDefined(); + } ); + + it( 'falls back to a generic message when the underlying error has no message', async () => { + const mediaDelete = jest.fn().mockResolvedValue( undefined ); + const parentOnError = jest.fn(); + unlock( registry.dispatch( uploadStore ) ).updateSettings( { + mediaDelete, + } ); + + const { child } = setUpParentAndChild( { parentOnError } ); + + await registry + .dispatch( uploadStore ) + .cancelItem( child!.id, new Error( '' ) ); + + expect( parentOnError ).toHaveBeenCalledWith( + expect.objectContaining( { + message: 'The image could not be uploaded.', + } ) + ); + } ); + } ); } ); describe( 'resizeCropItem', () => { diff --git a/packages/upload-media/src/store/types.ts b/packages/upload-media/src/store/types.ts index 308b2a67ff449d..02442b57d7f364 100644 --- a/packages/upload-media/src/store/types.ts +++ b/packages/upload-media/src/store/types.ts @@ -210,6 +210,10 @@ export interface Settings { id: number, subSizes: SubSizeData[] ) => Promise< Partial< Attachment > | void >; + // Function for deleting an attachment from the server. Used to clean up + // the parent attachment when client-side sub-size processing fails after + // the parent file has already been uploaded. + mediaDelete?: ( id: number ) => Promise< void >; } // Matches the Attachment type from the media-utils package. diff --git a/packages/upload-media/src/store/utils/index.ts b/packages/upload-media/src/store/utils/index.ts index 6109c85781211d..782e7c61d71d8b 100644 --- a/packages/upload-media/src/store/utils/index.ts +++ b/packages/upload-media/src/store/utils/index.ts @@ -274,9 +274,53 @@ export async function vipsCancelOperations( id: QueueItemId ) { * * If the vips module has not been loaded yet (i.e., no image processing * has occurred), this is a no-op since there is no worker to terminate. + * + * The worker itself is recreated lazily by `getWorkerAPI()` inside + * `@wordpress/vips/worker` on the next vips call — the module reference + * cached here can keep pointing at the same module since re-importing + * returns the same instance from the JS module cache. */ export function terminateVipsWorker(): void { if ( vipsModule ) { vipsModule.terminateVipsWorker(); } } + +/** + * Tracks the number of completed vips image processing operations across + * both the success path (`finishOperation`) and the failure path + * (`cancelItem`). Used to periodically recycle the WASM worker to reclaim + * memory, since WASM linear memory can only grow and never shrink. + */ +let completedVipsOperations = 0; + +/** + * Maximum number of vips operations before recycling the worker. + * Each operation can consume 50-100MB+ of WASM memory for large images. + */ +const MAX_VIPS_OPS_BEFORE_RECYCLE = 50; + +/** + * Records that a vips operation has completed and recycles the worker if + * the threshold has been reached and no other vips operations are in + * flight. Call this from both success and failure paths so that a burst + * of failures can't bypass the recycle budget. + * + * @param activeImageProcessingCount Number of vips operations currently + * in flight. Recycling is deferred while + * any are running so an in-flight worker + * isn't killed mid-operation. + */ +export function maybeRecycleVipsWorker( + activeImageProcessingCount: number +): void { + completedVipsOperations++; + + if ( + completedVipsOperations >= MAX_VIPS_OPS_BEFORE_RECYCLE && + activeImageProcessingCount === 0 + ) { + terminateVipsWorker(); + completedVipsOperations = 0; + } +} diff --git a/packages/vips/src/index.ts b/packages/vips/src/index.ts index 3a2657bed4d6b5..8a80a13642204c 100644 --- a/packages/vips/src/index.ts +++ b/packages/vips/src/index.ts @@ -63,9 +63,26 @@ async function getVips(): Promise< typeof Vips > { cleanup = fn; } ); }, + // Redirect wasm-vips internal stdout/stderr to prevent console errors + // (e.g. AVIF codec warnings that are not actionable for users). + // Set globalThis.__vipsDebug to a function to capture this output during development. + print: ( text: string ) => { + ( globalThis as any ).__vipsDebug?.( text ); + }, + printErr: ( text: string ) => { + ( globalThis as any ).__vipsDebug?.( text ); + }, } ); - return await vipsPromise; + const vipsInstance = await vipsPromise; + + // Disable the operation cache to prevent out-of-memory crashes + // during repeated image processing. libvips caches results from + // previous operations which accumulates WASM memory over time. + // See https://github.com/WordPress/gutenberg/issues/76706 + vipsInstance.Cache.max( 0 ); + + return vipsInstance; } /** diff --git a/packages/vips/src/test/resize-image.ts b/packages/vips/src/test/resize-image.ts index 1f1155d388d42c..68c83bc21c5a73 100644 --- a/packages/vips/src/test/resize-image.ts +++ b/packages/vips/src/test/resize-image.ts @@ -26,6 +26,9 @@ class MockVipsImage { jest.mock( 'wasm-vips', () => jest.fn( () => ( { Image: MockVipsImage, + Cache: { + max: jest.fn(), + }, } ) ) ); diff --git a/test/e2e/assets/1024x768_e2e_test_image.png b/test/e2e/assets/1024x768_e2e_test_image.png new file mode 100644 index 00000000000000..ec4a0bfd1849d7 Binary files /dev/null and b/test/e2e/assets/1024x768_e2e_test_image.png differ diff --git a/test/performance/config/performance-reporter.ts b/test/performance/config/performance-reporter.ts index ffe7aeb4440f36..fbd154a43972e4 100644 --- a/test/performance/config/performance-reporter.ts +++ b/test/performance/config/performance-reporter.ts @@ -45,6 +45,10 @@ export interface WPRawPerformanceResults { mediaProcessingJpeg: number[]; mediaProcessingAvif: number[]; mediaProcessingJpegToAvif: number[]; + jpegUploadProcessing: number[]; + pngUploadProcessing: number[]; + largeJpegUploadProcessing: number[]; + multipleImageUploadProcessing: number[]; } type PerformanceStats = { @@ -84,6 +88,10 @@ export interface WPPerformanceResults { mediaProcessingJpeg?: PerformanceStats; mediaProcessingAvif?: PerformanceStats; mediaProcessingJpegToAvif?: PerformanceStats; + jpegUploadProcessing?: PerformanceStats; + pngUploadProcessing?: PerformanceStats; + largeJpegUploadProcessing?: PerformanceStats; + multipleImageUploadProcessing?: PerformanceStats; } /** @@ -125,6 +133,12 @@ export function curateResults( mediaProcessingJpeg: stats( results.mediaProcessingJpeg ), mediaProcessingAvif: stats( results.mediaProcessingAvif ), mediaProcessingJpegToAvif: stats( results.mediaProcessingJpegToAvif ), + jpegUploadProcessing: stats( results.jpegUploadProcessing ), + pngUploadProcessing: stats( results.pngUploadProcessing ), + largeJpegUploadProcessing: stats( results.largeJpegUploadProcessing ), + multipleImageUploadProcessing: stats( + results.multipleImageUploadProcessing + ), }; return Object.fromEntries( diff --git a/test/performance/specs/media-upload.spec.js b/test/performance/specs/media-upload.spec.js new file mode 100644 index 00000000000000..c6f0eb1ca70ffb --- /dev/null +++ b/test/performance/specs/media-upload.spec.js @@ -0,0 +1,263 @@ +/** + * External dependencies + */ +const path = require( 'path' ); +const fs = require( 'fs/promises' ); +const os = require( 'os' ); +const { v4: uuid } = require( 'uuid' ); + +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Internal dependencies + */ +const { PerfUtils } = require( '../fixtures' ); + +const results = { + jpegUploadProcessing: [], + pngUploadProcessing: [], + largeJpegUploadProcessing: [], + multipleImageUploadProcessing: [], +}; + +const E2E_ASSETS_PATH = path.join( __dirname, '..', '..', 'e2e', 'assets' ); + +/** + * Creates a temporary copy of a test image with a unique filename. + * + * @param {string} sourceFile Filename in the e2e assets directory. + * @param {string} ext File extension (e.g. '.jpeg', '.png'). + * @return {Promise<{tmpFileName: string, tmpDirectory: string}>} Temp file info. + */ +async function createTempImage( sourceFile, ext ) { + const tmpDirectory = await fs.mkdtemp( + path.join( os.tmpdir(), 'gutenberg-perf-media-' ) + ); + const tmpFileName = path.join( tmpDirectory, uuid() + ext ); + await fs.copyFile( path.join( E2E_ASSETS_PATH, sourceFile ), tmpFileName ); + return { tmpFileName, tmpDirectory }; +} + +/** + * Runs upload iterations for a single image variant within one editor lifecycle. + * + * Inserts an image block, uploads the file, measures elapsed time, then + * removes the block and deletes uploaded media before the next iteration. + * + * @param {Object} options + * @param {Object} options.editor Editor utility. + * @param {Object} options.page Playwright page. + * @param {Object} options.requestUtils Request utility for media cleanup. + * @param {string} options.sourceFile Filename in the e2e assets directory. + * @param {string} options.ext File extension. + * @param {number[]} options.results Array to append elapsed times to. + * @param {number} options.samples Number of measured iterations. + * @param {number} options.throwaway Number of warmup iterations to discard. + */ +async function runUploadIterations( { + editor, + page, + requestUtils, + sourceFile, + ext, + results: bucket, + samples, + throwaway, +} ) { + const iterations = samples + throwaway; + + for ( let i = 1; i <= iterations; i++ ) { + const { tmpFileName, tmpDirectory } = await createTempImage( + sourceFile, + ext + ); + + await editor.insertBlock( { name: 'core/image' } ); + const imageBlock = editor.canvas.locator( + 'role=document[name="Block: Image"i]' + ); + await expect( imageBlock ).toBeVisible(); + + const startTime = performance.now(); + await imageBlock + .locator( 'data-testid=form-file-upload-input' ) + .setInputFiles( tmpFileName ); + + await expect( + imageBlock.getByRole( 'img', { + name: 'This image has an empty alt attribute', + } ) + ).toHaveAttribute( 'src', /^https?:\/\//, { + timeout: 120_000, + } ); + const elapsed = performance.now() - startTime; + + if ( i > throwaway ) { + bucket.push( elapsed ); + } + + // Reset state for next iteration. Use resetBlocks rather than + // selecting and pressing Backspace because after upload, DOM focus + // is inside the block and Backspace is consumed by an inner element. + await page.evaluate( () => { + window.wp.data.dispatch( 'core/block-editor' ).resetBlocks( [] ); + } ); + await requestUtils.deleteAllMedia(); + await fs.rm( tmpDirectory, { + recursive: true, + force: true, + } ); + } +} + +test.describe( 'Media Upload Performance', () => { + test.use( { + perfUtils: async ( { page }, use ) => { + await use( new PerfUtils( { page } ) ); + }, + } ); + + test.afterAll( async ( {}, testInfo ) => { + await testInfo.attach( 'results', { + body: JSON.stringify( results, null, 2 ), + contentType: 'application/json', + } ); + } ); + + test.describe( 'Single Image Upload', () => { + const samples = 10; + const throwaway = 1; + + test( 'JPEG uploads', async ( { + admin, + editor, + page, + requestUtils, + } ) => { + await admin.createNewPost(); + await runUploadIterations( { + editor, + page, + requestUtils, + sourceFile: '1024x768_e2e_test_image_size.jpeg', + ext: '.jpeg', + results: results.jpegUploadProcessing, + samples, + throwaway, + } ); + } ); + + test( 'PNG uploads', async ( { + admin, + editor, + page, + requestUtils, + } ) => { + await admin.createNewPost(); + await runUploadIterations( { + editor, + page, + requestUtils, + sourceFile: '1024x768_e2e_test_image.png', + ext: '.png', + results: results.pngUploadProcessing, + samples, + throwaway, + } ); + } ); + + test( 'Large JPEG uploads', async ( { + admin, + editor, + page, + requestUtils, + } ) => { + await admin.createNewPost(); + await runUploadIterations( { + editor, + page, + requestUtils, + sourceFile: '3200x2400_e2e_test_image_responsive_lightbox.jpeg', + ext: '.jpeg', + results: results.largeJpegUploadProcessing, + samples, + throwaway, + } ); + } ); + } ); + + test.describe( 'Multiple Image Upload', () => { + const samples = 5; + const throwaway = 1; + const iterations = samples + throwaway; + + test( 'Batch upload 5 images', async ( { + admin, + editor, + page, + requestUtils, + } ) => { + await admin.createNewPost(); + + for ( let i = 1; i <= iterations; i++ ) { + await editor.insertBlock( { name: 'core/gallery' } ); + + const galleryBlock = editor.canvas.locator( + 'role=document[name="Block: Gallery"i]' + ); + await expect( galleryBlock ).toBeVisible(); + + // Create 5 temp copies of the test image. + const tmpDirectory = await fs.mkdtemp( + path.join( os.tmpdir(), 'gutenberg-perf-media-batch-' ) + ); + const tmpFiles = []; + for ( let j = 0; j < 5; j++ ) { + const tmpFileName = path.join( + tmpDirectory, + uuid() + '.jpeg' + ); + await fs.copyFile( + path.join( + E2E_ASSETS_PATH, + '1024x768_e2e_test_image_size.jpeg' + ), + tmpFileName + ); + tmpFiles.push( tmpFileName ); + } + + const startTime = performance.now(); + await galleryBlock + .locator( 'data-testid=form-file-upload-input' ) + .setInputFiles( tmpFiles ); + + // Wait for all 5 images to finish uploading. + await expect( + editor.canvas.locator( '.wp-block-image img[src^="http"]' ) + ).toHaveCount( 5, { timeout: 120_000 } ); + + const elapsed = performance.now() - startTime; + + if ( i > throwaway ) { + results.multipleImageUploadProcessing.push( elapsed ); + } + + // Reset state for next iteration. + await page.evaluate( () => { + window.wp.data + .dispatch( 'core/block-editor' ) + .resetBlocks( [] ); + } ); + await requestUtils.deleteAllMedia(); + await fs.rm( tmpDirectory, { + recursive: true, + force: true, + } ); + } + } ); + } ); +} );