@wordpress/upload-media
Version:
Core media upload logic.
1,736 lines (1,466 loc) • 63.5 kB
text/typescript
/**
* WordPress dependencies
*/
import { createRegistry } from '@wordpress/data';
type WPDataRegistry = ReturnType< typeof createRegistry >;
/**
* Internal dependencies
*/
import { store as uploadStore } from '..';
import { ItemStatus, OperationType } from '../types';
import { unlock } from '../../lock-unlock';
jest.mock( '@wordpress/blob', () => ( {
__esModule: true,
createBlobURL: jest.fn( () => 'blob:foo' ),
isBlobURL: jest.fn( ( str: string ) => str.startsWith( 'blob:' ) ),
revokeBlobURL: jest.fn(),
} ) );
jest.mock( '../utils', () => ( {
vipsCancelOperations: jest.fn( () => Promise.resolve( true ) ),
vipsResizeImage: jest.fn( () =>
Promise.resolve(
new File( [ 'resized' ], 'example-100x100.jpg', {
type: 'image/jpeg',
} )
)
),
vipsRotateImage: jest.fn(),
vipsHasTransparency: jest.fn( () => Promise.resolve( false ) ),
vipsConvertImageFormat: jest.fn(),
terminateVipsWorker: jest.fn(),
} ) );
/*
* actions.ts transitively imports private-actions, which also pulls in
* convertGifToVideo / isUnsupportedConversionError, so the mock must cover the
* whole module surface. isUnsupportedConversionError is kept real.
*/
jest.mock( '../utils/video-conversion', () => {
const actual = jest.requireActual( '../utils/video-conversion' );
return {
convertGifToVideo: jest.fn(),
cancelGifToVideoOperations: jest.fn( () => Promise.resolve( true ) ),
terminateVideoConversionWorker: jest.fn(),
isUnsupportedConversionError: actual.isUnsupportedConversionError,
};
} );
// Import the mocked modules to access the mock functions.
import { vipsCancelOperations } from '../utils';
import { cancelGifToVideoOperations } from '../utils/video-conversion';
function createRegistryWithStores() {
// Create a registry and register used stores.
const registry = createRegistry();
// @ts-ignore
[ uploadStore ].forEach( registry.register );
return registry;
}
const jpegFile = new File( [ 'foo' ], 'example.jpg', {
lastModified: 1234567891,
type: 'image/jpeg',
} );
const mp4File = new File( [ 'foo' ], 'amazing-video.mp4', {
lastModified: 1234567891,
type: 'video/mp4',
} );
describe( 'actions', () => {
let registry: WPDataRegistry;
beforeEach( () => {
registry = createRegistryWithStores();
unlock( registry.dispatch( uploadStore ) ).pauseQueue();
} );
describe( 'addItem', () => {
it( 'adds an item to the queue', () => {
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
expect( registry.select( uploadStore ).getItems() ).toHaveLength(
1
);
expect(
registry.select( uploadStore ).getItems()[ 0 ]
).toStrictEqual(
expect.objectContaining( {
id: expect.any( String ),
file: jpegFile,
sourceFile: jpegFile,
status: ItemStatus.Processing,
attachment: {
url: expect.stringMatching( /^blob:/ ),
},
} )
);
} );
} );
describe( 'addItems', () => {
it( 'adds multiple items to the queue', () => {
const onError = jest.fn();
registry.dispatch( uploadStore ).addItems( {
files: [ jpegFile, mp4File ],
onError,
} );
expect( onError ).not.toHaveBeenCalled();
expect( registry.select( uploadStore ).getItems() ).toHaveLength(
2
);
expect(
registry.select( uploadStore ).getItems()[ 0 ]
).toStrictEqual(
expect.objectContaining( {
id: expect.any( String ),
file: jpegFile,
sourceFile: jpegFile,
status: ItemStatus.Processing,
attachment: {
url: expect.stringMatching( /^blob:/ ),
},
} )
);
expect(
registry.select( uploadStore ).getItems()[ 1 ]
).toStrictEqual(
expect.objectContaining( {
id: expect.any( String ),
file: mp4File,
sourceFile: mp4File,
status: ItemStatus.Processing,
attachment: {
url: expect.stringMatching( /^blob:/ ),
},
} )
);
} );
} );
describe( 'addSideloadItem', () => {
it( 'adds a sideload item with parent ID', () => {
// Add parent item first.
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const parentItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
unlock( registry.dispatch( uploadStore ) ).addSideloadItem( {
file: jpegFile,
parentId: parentItem.id,
additionalData: { post: 123, image_size: 'thumbnail' },
} );
const items = unlock(
registry.select( uploadStore )
).getAllItems();
expect( items ).toHaveLength( 2 );
expect( items[ 1 ].parentId ).toBe( parentItem.id );
expect( items[ 1 ].additionalData ).toEqual(
expect.objectContaining( {
post: 123,
image_size: 'thumbnail',
} )
);
} );
it( 'adds a sideload item with custom operations', () => {
unlock( registry.dispatch( uploadStore ) ).addSideloadItem( {
file: jpegFile,
additionalData: { post: 456, image_size: 'medium' },
} );
const items = unlock(
registry.select( uploadStore )
).getAllItems();
expect( items ).toHaveLength( 1 );
expect( items[ 0 ].status ).toBe( ItemStatus.Processing );
} );
} );
describe( 'prepareItem', () => {
it( 'should add Upload and ThumbnailGeneration for vips-supported image types', async () => {
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
// Manually call prepareItem to determine operations.
await unlock( registry.dispatch( uploadStore ) ).prepareItem(
item.id
);
const updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
// Should include Upload and ThumbnailGeneration (no ResizeCrop without bigImageSizeThreshold).
expect( updatedItem.operations ).toEqual(
expect.arrayContaining( [
OperationType.Upload,
OperationType.ThumbnailGeneration,
] )
);
// Server should not generate sub-sizes for vips-supported images.
expect( updatedItem.additionalData.generate_sub_sizes ).toBe(
false
);
} );
it( 'should add only Upload for non-image types', async () => {
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: mp4File,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
await unlock( registry.dispatch( uploadStore ) ).prepareItem(
item.id
);
const updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( updatedItem.operations ).toEqual(
expect.arrayContaining( [ OperationType.Upload ] )
);
expect( updatedItem.operations ).not.toEqual(
expect.arrayContaining( [ OperationType.ThumbnailGeneration ] )
);
// Server should generate sub-sizes for non-vips files.
expect( updatedItem.additionalData.generate_sub_sizes ).toBe(
true
);
// Server should convert formats (e.g. HEIC to JPEG).
expect( updatedItem.additionalData.convert_format ).toBe( true );
} );
it( 'should add only Upload for unsupported image types like SVG', async () => {
const svgFile = new File( [ '<svg></svg>' ], 'test.svg', {
lastModified: 1234567891,
type: 'image/svg+xml',
} );
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: svgFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
await unlock( registry.dispatch( uploadStore ) ).prepareItem(
item.id
);
const updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( updatedItem.operations ).toEqual(
expect.arrayContaining( [ OperationType.Upload ] )
);
expect( updatedItem.operations ).not.toEqual(
expect.arrayContaining( [ OperationType.ThumbnailGeneration ] )
);
expect( updatedItem.additionalData.generate_sub_sizes ).toBe(
true
);
expect( updatedItem.additionalData.convert_format ).toBe( true );
} );
it( 'should add only Upload for unsupported image types like BMP', async () => {
const bmpFile = new File( [ 'bmp' ], 'test.bmp', {
lastModified: 1234567891,
type: 'image/bmp',
} );
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: bmpFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
await unlock( registry.dispatch( uploadStore ) ).prepareItem(
item.id
);
const updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( updatedItem.operations ).toEqual(
expect.arrayContaining( [ OperationType.Upload ] )
);
expect( updatedItem.operations ).not.toEqual(
expect.arrayContaining( [ OperationType.ThumbnailGeneration ] )
);
expect( updatedItem.additionalData.generate_sub_sizes ).toBe(
true
);
expect( updatedItem.additionalData.convert_format ).toBe( true );
} );
it( 'should add only Upload for PDF files', async () => {
const pdfFile = new File( [ 'pdf' ], 'document.pdf', {
lastModified: 1234567891,
type: 'application/pdf',
} );
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: pdfFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
await unlock( registry.dispatch( uploadStore ) ).prepareItem(
item.id
);
const updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( updatedItem.operations ).toEqual(
expect.arrayContaining( [ OperationType.Upload ] )
);
expect( updatedItem.operations ).not.toEqual(
expect.arrayContaining( [ OperationType.ThumbnailGeneration ] )
);
expect( updatedItem.additionalData.generate_sub_sizes ).toBe(
true
);
expect( updatedItem.additionalData.convert_format ).toBe( true );
} );
it( 'routes very large interlaced images to the server', async () => {
// A progressive JPEG (SOF2) header reporting 20000x11857, which
// exceeds the client-side memory budget for interlaced images.
const dimensions = new Uint8Array( 4 );
const dimView = new DataView( dimensions.buffer );
dimView.setUint16( 0, 11857 ); // height
dimView.setUint16( 2, 20000 ); // width
const bytes = new Uint8Array( [
0xff,
0xd8, // SOI
0xff,
0xc2, // SOF2 (progressive)
0x00,
0x11, // segment length
0x08, // precision
...dimensions, // height (2) + width (2)
0x03, // components
0x00,
0x00,
0x00,
] );
const largeJpeg = new File( [ bytes ], 'huge.jpg', {
type: 'image/jpeg',
} );
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: largeJpeg,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
await unlock( registry.dispatch( uploadStore ) ).prepareItem(
item.id
);
const updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
// Should fall back to server-side processing: only Upload, no
// client-side thumbnail generation.
expect( updatedItem.operations ).toEqual(
expect.arrayContaining( [ OperationType.Upload ] )
);
expect( updatedItem.operations ).not.toEqual(
expect.arrayContaining( [ OperationType.ThumbnailGeneration ] )
);
expect( updatedItem.additionalData.generate_sub_sizes ).toBe(
true
);
expect( updatedItem.additionalData.convert_format ).toBe( true );
} );
} );
describe( 'concurrent sideloads', () => {
it( 'does not pause sideload items targeting the same post', async () => {
// Configure mediaSideload so sideload uploads can proceed.
unlock( registry.dispatch( uploadStore ) ).updateSettings( {
mediaSideload: jest.fn(),
} );
// Use a fake parentId so we only test sideload scheduling.
const fakeParentId = 'fake-parent-id';
// Add two sideload items targeting the same post.
unlock( registry.dispatch( uploadStore ) ).addSideloadItem( {
file: jpegFile,
parentId: fakeParentId,
additionalData: { post: 100, image_size: 'thumbnail' },
operations: [ OperationType.Upload ],
} );
unlock( registry.dispatch( uploadStore ) ).addSideloadItem( {
file: jpegFile,
parentId: fakeParentId,
additionalData: { post: 100, image_size: 'medium' },
operations: [ OperationType.Upload ],
} );
// Resume the queue to trigger processing.
await unlock( registry.dispatch( uploadStore ) ).resumeQueue();
const items = unlock(
registry.select( uploadStore )
).getAllItems();
const sideloadItems = items.filter(
( item ) => item.parentId === fakeParentId
);
// Neither sideload item should be paused.
for ( const item of sideloadItems ) {
expect( item.status ).not.toBe( ItemStatus.Paused );
}
} );
it( 'allows multiple sideloads to the same attachment to upload concurrently', async () => {
const mediaSideload = jest.fn();
unlock( registry.dispatch( uploadStore ) ).updateSettings( {
mediaSideload,
maxConcurrentUploads: 5,
} );
// Add a parent item first.
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const parentItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
// Add 3 sideload items to same post.
for ( const size of [ 'thumbnail', 'medium', 'large' ] ) {
unlock( registry.dispatch( uploadStore ) ).addSideloadItem( {
file: jpegFile,
parentId: parentItem.id,
additionalData: { post: 200, image_size: size },
operations: [ OperationType.Upload ],
} );
}
// Resume the queue.
await unlock( registry.dispatch( uploadStore ) ).resumeQueue();
// All 3 sideloads should have started (not serialized).
expect( mediaSideload ).toHaveBeenCalledTimes( 3 );
} );
it( 'respects maxConcurrentUploads for sideloads', async () => {
const mediaSideload = jest.fn();
unlock( registry.dispatch( uploadStore ) ).updateSettings( {
mediaSideload,
maxConcurrentUploads: 2,
} );
// Use a fake parentId so the parent item does not consume
// an upload slot. Only sideload items compete for slots.
const fakeParentId = 'fake-parent-id';
// Add 4 sideload items.
for ( const size of [
'thumbnail',
'medium',
'large',
'medium_large',
] ) {
unlock( registry.dispatch( uploadStore ) ).addSideloadItem( {
file: jpegFile,
parentId: fakeParentId,
additionalData: { post: 300, image_size: size },
operations: [ OperationType.Upload ],
} );
}
// Resume the queue.
await unlock( registry.dispatch( uploadStore ) ).resumeQueue();
// Only 2 should have started due to concurrency limit.
expect( mediaSideload ).toHaveBeenCalledTimes( 2 );
} );
it( 'starts pending sideloads after one finishes', async () => {
let onSuccessCallback:
| ( ( subSize: Record< string, unknown > ) => void )
| undefined;
const mediaSideload = jest.fn( ( { onSuccess } ) => {
// Capture the first callback to simulate completion later.
if ( ! onSuccessCallback ) {
onSuccessCallback = onSuccess;
}
} );
unlock( registry.dispatch( uploadStore ) ).updateSettings( {
mediaSideload,
maxConcurrentUploads: 1,
} );
// Use a fake parentId so the parent item does not consume
// an upload slot.
const fakeParentId = 'fake-parent-id';
// Add 2 sideload items.
unlock( registry.dispatch( uploadStore ) ).addSideloadItem( {
file: jpegFile,
parentId: fakeParentId,
additionalData: { post: 400, image_size: 'thumbnail' },
operations: [ OperationType.Upload ],
} );
unlock( registry.dispatch( uploadStore ) ).addSideloadItem( {
file: jpegFile,
parentId: fakeParentId,
additionalData: { post: 400, image_size: 'medium' },
operations: [ OperationType.Upload ],
} );
// Resume the queue.
await unlock( registry.dispatch( uploadStore ) ).resumeQueue();
// Only 1 should have started due to maxConcurrentUploads=1.
expect( mediaSideload ).toHaveBeenCalledTimes( 1 );
// Complete the first upload to trigger the pending one.
onSuccessCallback?.( {
image_size: 'thumbnail',
width: 150,
height: 150,
file: 'image-150x150.jpg',
mime_type: 'image/jpeg',
filesize: 5000,
} );
// Allow async dispatch to propagate.
await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
// The second sideload should now have started.
expect( mediaSideload ).toHaveBeenCalledTimes( 2 );
} );
it( 'resumeItemByPostId is not on private dispatch', () => {
const privateDispatch = unlock( registry.dispatch( uploadStore ) );
expect(
( privateDispatch as Record< string, unknown > )
.resumeItemByPostId
).toBeUndefined();
} );
} );
describe( 'cancelItem', () => {
beforeEach( () => {
( vipsCancelOperations as jest.Mock ).mockClear();
( cancelGifToVideoOperations as jest.Mock ).mockClear();
} );
it( 'calls vipsCancelOperations when cancelling', async () => {
// Suppress console.error that fires when there's no onError callback.
const consoleErrorSpy = jest
.spyOn( console, 'error' )
.mockImplementation( () => {} );
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
await registry
.dispatch( uploadStore )
.cancelItem( item.id, new Error( 'User cancelled' ) );
expect( vipsCancelOperations ).toHaveBeenCalledWith( item.id );
expect( consoleErrorSpy ).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
} );
it( 'cancels any in-flight GIF-to-video conversion when cancelling', async () => {
// Suppress console.error that fires when there's no onError callback.
const consoleErrorSpy = jest
.spyOn( console, 'error' )
.mockImplementation( () => {} );
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
await registry
.dispatch( uploadStore )
.cancelItem( item.id, new Error( 'User cancelled' ) );
expect( cancelGifToVideoOperations ).toHaveBeenCalledWith(
item.id
);
consoleErrorSpy.mockRestore();
} );
it( 'removes item from queue after cancelling', async () => {
// Suppress console.error that fires when there's no onError callback.
const consoleErrorSpy = jest
.spyOn( console, 'error' )
.mockImplementation( () => {} );
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
await registry
.dispatch( uploadStore )
.cancelItem( item.id, new Error( 'User cancelled' ) );
expect(
unlock( registry.select( uploadStore ) ).getAllItems()
).toHaveLength( 0 );
consoleErrorSpy.mockRestore();
} );
it( 'calls onError callback when not silent', async () => {
const onError = jest.fn();
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
onError,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
await registry
.dispatch( uploadStore )
.cancelItem( item.id, new Error( 'Test error' ) );
expect( onError ).toHaveBeenCalledWith(
expect.objectContaining( { message: 'Test error' } )
);
} );
it( 'does not call onError when silent', async () => {
const onError = jest.fn();
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
onError,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
await registry
.dispatch( uploadStore )
.cancelItem( item.id, new Error( 'Test error' ), true );
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,
imageSize = 'medium',
}: {
parentSubSizes?: { name: string; id: number }[];
parentOnError?: jest.Mock;
imageSize?: string;
} = {} ) => {
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: imageSize },
} );
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.',
} )
);
} );
it( 'keeps the parent GIF when its only child is a failed animated_video companion', async () => {
const mediaDelete = jest.fn().mockResolvedValue( undefined );
const parentOnError = jest.fn();
unlock( registry.dispatch( uploadStore ) ).updateSettings( {
mediaDelete,
} );
// The converted video is the sole child of the GIF and has no
// accumulated sub-sizes — the pre-fix "total failure" case.
const { parent, child } = setUpParentAndChild( {
parentOnError,
imageSize: 'animated_video',
} );
await registry
.dispatch( uploadStore )
.cancelItem(
child!.id,
new Error( 'conversion failed' ),
true
);
// The optional companion failing must not delete or cancel the
// already-uploaded GIF attachment.
expect( mediaDelete ).not.toHaveBeenCalled();
expect( parentOnError ).not.toHaveBeenCalled();
expect(
unlock( registry.select( uploadStore ) ).getItem(
parent.id
)
).toBeDefined();
} );
} );
} );
describe( 'retryItem', () => {
beforeEach( () => {
unlock( registry.dispatch( uploadStore ) ).updateSettings( {
retry: {
maxRetryAttempts: 3,
initialRetryDelayMs: 1000,
maxRetryDelayMs: 30000,
backoffMultiplier: 2,
retryJitter: 0.1,
},
} );
} );
it( 'does nothing for non-existent item', async () => {
await registry
.dispatch( uploadStore )
.retryItem( 'non-existent-id' );
expect(
unlock( registry.select( uploadStore ) ).getAllItems()
).toHaveLength( 0 );
} );
it( 'does nothing for item without error', async () => {
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
// Item has no error, so retryItem should do nothing.
await registry.dispatch( uploadStore ).retryItem( item.id );
const updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( updatedItem.status ).toBe( ItemStatus.Processing );
expect( updatedItem.retryCount ).toBeUndefined();
} );
it( 'sets status to Processing and clears error', async () => {
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
// Schedule retry to put item in PendingRetry status with error.
await registry
.dispatch( uploadStore )
.scheduleRetry( item.id, new Error( 'Network error' ) );
// Retry the item.
await registry.dispatch( uploadStore ).retryItem( item.id );
const updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( updatedItem.status ).toBe( ItemStatus.Processing );
expect( updatedItem.error ).toBeUndefined();
} );
it( 'increments retryCount', async () => {
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
// Schedule retry to put item in error state.
await registry
.dispatch( uploadStore )
.scheduleRetry( item.id, new Error( 'Network error' ) );
// Retry the item.
await registry.dispatch( uploadStore ).retryItem( item.id );
const updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( updatedItem.retryCount ).toBe( 1 );
} );
} );
describe( 'cancelItem retry integration', () => {
beforeEach( () => {
jest.useFakeTimers();
( vipsCancelOperations as jest.Mock ).mockClear();
unlock( registry.dispatch( uploadStore ) ).updateSettings( {
retry: {
maxRetryAttempts: 3,
initialRetryDelayMs: 1000,
maxRetryDelayMs: 30000,
backoffMultiplier: 2,
retryJitter: 0.1,
},
} );
} );
afterEach( () => {
jest.useRealTimers();
} );
it( 'schedules retry for retryable errors', async () => {
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
// Cancel with a retryable error (network error pattern).
await registry
.dispatch( uploadStore )
.cancelItem( item.id, new Error( 'Network error' ) );
// Item should still be in the queue with PendingRetry status.
const updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( updatedItem ).toBeDefined();
expect( updatedItem.status ).toBe( ItemStatus.PendingRetry );
} );
it( 'does NOT schedule retry when silent=true', async () => {
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
// Cancel silently with a retryable error.
await registry
.dispatch( uploadStore )
.cancelItem( item.id, new Error( 'Network error' ), true );
// Item should be removed (not retried).
expect(
unlock( registry.select( uploadStore ) ).getAllItems()
).toHaveLength( 0 );
} );
it( 'does NOT schedule retry for non-retryable errors', async () => {
const consoleErrorSpy = jest
.spyOn( console, 'error' )
.mockImplementation( () => {} );
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
// Cancel with a non-retryable error.
await registry
.dispatch( uploadStore )
.cancelItem( item.id, new Error( 'File validation failed' ) );
// Item should be removed (not retried).
expect(
unlock( registry.select( uploadStore ) ).getAllItems()
).toHaveLength( 0 );
consoleErrorSpy.mockRestore();
} );
it( 'does NOT schedule retry when retry settings are undefined', async () => {
const consoleErrorSpy = jest
.spyOn( console, 'error' )
.mockImplementation( () => {} );
// Disable retry settings.
unlock( registry.dispatch( uploadStore ) ).updateSettings( {
retry: undefined,
} );
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
// Cancel with a retryable error.
await registry
.dispatch( uploadStore )
.cancelItem( item.id, new Error( 'Network error' ) );
// Item should be removed (retry not available without settings).
expect(
unlock( registry.select( uploadStore ) ).getAllItems()
).toHaveLength( 0 );
consoleErrorSpy.mockRestore();
} );
it( 'clears pending retry timer on manual cancel', async () => {
const onError = jest.fn();
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
onError,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
// Schedule a retry to put item in PendingRetry with a pending timer.
await registry
.dispatch( uploadStore )
.cancelItem( item.id, new Error( 'Network error' ) );
const pendingItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( pendingItem.status ).toBe( ItemStatus.PendingRetry );
// Now manually cancel the item while it's pending retry.
await registry
.dispatch( uploadStore )
.cancelItem( item.id, new Error( 'Manual cancel' ), true );
// Item should be removed from queue.
expect(
unlock( registry.select( uploadStore ) ).getAllItems()
).toHaveLength( 0 );
// Advance timers — the old retry timer should NOT fire.
await jest.runAllTimersAsync();
// Queue should still be empty (timer was cleared).
expect(
unlock( registry.select( uploadStore ) ).getAllItems()
).toHaveLength( 0 );
} );
} );
describe( 'scheduleRetry', () => {
beforeEach( () => {
jest.useFakeTimers();
unlock( registry.dispatch( uploadStore ) ).updateSettings( {
retry: {
maxRetryAttempts: 3,
initialRetryDelayMs: 1000,
maxRetryDelayMs: 30000,
backoffMultiplier: 2,
retryJitter: 0.1,
},
} );
} );
afterEach( () => {
jest.useRealTimers();
} );
it( 'sets item status to PendingRetry', async () => {
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
await registry
.dispatch( uploadStore )
.scheduleRetry( item.id, new Error( 'Network error' ) );
const updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( updatedItem.status ).toBe( ItemStatus.PendingRetry );
} );
it( 'stores the error on the item', async () => {
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
const error = new Error( 'Network error' );
await registry
.dispatch( uploadStore )
.scheduleRetry( item.id, error );
const updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( updatedItem.error ).toBe( error );
} );
it( 'sets nextRetryTimestamp', async () => {
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
const beforeTime = Date.now();
await registry
.dispatch( uploadStore )
.scheduleRetry( item.id, new Error( 'Network error' ) );
const updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( updatedItem.nextRetryTimestamp ).toBeGreaterThan(
beforeTime
);
} );
it( 'does nothing if item does not exist', async () => {
await registry
.dispatch( uploadStore )
.scheduleRetry(
'non-existent-id',
new Error( 'Network error' )
);
expect(
unlock( registry.select( uploadStore ) ).getAllItems()
).toHaveLength( 0 );
} );
it( 'executes retry after timer fires', async () => {
// executeRetry is now a no-op when the queue is paused (the outer
// beforeEach pauses); resume so the timer's executeRetry mutates
// state.
await unlock( registry.dispatch( uploadStore ) ).resumeQueue();
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
await registry
.dispatch( uploadStore )
.scheduleRetry( item.id, new Error( 'Network error' ) );
// Item should be in PendingRetry status.
let updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( updatedItem.status ).toBe( ItemStatus.PendingRetry );
// Fire all timers to trigger executeRetry.
await jest.runAllTimersAsync();
// Item should now be back in Processing status with incremented retryCount.
updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( updatedItem.status ).toBe( ItemStatus.Processing );
expect( updatedItem.retryCount ).toBe( 1 );
} );
} );
describe( 'executeRetry', () => {
beforeEach( async () => {
jest.useFakeTimers();
unlock( registry.dispatch( uploadStore ) ).updateSettings( {
retry: {
maxRetryAttempts: 3,
initialRetryDelayMs: 1000,
maxRetryDelayMs: 30000,
backoffMultiplier: 2,
retryJitter: 0.1,
},
} );
// executeRetry is now a no-op when the queue is paused (the outer
// beforeEach pauses); resume so executeRetry mutates state.
await unlock( registry.dispatch( uploadStore ) ).resumeQueue();
} );
afterEach( () => {
jest.useRealTimers();
} );
it( 'resets item to Processing status', async () => {
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
// First schedule a retry to put item in PendingRetry status.
await registry
.dispatch( uploadStore )
.scheduleRetry( item.id, new Error( 'Network error' ) );
// Execute the retry.
await registry.dispatch( uploadStore ).executeRetry( item.id );
const updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( updatedItem.status ).toBe( ItemStatus.Processing );
} );
it( 'clears the error on the item', async () => {
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
// Schedule retry to set error.
await registry
.dispatch( uploadStore )
.scheduleRetry( item.id, new Error( 'Network error' ) );
// Verify error is set.
let updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( updatedItem.error ).toBeDefined();
// Execute retry.
await registry.dispatch( uploadStore ).executeRetry( item.id );
updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( updatedItem.error ).toBeUndefined();
} );
it( 'increments retryCount', async () => {
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
// Schedule retry (sets retryCount to current value).
await registry
.dispatch( uploadStore )
.scheduleRetry( item.id, new Error( 'Network error' ) );
// Execute retry (increments retryCount).
await registry.dispatch( uploadStore ).executeRetry( item.id );
const updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( updatedItem.retryCount ).toBe( 1 );
} );
it( 'creates a fresh AbortController after retry', async () => {
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
const originalController = item.abortController;
// Schedule retry to put item in PendingRetry status.
await registry
.dispatch( uploadStore )
.scheduleRetry( item.id, new Error( 'Network error' ) );
// Execute the retry.
await registry.dispatch( uploadStore ).executeRetry( item.id );
const updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
// Should have a new AbortController instance.
expect( updatedItem.abortController ).toBeInstanceOf(
AbortController
);
expect( updatedItem.abortController ).not.toBe(
originalController
);
// The new controller should not be aborted.
expect( updatedItem.abortController?.signal.aborted ).toBe( false );
} );
it( 'does nothing if item does not exist', async () => {
await registry
.dispatch( uploadStore )
.executeRetry( 'non-existent-id' );
// Should not throw, just return silently.
expect(
unlock( registry.select( uploadStore ) ).getAllItems()
).toHaveLength( 0 );
} );
it( 'removeItem clears any pending retry timer', async () => {
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
// Schedule a retry to put a timer in the retryTimers map.
await registry
.dispatch( uploadStore )
.scheduleRetry( item.id, new Error( 'Network error' ) );
expect(
unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]
.status
).toBe( ItemStatus.PendingRetry );
// Remove the item directly (not via cancelItem).
await unlock( registry.dispatch( uploadStore ) ).removeItem(
item.id
);
// Item should be gone.
expect(
unlock( registry.select( uploadStore ) ).getAllItems()
).toHaveLength( 0 );
// Advance timers — the old retry timer must NOT re-add or
// touch the item.
await jest.runAllTimersAsync();
expect(
unlock( registry.select( uploadStore ) ).getAllItems()
).toHaveLength( 0 );
} );
it( 'falls through to cancellation after exhausting max retries', async () => {
const onError = jest.fn();
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
onError,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
// First failure with retryable error → schedules retry (count=0).
await registry
.dispatch( uploadStore )
.cancelItem( item.id, new Error( 'Network error' ) );
expect(
unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ]
.status
).toBe( ItemStatus.PendingRetry );
// Run through the 3 scheduled retries — each fires the timer,
// executes the retry (incrementing retryCount), then we simulate
// another failure.
for ( let attempt = 1; attempt <= 3; attempt++ ) {
await jest.runAllTimersAsync();
const inProgress = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( inProgress.status ).toBe( ItemStatus.Processing );
expect( inProgress.retryCount ).toBe( attempt );
await registry
.dispatch( uploadStore )
.cancelItem( item.id, new Error( 'Network error' ) );
}
// After max retries exhausted, the next cancel should remove
// the item and surface the error to onError.
expect(
unlock( registry.select( uploadStore ) ).getAllItems()
).toHaveLength( 0 );
expect( onError ).toHaveBeenCalledWith(
expect.objectContaining( { message: 'Network error' } )
);
} );
it( 'does nothing if item is not in PendingRetry status', async () => {
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
// Item is in Processing status, not PendingRetry.
expect( item.status ).toBe( ItemStatus.Processing );
// Execute retry should do nothing.
await registry.dispatch( uploadStore ).executeRetry( item.id );
// Status should remain unchanged.
const updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( updatedItem.status ).toBe( ItemStatus.Processing );
expect( updatedItem.retryCount ).toBeUndefined();
} );
it( 'leaves item in PendingRetry when queue is paused, then resumes on resumeQueue', async () => {
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
// Schedule a retry to put item in PendingRetry.
await registry
.dispatch( uploadStore )
.scheduleRetry( item.id, new Error( 'Network error' ) );
// Pause the queue before the timer fires.
unlock( registry.dispatch( uploadStore ) ).pauseQueue();
// Fire the retry timer while paused — executeRetry should bail
// without mutating state.
await jest.runAllTimersAsync();
const pausedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( pausedItem.status ).toBe( ItemStatus.PendingRetry );
expect( pausedItem.retryCount ).toBe( 0 );
// Resume — resumeQueue should re-trigger executeRetry for any
// PendingRetry items so they actually process.
await unlock( registry.dispatch( uploadStore ) ).resumeQueue();
const resumedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( resumedItem.status ).toBe( ItemStatus.Processing );
expect( resumedItem.retryCount ).toBe( 1 );
} );
} );
describe( 'resizeCropItem', () => {
it( 'uses imageQuality from store settings when set', async () => {
unlock( registry.dispatch( uploadStore ) ).updateSettings( {
imageQuality: 0.5,
} );
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
const { vipsResizeImage } = require( '../utils' );
( vipsResizeImage as jest.Mock ).mockClear();
await unlock( registry.dispatch( uploadStore ) ).resizeCropItem(
item.id,
{ resize: { width: 100, height: 100 } }
);
// Verify the resize was called (quality will be wired through in a future update).
expect( vipsResizeImage ).toHaveBeenCalled();
} );
it( 'falls back to default quality when imageQuality is not set', async () => {
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
const { vipsResizeImage } = require( '../utils' );
( vipsResizeImage as jest.Mock ).mockClear();
await unlock( registry.dispatch( uploadStore ) ).resizeCropItem(
item.id,
{ resize: { width: 100, height: 100 } }
);
expect( vipsResizeImage ).toHaveBeenCalled();
} );
it( 'skips resize when no resize args are provided', async () => {
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
await unlock( registry.dispatch( uploadStore ) ).resizeCropItem(
item.id
);
// Item should finish without resize.
const updatedItem = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
expect( updatedItem.file ).toBe( jpegFile );
} );
} );
describe( 'generateThumbnails', () => {
const mockBitmapClose = jest.fn();
function mockCreateImageBitmap( width: number, height: number ) {
global.createImageBitmap = jest.fn( () =>
Promise.resolve( {
width,
height,
close: mockBitmapClose,
} )
) as unknown as typeof global.createImageBitmap;
}
/**
* Sets up an item in the queue with an attachment and
* ThumbnailGeneration as the current operation, simulating
* the state after upload completes.
*
* @param overrides Optional overrides.
* @param overrides.attachment Attachment field overrides.
*/
async function setupItemForThumbnailGeneration( overrides?: {
attachment?: Record< string, unknown >;
} ) {
// Add an item with ThumbnailGeneration as the only operation.
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
operations: [ OperationType.ThumbnailGeneration ],
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];
// Simulate upload completion by finishing the operation with attachment data.
// finishOperation shifts the operations array, so ThumbnailGeneration
// becomes the next operation to process.
await unlock( registry.dispatch( uploadStore ) ).finishOperation(
item.id,
{
attachment: {
id: 123,
filename: 'example.jpg',
missing_image_sizes: [ 'thumbnail', 'medium' ],
...( overrides?.attachment || {} ),
},
}
);
return unlock( registry.select( uploadStore ) ).getAllItems()[ 0 ];
}
beforeEach( () => {
mockBitmapClose.mockClear();
} );
afterEach( () => {
// Clean up global mock.
// @ts-ignore
delete global.createImageBitmap;
} );
it( 'should not sideload a scaled version when image is below the threshold', async () => {
// Image is 800x600, threshold is 2560.
mockCreateImageBitmap( 800, 600 );
unlock( registry.dispatch( uploadStore ) ).updateSettings( {
bigImageSizeThreshold: 2560,
allImageSizes: {
thumbnail: { width: 150, height: 150 },
medium: { width: 300, height: 300 },
},
} );
const item = await setupItemForThumbnailGeneration();
await unlock( registry.dispatch( uploadStore ) ).generateThumbnails(
item.id
);
const allItems = unlock(
registry.select( uploadStore )
).getAllItems();
// Should have sideload items for thumbnails, but NOT for 'scaled'.
const scaledItems = allItems.filter(
( i ) => i.additionalData?.image_size === 'scaled'
);
expect( scaledItems ).toHaveLength( 0 );
expect( mockBitmapClose ).toHaveBeenCalled();
} );
it( 'should sideload a scaled version when image exceeds the threshold', async () => {
// Image is 4000x3000, threshold is 2560.
mockCreateImageBitmap( 4000, 3000 );
unlock( registry.dispatch( uploadStore ) ).updateSettings( {
bigImageSizeThreshold: 2560,
allImageSizes: {
thumbnail: { width: 150, height: 150 },
medium: { width: 300, height: 300 },
},
} );
const item = await setupItemForThumbnailGeneration();
await unlock( registry.dispatch( uploadStore ) ).generateThumbnails(
item.id
);
const allItems = unlock(
registry.select( uploadStore )