From c6c40e5a9521a480abafa8dd7c828a4598319597 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 17 Jul 2026 15:52:31 -0700 Subject: [PATCH 1/4] Media: Extend cross-origin isolation to the Media Library grid The client-side media pipeline needs SharedArrayBuffer, which requires a cross-origin isolated context. Core only isolates the block editor screens, so uploads from the Media Library grid cannot use the pipeline. Hook the existing Document-Isolation-Policy output buffer on load-upload.php, gated to grid mode for users who can upload files. The mode is resolved the same way upload.php resolves it later in the request, without updating the saved user option. List mode has no pipeline integration and stays untouched, avoiding isolation side effects on a screen that gets no benefit. --- src/wp-includes/default-filters.php | 1 + src/wp-includes/media.php | 51 +++++ .../wpMediaLibraryCrossOriginIsolation.php | 199 ++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 tests/phpunit/tests/media/wpMediaLibraryCrossOriginIsolation.php diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 3702c17b418e8..a18636dea90b3 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -704,6 +704,7 @@ add_action( 'load-post-new.php', 'wp_set_up_cross_origin_isolation' ); add_action( 'load-site-editor.php', 'wp_set_up_cross_origin_isolation' ); add_action( 'load-widgets.php', 'wp_set_up_cross_origin_isolation' ); +add_action( 'load-upload.php', 'wp_set_up_media_library_cross_origin_isolation' ); // Nav menu. add_filter( 'nav_menu_item_id', '_nav_menu_item_id_use_once', 10, 2 ); add_filter( 'nav_menu_css_class', 'wp_nav_menu_remove_menu_item_has_children_class', 10, 4 ); diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index e5b4276e46af5..a182d37c5d973 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -6701,6 +6701,57 @@ function wp_set_up_cross_origin_isolation(): void { wp_start_cross_origin_isolation_output_buffer(); } +/** + * Returns the current Media Library mode (grid or list). + * + * Replicates the mode resolution in wp-admin/upload.php, which runs after + * the `load-upload.php` hook, without updating the saved user option. + * + * @since 7.2.0 + * + * @return string Either 'grid' or 'list'. + */ +function wp_get_media_library_mode(): string { + $modes = array( 'grid', 'list' ); + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET['mode'] ) && in_array( $_GET['mode'], $modes, true ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return $_GET['mode']; + } + + $mode = get_user_option( 'media_library_mode', get_current_user_id() ); + + return in_array( $mode, $modes, true ) ? $mode : 'grid'; +} + +/** + * Enables cross-origin isolation in the Media Library grid. + * + * Required for enabling SharedArrayBuffer for WebAssembly-based + * media processing when uploading via the Media Library grid. + * List mode has no client-side pipeline integration and is not + * isolated. + * + * @since 7.2.0 + */ +function wp_set_up_media_library_cross_origin_isolation(): void { + if ( ! wp_is_client_side_media_processing_enabled() ) { + return; + } + + if ( 'grid' !== wp_get_media_library_mode() ) { + return; + } + + // Cross-origin isolation is not needed if users can't upload files anyway. + if ( ! current_user_can( 'upload_files' ) ) { + return; + } + + wp_start_cross_origin_isolation_output_buffer(); +} + /** * Sends the Document-Isolation-Policy header for cross-origin isolation. * diff --git a/tests/phpunit/tests/media/wpMediaLibraryCrossOriginIsolation.php b/tests/phpunit/tests/media/wpMediaLibraryCrossOriginIsolation.php new file mode 100644 index 0000000000000..b9bcf5ed4acf2 --- /dev/null +++ b/tests/phpunit/tests/media/wpMediaLibraryCrossOriginIsolation.php @@ -0,0 +1,199 @@ +original_user_agent = $_SERVER['HTTP_USER_AGENT'] ?? null; + $this->original_http_host = $_SERVER['HTTP_HOST'] ?? null; + $this->original_get_mode = $_GET['mode'] ?? null; + } + + public function tear_down() { + if ( null === $this->original_user_agent ) { + unset( $_SERVER['HTTP_USER_AGENT'] ); + } else { + $_SERVER['HTTP_USER_AGENT'] = $this->original_user_agent; + } + + if ( null === $this->original_http_host ) { + unset( $_SERVER['HTTP_HOST'] ); + } else { + $_SERVER['HTTP_HOST'] = $this->original_http_host; + } + + if ( null === $this->original_get_mode ) { + unset( $_GET['mode'] ); + } else { + $_GET['mode'] = $this->original_get_mode; + } + + // Clean up any output buffers started during tests. + while ( ob_get_level() > 1 ) { + ob_end_clean(); + } + + remove_all_filters( 'wp_client_side_media_processing_enabled' ); + parent::tear_down(); + } + + /** + * Sets up the environment for the isolation happy path: a secure + * origin, a Chromium 137+ User-Agent, and a user who can upload. + */ + private function set_up_grid_isolation_environment() { + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'; + $_SERVER['HTTP_HOST'] = 'localhost'; + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); + } + + public function test_mode_defaults_to_grid() { + unset( $_GET['mode'] ); + + $this->assertSame( 'grid', wp_get_media_library_mode() ); + } + + public function test_mode_from_query_string() { + $_GET['mode'] = 'list'; + + $this->assertSame( 'list', wp_get_media_library_mode() ); + } + + public function test_invalid_query_string_mode_falls_back_to_grid() { + $_GET['mode'] = 'bogus'; + + $this->assertSame( 'grid', wp_get_media_library_mode() ); + } + + /** + * A non-canonical query string mode is rejected, matching upload.php. + * + * upload.php compares the raw value strictly, so `?mode=GRID` falls + * back to the user option rather than being normalized to `grid`. + */ + public function test_non_canonical_query_string_mode_falls_back_to_user_option() { + $user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + update_user_option( $user_id, 'media_library_mode', 'list' ); + + $_GET['mode'] = 'GRID'; + + $this->assertSame( 'list', wp_get_media_library_mode() ); + } + + public function test_mode_from_user_option() { + unset( $_GET['mode'] ); + + $user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + update_user_option( $user_id, 'media_library_mode', 'list' ); + + $this->assertSame( 'list', wp_get_media_library_mode() ); + } + + public function test_no_buffer_when_client_side_processing_disabled() { + $this->set_up_grid_isolation_environment(); + $_GET['mode'] = 'grid'; + + add_filter( 'wp_client_side_media_processing_enabled', '__return_false' ); + + $level_before = ob_get_level(); + wp_set_up_media_library_cross_origin_isolation(); + $level_after = ob_get_level(); + + $this->assertSame( $level_before, $level_after ); + } + + public function test_no_buffer_in_list_mode() { + $this->set_up_grid_isolation_environment(); + $_GET['mode'] = 'list'; + + $level_before = ob_get_level(); + wp_set_up_media_library_cross_origin_isolation(); + $level_after = ob_get_level(); + + $this->assertSame( $level_before, $level_after ); + } + + public function test_no_buffer_when_logged_out() { + $this->set_up_grid_isolation_environment(); + $_GET['mode'] = 'grid'; + + wp_set_current_user( 0 ); + + $level_before = ob_get_level(); + wp_set_up_media_library_cross_origin_isolation(); + $level_after = ob_get_level(); + + $this->assertSame( $level_before, $level_after ); + } + + public function test_no_buffer_when_user_cannot_upload() { + $this->set_up_grid_isolation_environment(); + $_GET['mode'] = 'grid'; + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + + $level_before = ob_get_level(); + wp_set_up_media_library_cross_origin_isolation(); + $level_after = ob_get_level(); + + $this->assertSame( $level_before, $level_after ); + } + + /** + * This test must run in a separate process because the output buffer + * callback sends HTTP headers via header(), which would fail in the + * main PHPUnit process where output has already started. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_starts_output_buffer_in_grid_mode_for_chromium() { + $this->set_up_grid_isolation_environment(); + $_GET['mode'] = 'grid'; + + $level_before = ob_get_level(); + wp_set_up_media_library_cross_origin_isolation(); + $level_after = ob_get_level(); + + $this->assertSame( $level_before + 1, $level_after, 'Output buffer should be started on the grid for Chromium 137+.' ); + + ob_end_clean(); + } + + public function test_no_buffer_for_firefox() { + $this->set_up_grid_isolation_environment(); + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0'; + $_GET['mode'] = 'grid'; + + $level_before = ob_get_level(); + wp_set_up_media_library_cross_origin_isolation(); + $level_after = ob_get_level(); + + $this->assertSame( $level_before, $level_after, 'Output buffer should not be started for non-Chromium browsers.' ); + } +} From 48e92f5432bf966d20c6b0171b1045066d46b24a Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 17 Jul 2026 15:52:55 -0700 Subject: [PATCH 2/4] Media: Route Media Library grid uploads through the client-side pipeline Grid uploads go through wp.Uploader/plupload to async-upload.php, doing all image processing server-side even when the browser could handle it. Add a media-library-upload script that configures the @wordpress/upload-media store and intercepts plupload's FilesAdded at a higher priority, routing each file through the pipeline: REST upload of the original, client-side thumbnails via wasm-vips, then sideload and finalize. The grid UI is preserved by mirroring wp-plupload's placeholder tiles, progress, queue reset, and error sidebar. mediaSideload/mediaFinalize are thin apiFetch wrappers because the @wordpress/media-utils equivalents are private APIs. When the browser is not cross-origin isolated or lacks client-side support, the script no-ops and classic plupload keeps handling uploads, so degraded environments lose nothing. --- Gruntfile.js | 2 + .../_enqueues/admin/media-library-upload.js | 436 ++++++++++++++++++ src/wp-admin/upload.php | 1 + src/wp-includes/media.php | 66 +++ src/wp-includes/script-loader.php | 3 + .../media/wpEnqueueMediaLibraryUpload.php | 126 +++++ 6 files changed, 634 insertions(+) create mode 100644 src/js/_enqueues/admin/media-library-upload.js create mode 100644 tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php diff --git a/Gruntfile.js b/Gruntfile.js index 61f18481e23a8..42c6c177e955c 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -527,6 +527,7 @@ module.exports = function(grunt) { [ WORKING_DIR + 'wp-admin/js/language-chooser.js' ]: [ './src/js/_enqueues/lib/language-chooser.js' ], [ WORKING_DIR + 'wp-admin/js/link.js' ]: [ './src/js/_enqueues/admin/link.js' ], [ WORKING_DIR + 'wp-admin/js/media-gallery.js' ]: [ './src/js/_enqueues/deprecated/media-gallery.js' ], + [ WORKING_DIR + 'wp-admin/js/media-library-upload.js' ]: [ './src/js/_enqueues/admin/media-library-upload.js' ], [ WORKING_DIR + 'wp-admin/js/media-upload.js' ]: [ './src/js/_enqueues/admin/media-upload.js' ], [ WORKING_DIR + 'wp-admin/js/media.js' ]: [ './src/js/_enqueues/admin/media.js' ], [ WORKING_DIR + 'wp-admin/js/nav-menu.js' ]: [ './src/js/_enqueues/lib/nav-menu.js' ], @@ -1270,6 +1271,7 @@ module.exports = function(grunt) { 'src/wp-admin/js/language-chooser.js': 'src/js/_enqueues/lib/language-chooser.js', 'src/wp-admin/js/link.js': 'src/js/_enqueues/admin/link.js', 'src/wp-admin/js/media-gallery.js': 'src/js/_enqueues/deprecated/media-gallery.js', + 'src/wp-admin/js/media-library-upload.js': 'src/js/_enqueues/admin/media-library-upload.js', 'src/wp-admin/js/media-upload.js': 'src/js/_enqueues/admin/media-upload.js', 'src/wp-admin/js/media.js': 'src/js/_enqueues/admin/media.js', 'src/wp-admin/js/nav-menu.js': 'src/js/_enqueues/lib/nav-menu.js', diff --git a/src/js/_enqueues/admin/media-library-upload.js b/src/js/_enqueues/admin/media-library-upload.js new file mode 100644 index 0000000000000..1f7514fcc4a84 --- /dev/null +++ b/src/js/_enqueues/admin/media-library-upload.js @@ -0,0 +1,436 @@ +/** + * Routes Media Library grid uploads through the client-side media pipeline. + * + * On wp-admin/upload.php (grid mode) WordPress uploads via wp.Uploader / + * plupload to async-upload.php. When the browser is cross-origin isolated + * and supports the client-side pipeline, this script intercepts the + * uploader's FilesAdded handler and routes files through + * @wordpress/upload-media instead: the original image is uploaded via the + * REST API and thumbnails are generated in the browser (wasm-vips), then + * sideloaded and finalized. + * + * When client-side support is unavailable the script cleanly no-ops and + * the classic plupload flow is left untouched. + * + * @output wp-admin/js/media-library-upload.js + */ + +/* global plupload */ + +( function () { + // Guard against double execution (e.g. duplicate enqueues). + if ( window.__wpMediaLibraryUpload ) { + return; + } + + // Require every dependency the integration relies on. + if ( + typeof wp === 'undefined' || + typeof plupload === 'undefined' || + ! wp.Uploader || + ! wp.uploadMedia || + ! wp.mediaUtils || + ! wp.data || + ! wp.element || + ! wp.apiFetch || + ! wp.media + ) { + return; + } + + // Bail unless the browser actually supports client-side processing. This + // is the clean no-op: when the isolation headers did not land, classic + // plupload keeps handling uploads. + if ( + ! wp.uploadMedia.detectClientSideMediaSupport || + ! wp.uploadMedia.detectClientSideMediaSupport().supported + ) { + return; + } + + window.__wpMediaLibraryUpload = true; + + var __ = wp.i18n.__; + var settings = window._wpMediaLibraryUploadSettings || {}; + var uploadStore = wp.uploadMedia.store; + + // Map from a file identity key to the placeholder Attachment models + // (an array: concurrent uploads of an identical file share a key), used + // to reflect pipeline progress back onto the grid tiles. + var progressModels = new Map(); + + /** + * Builds a stable identity key for a File. + * + * The queue item's `sourceFile` is a clone of the original file, so it + * cannot be matched by reference. The clone preserves name, size, and + * last-modified time, which together identify a file within one session. + * Two in-flight uploads of the same file collide on this key, so keys + * map to arrays of models and progress is mirrored to all of them. + * + * @param {File} file The file to key. + * @return {string} Identity key. + */ + function fileKey( file ) { + return file.name + '::' + file.size + '::' + file.lastModified; + } + + /** + * Recursively appends data to a FormData object, supporting nested objects. + * + * Mirrors flattenFormData() in @wordpress/media-utils. + * + * @param {FormData} formData The form data to append to. + * @param {string} key The key to append under. + * @param {string|Object} data The value to append. + */ + function flattenFormData( formData, key, data ) { + if ( + data !== null && + typeof data === 'object' && + Object.getPrototypeOf( data ) === Object.prototype + ) { + Object.keys( data ).forEach( function ( name ) { + flattenFormData( formData, key + '[' + name + ']', data[ name ] ); + } ); + } else if ( data !== undefined ) { + formData.append( key, String( data ) ); + } + } + + /** + * Sideloads a client-generated thumbnail to an existing attachment. + * + * Reimplements the private sideloadMedia() helper from + * @wordpress/media-utils as a thin apiFetch wrapper. + * + * @param {Object} args The sideload arguments. + */ + function mediaSideload( args ) { + var file = args.file; + var additionalData = args.additionalData || {}; + + var data = new FormData(); + data.append( 'file', file, file.name || file.type.replace( '/', '.' ) ); + Object.keys( additionalData ).forEach( function ( key ) { + flattenFormData( data, key, additionalData[ key ] ); + } ); + + wp.apiFetch( { + path: '/wp/v2/media/' + args.attachmentId + '/sideload', + body: data, + method: 'POST', + signal: args.signal, + } ) + .then( function ( subSize ) { + if ( args.onSuccess ) { + args.onSuccess( subSize ); + } + } ) + .catch( function ( error ) { + if ( args.onError ) { + var normalized = error; + if ( ! ( error instanceof Error ) ) { + normalized = new Error( + error && error.message ? error.message : String( error ) + ); + } + args.onError( normalized ); + } + } ); + } + + /** + * Finalizes an upload once all client-side processing is complete. + * + * Reimplements the private mediaFinalize() helper. The returned + * attachment is load-bearing: it carries the post-finalize (scaled) + * URL used for srcset. + * + * @param {number} id The parent attachment ID. + * @param {Array} subSizes Accumulated sub-size data. + * @return {Promise} Resolves with the transformed attachment. + */ + function mediaFinalize( id, subSizes ) { + return wp + .apiFetch( { + path: '/wp/v2/media/' + id + '/finalize', + method: 'POST', + data: { sub_sizes: subSizes || [] }, + } ) + .then( function ( response ) { + if ( ! response ) { + return undefined; + } + return wp.mediaUtils.transformAttachment( response ); + } ); + } + + // Configure the default-registry upload-media store once. Rendering the + // provider with useSubRegistry: false wires the settings into the store + // that wp.data.dispatch/select address (the block editor does the same). + var pipelineSettings = { + mediaUpload: wp.mediaUtils.uploadMedia, + mediaSideload: mediaSideload, + mediaFinalize: mediaFinalize, + maxUploadFileSize: settings.maxUploadFileSize, + allowedMimeTypes: settings.allowedMimeTypes, + allImageSizes: settings.allImageSizes, + bigImageSizeThreshold: settings.bigImageSizeThreshold, + imageStripMeta: settings.imageStripMeta, + imageMaxBitDepth: settings.imageMaxBitDepth, + }; + + wp.element + .createRoot( document.createElement( 'div' ) ) + .render( + wp.element.createElement( wp.uploadMedia.MediaUploadProvider, { + settings: pipelineSettings, + useSubRegistry: false, + } ) + ); + + /** + * Resets the upload queue once every attachment has finished uploading. + * + * Parity with wp-plupload.js so browse mode flips back when done. + */ + function maybeResetQueue() { + var complete = wp.Uploader.queue.all( function ( attachment ) { + return ! attachment.get( 'uploading' ); + } ); + + if ( complete ) { + wp.Uploader.queue.reset(); + } + } + + /** + * Removes a model from the progress map. + * + * @param {Object} model The Attachment model to stop tracking. + */ + function stopTrackingProgress( model ) { + progressModels.forEach( function ( models, key ) { + var index = models.indexOf( model ); + if ( index !== -1 ) { + models.splice( index, 1 ); + } + if ( models.length === 0 ) { + progressModels.delete( key ); + } + } ); + } + + /** + * Handles a completed upload by syncing the grid tile with the server data. + * + * @param {Object} model The placeholder Attachment model. + * @param {Object} attachment The finalized attachment from the pipeline. + */ + function handleSuccess( model, attachment ) { + model.set( { id: attachment.id }, { silent: true } ); + + // Register the model in Attachments.all (parity with wp-plupload.js). + wp.media.model.Attachment.get( attachment.id, model ); + + model + .fetch() + .done( function () { + [ 'file', 'loaded', 'size', 'percent' ].forEach( function ( + key + ) { + model.unset( key, { silent: true } ); + } ); + model.set( { uploading: false } ); + } ) + .fail( function () { + // Fetch failed, but the upload succeeded: clear the uploading + // state with what the pipeline gave us so no tile is stuck. + [ 'file', 'loaded', 'size', 'percent' ].forEach( function ( + key + ) { + model.unset( key, { silent: true } ); + } ); + model.set( attachment ); + model.set( { uploading: false } ); + } ) + .always( function () { + stopTrackingProgress( model ); + maybeResetQueue(); + } ); + } + + /** + * Handles an upload error by removing the tile and surfacing the message. + * + * @param {Object} model The placeholder Attachment model. + * @param {Error} error The upload error. + * @param {File} nativeFile The original file (for the error label). + */ + function handleError( model, error, nativeFile ) { + var message = + ( wp.uploadMedia.getErrorMessage && + wp.uploadMedia.getErrorMessage( error ) ) || + ( error && error.message ) || + __( 'An error occurred while uploading the file.' ); + + model.destroy(); + + wp.Uploader.errors.unshift( { + message: message, + data: {}, + file: { name: nativeFile.name }, + } ); + + stopTrackingProgress( model ); + maybeResetQueue(); + } + + /** + * Dispatches a single file into the client-side pipeline. + * + * @param {File} nativeFile The original file to upload. + * @param {Object} model The placeholder Attachment model. + */ + function uploadFile( nativeFile, model ) { + wp.data.dispatch( uploadStore ).addItems( { + files: [ nativeFile ], + onSuccess: function ( attachments ) { + handleSuccess( model, attachments[ 0 ] ); + }, + onError: function ( error ) { + handleError( model, error, nativeFile ); + }, + } ); + } + + /** + * Intercepts files added to a plupload uploader. + * + * Returns undefined (not false) when the store is not yet configured so + * the built-in handler runs and uploads server-side - a degradation, never + * data loss. Otherwise builds the same placeholder tiles as wp-plupload, + * routes each file through the pipeline, and returns false to suppress the + * built-in handler. + * + * @param {Object} wpUploader The wp.Uploader instance. + * @param {Object} up The plupload uploader instance. + * @param {Array} files Files added to the queue. + * @return {boolean|undefined} False to suppress the built-in handler. + */ + function handleFilesAdded( wpUploader, up, files ) { + var storeSettings = wp.data.select( uploadStore ).getSettings(); + + // Safety valve: if settings never landed, defer to classic plupload. + if ( ! storeSettings || ! storeSettings.mediaUpload ) { + return; + } + + files.forEach( function ( file ) { + // Ignore failed uploads. + if ( plupload.FAILED === file.status ) { + return; + } + + // Build the same placeholder attributes as wp-plupload.js so the + // grid's progress tiles and "Uploading n/m" status work unchanged. + var attributes = { + file: file, + uploading: true, + date: new Date(), + filename: file.name, + menuOrder: 0, + uploadedTo: wp.media.model.settings.post.id, + loaded: file.loaded, + size: file.size, + percent: file.percent, + }; + + var image = /(?:jpe?g|png|gif)$/i.exec( file.name ); + if ( image ) { + attributes.type = 'image'; + // `jpg` is not a valid subtype, so map it to `jpeg`. + attributes.subtype = 'jpg' === image[ 0 ] ? 'jpeg' : image[ 0 ]; + } + + var model = wp.media.model.Attachment.create( attributes ); + wp.Uploader.queue.add( model ); + wpUploader.added( model ); + + var nativeFile = file.getNative(); + var key = fileKey( nativeFile ); + var models = progressModels.get( key ); + if ( models ) { + models.push( model ); + } else { + progressModels.set( key, [ model ] ); + } + + // Remove the file from plupload so it is not uploaded twice. + up.removeFile( file ); + + uploadFile( nativeFile, model ); + } ); + + up.refresh(); + + return false; + } + + // Wrap wp.Uploader.prototype.init (an empty stub called once per instance + // after plupload is initialized) to bind a higher-priority FilesAdded + // handler on every uploader instance, including the Media Library grid's. + var originalInit = wp.Uploader.prototype.init; + wp.Uploader.prototype.init = function () { + originalInit.apply( this, arguments ); + + var wpUploader = this; + var up = this.uploader; + + if ( ! up || up.__wpMediaLibraryUploadBound ) { + return; + } + up.__wpMediaLibraryUploadBound = true; + + // plupload sorts handlers by priority (descending) and a `false` + // return breaks the chain, so priority 100 runs before and suppresses + // the built-in FilesAdded handler. + up.bind( + 'FilesAdded', + function ( uploader, files ) { + return handleFilesAdded( wpUploader, uploader, files ); + }, + this, + 100 + ); + }; + + // Reflect pipeline progress onto the placeholder tiles. Progress is + // reported 0-100; hold at 99 until the model is marked done so the tile + // does not appear finished before the sync completes. + wp.data.subscribe( function () { + if ( progressModels.size === 0 ) { + return; + } + + var items = wp.data.select( uploadStore ).getItems(); + items.forEach( function ( item ) { + if ( ! item.sourceFile ) { + return; + } + + var models = progressModels.get( fileKey( item.sourceFile ) ); + if ( ! models ) { + return; + } + + if ( typeof item.progress === 'number' ) { + var percent = Math.min( 99, Math.round( item.progress ) ); + models.forEach( function ( model ) { + model.set( { percent: percent } ); + } ); + } + } ); + } ); +} )(); diff --git a/src/wp-admin/upload.php b/src/wp-admin/upload.php index 1f42a287e4957..edbbc942a7c23 100644 --- a/src/wp-admin/upload.php +++ b/src/wp-admin/upload.php @@ -141,6 +141,7 @@ wp_enqueue_media(); wp_enqueue_script( 'media-grid' ); wp_enqueue_script( 'media' ); + wp_enqueue_media_library_upload(); // Remove the error parameter added by deprecation of wp-admin/media.php. add_filter( diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index a182d37c5d973..b5256063795cc 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -6752,6 +6752,72 @@ function wp_set_up_media_library_cross_origin_isolation(): void { wp_start_cross_origin_isolation_output_buffer(); } +/** + * Returns the settings for the client-side media processing pipeline + * in the Media Library. + * + * These mirror the values the block editor consumes for the same + * pipeline: the REST index (image sizes and the big-image threshold) + * and get_block_editor_settings() (max upload size and allowed mime + * types), plus the image encoding filters. + * + * @since 7.2.0 + * + * @return array { + * Settings for the client-side media processing pipeline. + * + * @type int $maxUploadFileSize Maximum upload file size in bytes. + * @type array $allowedMimeTypes Allowed mime types keyed by file extension. + * @type array $allImageSizes All registered image sub-sizes. + * @type int $bigImageSizeThreshold Threshold above which originals are scaled down. + * @type bool $imageStripMeta Whether metadata is stripped from generated images. + * @type int $imageMaxBitDepth Maximum bit depth for generated images. + * } + */ +function wp_get_media_library_upload_settings(): array { + /** This filter is documented in wp-admin/includes/image.php */ + $big_image_size_threshold = (int) apply_filters( 'big_image_size_threshold', 2560, array( 0, 0 ), '', 0 ); + + /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */ + $image_strip_meta = (bool) apply_filters( 'image_strip_meta', true ); + + /** This filter is documented in wp-includes/class-wp-image-editor-imagick.php */ + $image_max_bit_depth = (int) apply_filters( 'image_max_bit_depth', 16, 16 ); + + return array( + 'maxUploadFileSize' => (int) wp_max_upload_size(), + 'allowedMimeTypes' => get_allowed_mime_types(), + 'allImageSizes' => wp_get_registered_image_subsizes(), + 'bigImageSizeThreshold' => $big_image_size_threshold, + 'imageStripMeta' => $image_strip_meta, + 'imageMaxBitDepth' => $image_max_bit_depth, + ); +} + +/** + * Enqueues the script that routes Media Library grid uploads through + * the client-side media processing pipeline. + * + * The script self-guards: when the browser is not cross-origin isolated + * or lacks client-side media support, it no-ops and the classic plupload + * flow keeps handling uploads. + * + * @since 7.2.0 + */ +function wp_enqueue_media_library_upload(): void { + if ( ! wp_is_client_side_media_processing_enabled() ) { + return; + } + + wp_enqueue_script( 'media-library-upload' ); + + wp_add_inline_script( + 'media-library-upload', + 'window._wpMediaLibraryUploadSettings = ' . wp_json_encode( wp_get_media_library_upload_settings() ) . ';', + 'before' + ); +} + /** * Sends the Document-Isolation-Policy header for cross-origin isolation. * diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index d777ecccc4373..18309abe2721e 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -1515,6 +1515,9 @@ function wp_default_scripts( $scripts ) { $scripts->add( 'media', "/wp-admin/js/media$suffix.js", array( 'jquery', 'clipboard', 'wp-i18n', 'wp-a11y' ), false, 1 ); $scripts->set_translations( 'media' ); + $scripts->add( 'media-library-upload', "/wp-admin/js/media-library-upload$suffix.js", array( 'media-views', 'wp-upload-media', 'wp-media-utils', 'wp-api-fetch', 'wp-data', 'wp-element', 'wp-i18n' ), false, 1 ); + $scripts->set_translations( 'media-library-upload' ); + $scripts->add( 'image-edit', "/wp-admin/js/image-edit$suffix.js", array( 'jquery', 'jquery-ui-core', 'imgareaselect', 'wp-a11y' ), false, 1 ); $scripts->set_translations( 'image-edit' ); diff --git a/tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php b/tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php new file mode 100644 index 0000000000000..d8cc6644dbbe4 --- /dev/null +++ b/tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php @@ -0,0 +1,126 @@ +original_http_host = $_SERVER['HTTP_HOST'] ?? null; + + // A secure origin so client-side media processing is enabled. + $_SERVER['HTTP_HOST'] = 'localhost'; + + /* + * The script is registered in the admin-only branch of + * wp_default_scripts(), so default scripts must be (re)registered + * from an admin context. + */ + set_current_screen( 'upload' ); + $this->original_wp_scripts = $GLOBALS['wp_scripts'] ?? null; + $GLOBALS['wp_scripts'] = new WP_Scripts(); + } + + public function tear_down() { + if ( null === $this->original_http_host ) { + unset( $_SERVER['HTTP_HOST'] ); + } else { + $_SERVER['HTTP_HOST'] = $this->original_http_host; + } + + $GLOBALS['wp_scripts'] = $this->original_wp_scripts; + $GLOBALS['current_screen'] = null; + + remove_all_filters( 'wp_client_side_media_processing_enabled' ); + parent::tear_down(); + } + + public function test_script_enqueued() { + wp_enqueue_media_library_upload(); + + $this->assertTrue( wp_script_is( 'media-library-upload', 'enqueued' ) ); + } + + public function test_script_not_enqueued_when_client_side_processing_disabled() { + add_filter( 'wp_client_side_media_processing_enabled', '__return_false' ); + + wp_enqueue_media_library_upload(); + + $this->assertFalse( wp_script_is( 'media-library-upload', 'enqueued' ) ); + } + + /** + * The script depends on media-views and wp-upload-media, not + * wp-block-editor, so block editor bundles are not dragged onto + * the Media Library page. + */ + public function test_dependencies() { + wp_enqueue_media_library_upload(); + + $script = wp_scripts()->registered['media-library-upload']; + $this->assertContains( 'media-views', $script->deps ); + $this->assertContains( 'wp-upload-media', $script->deps ); + $this->assertNotContains( 'wp-block-editor', $script->deps ); + } + + public function test_inline_settings_expose_all_keys() { + wp_enqueue_media_library_upload(); + + $before = wp_scripts()->get_data( 'media-library-upload', 'before' ); + $inline = implode( "\n", (array) $before ); + + $this->assertStringContainsString( 'window._wpMediaLibraryUploadSettings', $inline ); + + foreach ( array( + 'maxUploadFileSize', + 'allowedMimeTypes', + 'allImageSizes', + 'bigImageSizeThreshold', + 'imageStripMeta', + 'imageMaxBitDepth', + ) as $key ) { + $this->assertStringContainsString( $key, $inline ); + } + } + + public function test_big_image_size_threshold_filter() { + add_filter( + 'big_image_size_threshold', + static function () { + return 4096; + } + ); + + $settings = wp_get_media_library_upload_settings(); + + $this->assertSame( 4096, $settings['bigImageSizeThreshold'] ); + } + + public function test_settings_value_types() { + $settings = wp_get_media_library_upload_settings(); + + $this->assertIsInt( $settings['maxUploadFileSize'] ); + $this->assertIsArray( $settings['allowedMimeTypes'] ); + $this->assertIsArray( $settings['allImageSizes'] ); + $this->assertIsInt( $settings['bigImageSizeThreshold'] ); + $this->assertIsBool( $settings['imageStripMeta'] ); + $this->assertIsInt( $settings['imageMaxBitDepth'] ); + } +} From 88fed705ddf5897e5097b720b11e7107119aaedc Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 17 Jul 2026 15:53:01 -0700 Subject: [PATCH 3/4] Media: Add E2E coverage for Media Library grid client-side uploads Assert the Document-Isolation-Policy header is sent on the grid and not in list mode, that a JPEG upload flows through the REST create, sideload, and finalize endpoints with no async-upload.php requests, and that a disallowed file type surfaces in the error sidebar. Playwright's Chromium build ships without Document-Isolation-Policy support, so the upload assertions skip when the context is not cross-origin isolated; the header assertions still run everywhere. --- tests/e2e/assets/test-image.jpg | Bin 0 -> 2028 bytes .../media-library-client-side-upload.test.js | 148 ++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 tests/e2e/assets/test-image.jpg create mode 100644 tests/e2e/specs/media-library-client-side-upload.test.js diff --git a/tests/e2e/assets/test-image.jpg b/tests/e2e/assets/test-image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..534aac1d6bf494f7e83c96b32cfe29271e8aac5a GIT binary patch literal 2028 zcmbu9dr(t%7RP@#A%s9M1_M#3O~Dt1TBd+Rgp##vMX-pVB9KCg6dQ;vBM%J{Kmmyu zlp-oER04)V-4!WI0zQD41VL?4UdqEok{}O7I7=&|K(rj7AT%0fhQ?sb zjDvu(jPHRN5o6={$yW0fp|Mz}LnQa(CDj&dK5LZNhV{a0J@%*M;wP>w&H zn4Fq^_FVOP?#;Y<0r_pw#03HL(#ZZRmvIOX%FGOHhBa|Ps3RtDq8Y~V6LXubq1f0% zE1cYqTaZ30scw|u)_8=$w)<0hE$!BND%Pn?Xm82>H?Z9Qi);z(53Uh_M?=Qtp^1P7 z^m=Ddg*xUEF*fx4@ZHSXCnJe=DZag3N8(bgM}=qUA?rlawXkoAha?sMXwEsMH-uEO zIg)q|(2ZG^z?e+9Pe5D)UY2EQaM_Za)qdVc_^NaoZ{g@BzVyNHxXkPD*ob}0DKXQH zM{Y#cD8kEXrWG;KDOuExEy8Tqfizw^m0XT3m_NZ=R&|Up`B;xv7(nhZ7K7-woReh? z*_`M{^WN?2IB1oZNF`95ZYjL_aYYA}v8**md4Q`Fb0-RP?ITTW2c^2E-SJ`No2=$& zf92E0yCtz76rnZDrB8)2X{ii@)iwYtZ=RNH(-m>hvRgA<0#vbJ(l9 z>bHJ)hd_T%Z}G4vgJ<3bYov1wZCuZW$Mzvf84U>w_^(fSRuLxtp|=dJouR>78OUY> zP)xmSkk@-@cIqyQnaQ>dUBRp``Zi8z%zuBdcC{Sr<=O3SCqLq z-P&zW|0~5e1<$*7HD1eo)u&v~oXU-u*yeKUTkFKPOCjq7*}iOoIvT;Miq$-%I!nig z4M5J96_YtZsy%m9^#hBHd?CFpCfFl5GBSKB@=<&(f^!$qO zv_g*yyleoHL+vE33sSEMMW}k57{-p13yt*z4BG};5`m-*LrOJYKBHK-#{PsHQD*b% z5*hebQYF4rcJ%yIftDiA!Hq%+YpY#1sO9m^ZJe|8p(~eT6QS1&+V`k%oZfO)lcK-f ztM%8_h~*W0lpkT5-}pqJq?|70gd@?KaHiN+%c|CXTJe`GsxtdzCEft?lfSq&;JYa! zfu$H*iJZxF5Z>&+1Y;@%&Ga3tJMlmlV*q&};$#CrSPzi(Ps-p8om&Rg#kUP$h!@tM zZT-A)^vjHq3j#QGx2Sa>+nm++Vcf*#+3xCWm|qe*F3mWo`SuMK9*R{fhLC zH2G6E#Pz`oo{Dvw*2LA?3`%M5YpGYXA0v61-CAb`;@s7d1I^g5+E*iVc(;T?O0&u# za=Zu9X9-r1>9Z>$XY3ZaMcVipW*IrEEB^qJRLfla0he?2mpFh`2f_hLuEec~hWB+H z=MN7m39^vFG3?bBJI~9<>^L8&0t&RTu%1OB1TDUIBm6`?J7{57PxCa{&SttLD!u+z zmeZ@wB=<=x7BBLvBD9Y>AK_|fS`Wm3JUUZdty+*i;8M`@8=YpPF+M76XEK@1k}wE= z6E=_~Zrn-u)%AL$DYWm+e&4{=+Hz0(gDv~B!s(51EYjSsB|l?-+nytFpvkMnIOFz9 zDR2a3)r>u@!>8CLTUP0fss2j>~b=h(03{cN-c_1$N%Z5La;vo0Y! zI;s8swf|z}Ny>|h(z@NdXGN(=&e+9YI4(#NEFQ0u&EmBYh?|Na;oG*aMDE|uGyoi8 zykHB3o_sRfzr*W7%V1RWiyJ$|)uT_IwbaV?{w(ejt*pM5Ak>9RHNkCXtY&<^a{_Pd PO?Tnlf9gLS!7%(gr&MST literal 0 HcmV?d00001 diff --git a/tests/e2e/specs/media-library-client-side-upload.test.js b/tests/e2e/specs/media-library-client-side-upload.test.js new file mode 100644 index 0000000000000..adc474cd195a3 --- /dev/null +++ b/tests/e2e/specs/media-library-client-side-upload.test.js @@ -0,0 +1,148 @@ +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * External dependencies + */ +import path from 'path'; + +const TEST_IMAGE_PATH = path.join( __dirname, '../assets/test-image.jpg' ); + +// The plupload HTML5 runtime creates this hidden file input over the +// "Add New" browse button; setting files on it triggers FilesAdded. +const FILE_INPUT_SELECTOR = '.moxie-shim-html5 input[type="file"]'; + +test.describe( 'Media Library grid client-side uploads', () => { + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllMedia(); + } ); + + test( 'sends the Document-Isolation-Policy header on the grid', async ( { + page, + admin, + } ) => { + const responsePromise = page.waitForResponse( + ( resp ) => + resp.url().includes( '/wp-admin/upload.php' ) && + resp.request().resourceType() === 'document' && + resp.status() === 200 + ); + + await admin.visitAdminPage( 'upload.php', 'mode=grid' ); + + const headers = ( await responsePromise ).headers(); + expect( headers[ 'document-isolation-policy' ] ).toBe( + 'isolate-and-credentialless' + ); + } ); + + test( 'does not send the Document-Isolation-Policy header in list mode', async ( { + page, + admin, + } ) => { + const responsePromise = page.waitForResponse( + ( resp ) => + resp.url().includes( '/wp-admin/upload.php' ) && + resp.request().resourceType() === 'document' && + resp.status() === 200 + ); + + await admin.visitAdminPage( 'upload.php', 'mode=list' ); + + const headers = ( await responsePromise ).headers(); + expect( headers[ 'document-isolation-policy' ] ).toBeUndefined(); + } ); + + test( 'uploads an image through the client-side pipeline', async ( { + page, + admin, + } ) => { + await admin.visitAdminPage( 'upload.php', 'mode=grid' ); + + const isolated = await page.evaluate( () => + Boolean( window.crossOriginIsolated ) + ); + // Playwright's Chromium build lacks Document-Isolation-Policy + // support, so isolation is legitimately unavailable there and the + // pipeline falls back to classic uploads. Only assert where + // isolation is real. + test.skip( + ! isolated, + 'The client-side pipeline requires a cross-origin isolated context' + ); + + // The REST route may be a pretty permalink (/wp/v2/media) or the + // plain form (index.php?rest_route=%2Fwp%2Fv2%2Fmedia), so match on + // the decoded URL. + let mediaCreateCount = 0; + let sideloadCount = 0; + let finalizeCount = 0; + const asyncUploads = []; + page.on( 'request', ( request ) => { + if ( request.method() !== 'POST' ) { + return; + } + const url = request.url(); + if ( url.includes( '/async-upload.php' ) ) { + asyncUploads.push( url ); + return; + } + const decoded = decodeURIComponent( url ); + if ( /\/wp\/v2\/media\/\d+\/sideload/.test( decoded ) ) { + sideloadCount++; + } else if ( /\/wp\/v2\/media\/\d+\/finalize/.test( decoded ) ) { + finalizeCount++; + } else if ( /\/wp\/v2\/media(?:[?&]|$)/.test( decoded ) ) { + mediaCreateCount++; + } + } ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( TEST_IMAGE_PATH ); + + // The finalized attachment resolves to a normal (non-uploading) tile. + await expect( + page.locator( 'li.attachment:not(.uploading)' ).first() + ).toBeVisible( { timeout: 60_000 } ); + + // The original upload and every sideload go through the REST API, + // and the upload is finalized exactly once. + expect( mediaCreateCount ).toBeGreaterThanOrEqual( 1 ); + expect( sideloadCount ).toBeGreaterThanOrEqual( 1 ); + expect( finalizeCount ).toBe( 1 ); + + // Nothing goes through the classic async-upload.php endpoint. + expect( asyncUploads ).toEqual( [] ); + } ); + + test( 'shows an error for a disallowed file type', async ( { + page, + admin, + } ) => { + await admin.visitAdminPage( 'upload.php', 'mode=grid' ); + + const isolated = await page.evaluate( () => + Boolean( window.crossOriginIsolated ) + ); + test.skip( + ! isolated, + 'The client-side pipeline requires a cross-origin isolated context' + ); + + const fileInput = page.locator( FILE_INPUT_SELECTOR ).first(); + await fileInput.waitFor( { state: 'attached', timeout: 30_000 } ); + await fileInput.setInputFiles( { + name: 'disallowed.xyz', + mimeType: 'application/octet-stream', + buffer: Buffer.from( 'not an allowed file type' ), + } ); + + // The Manage frame renders rejected uploads in the error sidebar. + await expect( + page.locator( '.upload-error, .upload-errors' ).first() + ).toBeVisible( { timeout: 30_000 } ); + } ); +} ); From 27d0ef13df217bcaad9d89749c929174a505bf19 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Fri, 17 Jul 2026 16:43:27 -0700 Subject: [PATCH 4/4] Media: Reference Trac ticket 65661 in test annotations The ticket did not exist yet when the tests were written; annotate all new test methods now that it has been filed. --- .../media/wpEnqueueMediaLibraryUpload.php | 17 ++++++++++ .../wpMediaLibraryCrossOriginIsolation.php | 31 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php b/tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php index d8cc6644dbbe4..7c804fe46c89b 100644 --- a/tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php +++ b/tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php @@ -52,12 +52,18 @@ public function tear_down() { parent::tear_down(); } + /** + * @ticket 65661 + */ public function test_script_enqueued() { wp_enqueue_media_library_upload(); $this->assertTrue( wp_script_is( 'media-library-upload', 'enqueued' ) ); } + /** + * @ticket 65661 + */ public function test_script_not_enqueued_when_client_side_processing_disabled() { add_filter( 'wp_client_side_media_processing_enabled', '__return_false' ); @@ -70,6 +76,8 @@ public function test_script_not_enqueued_when_client_side_processing_disabled() * The script depends on media-views and wp-upload-media, not * wp-block-editor, so block editor bundles are not dragged onto * the Media Library page. + * + * @ticket 65661 */ public function test_dependencies() { wp_enqueue_media_library_upload(); @@ -80,6 +88,9 @@ public function test_dependencies() { $this->assertNotContains( 'wp-block-editor', $script->deps ); } + /** + * @ticket 65661 + */ public function test_inline_settings_expose_all_keys() { wp_enqueue_media_library_upload(); @@ -100,6 +111,9 @@ public function test_inline_settings_expose_all_keys() { } } + /** + * @ticket 65661 + */ public function test_big_image_size_threshold_filter() { add_filter( 'big_image_size_threshold', @@ -113,6 +127,9 @@ static function () { $this->assertSame( 4096, $settings['bigImageSizeThreshold'] ); } + /** + * @ticket 65661 + */ public function test_settings_value_types() { $settings = wp_get_media_library_upload_settings(); diff --git a/tests/phpunit/tests/media/wpMediaLibraryCrossOriginIsolation.php b/tests/phpunit/tests/media/wpMediaLibraryCrossOriginIsolation.php index b9bcf5ed4acf2..cf4be1219f82a 100644 --- a/tests/phpunit/tests/media/wpMediaLibraryCrossOriginIsolation.php +++ b/tests/phpunit/tests/media/wpMediaLibraryCrossOriginIsolation.php @@ -70,18 +70,27 @@ private function set_up_grid_isolation_environment() { wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); } + /** + * @ticket 65661 + */ public function test_mode_defaults_to_grid() { unset( $_GET['mode'] ); $this->assertSame( 'grid', wp_get_media_library_mode() ); } + /** + * @ticket 65661 + */ public function test_mode_from_query_string() { $_GET['mode'] = 'list'; $this->assertSame( 'list', wp_get_media_library_mode() ); } + /** + * @ticket 65661 + */ public function test_invalid_query_string_mode_falls_back_to_grid() { $_GET['mode'] = 'bogus'; @@ -93,6 +102,8 @@ public function test_invalid_query_string_mode_falls_back_to_grid() { * * upload.php compares the raw value strictly, so `?mode=GRID` falls * back to the user option rather than being normalized to `grid`. + * + * @ticket 65661 */ public function test_non_canonical_query_string_mode_falls_back_to_user_option() { $user_id = self::factory()->user->create( array( 'role' => 'editor' ) ); @@ -104,6 +115,9 @@ public function test_non_canonical_query_string_mode_falls_back_to_user_option() $this->assertSame( 'list', wp_get_media_library_mode() ); } + /** + * @ticket 65661 + */ public function test_mode_from_user_option() { unset( $_GET['mode'] ); @@ -114,6 +128,9 @@ public function test_mode_from_user_option() { $this->assertSame( 'list', wp_get_media_library_mode() ); } + /** + * @ticket 65661 + */ public function test_no_buffer_when_client_side_processing_disabled() { $this->set_up_grid_isolation_environment(); $_GET['mode'] = 'grid'; @@ -127,6 +144,9 @@ public function test_no_buffer_when_client_side_processing_disabled() { $this->assertSame( $level_before, $level_after ); } + /** + * @ticket 65661 + */ public function test_no_buffer_in_list_mode() { $this->set_up_grid_isolation_environment(); $_GET['mode'] = 'list'; @@ -138,6 +158,9 @@ public function test_no_buffer_in_list_mode() { $this->assertSame( $level_before, $level_after ); } + /** + * @ticket 65661 + */ public function test_no_buffer_when_logged_out() { $this->set_up_grid_isolation_environment(); $_GET['mode'] = 'grid'; @@ -151,6 +174,9 @@ public function test_no_buffer_when_logged_out() { $this->assertSame( $level_before, $level_after ); } + /** + * @ticket 65661 + */ public function test_no_buffer_when_user_cannot_upload() { $this->set_up_grid_isolation_environment(); $_GET['mode'] = 'grid'; @@ -171,6 +197,8 @@ public function test_no_buffer_when_user_cannot_upload() { * * @runInSeparateProcess * @preserveGlobalState disabled + * + * @ticket 65661 */ public function test_starts_output_buffer_in_grid_mode_for_chromium() { $this->set_up_grid_isolation_environment(); @@ -185,6 +213,9 @@ public function test_starts_output_buffer_in_grid_mode_for_chromium() { ob_end_clean(); } + /** + * @ticket 65661 + */ public function test_no_buffer_for_firefox() { $this->set_up_grid_isolation_environment(); $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0';