- generate( refinePrompt.trim(), previewSrc )
+ generate(
+ refinePrompt.trim(),
+ previewSrc,
+ historyIndex
+ )
}
>
{ __( 'Refine', 'ai' ) }
diff --git a/src/experiments/image-generation/components/MediaLibraryImageEditor.tsx b/src/experiments/image-generation/components/MediaLibraryImageEditor.tsx
new file mode 100644
index 00000000..adbaf5ae
--- /dev/null
+++ b/src/experiments/image-generation/components/MediaLibraryImageEditor.tsx
@@ -0,0 +1,509 @@
+/**
+ * AI editing panel for the WordPress Media Library image editor.
+ *
+ * Renders preset action buttons and a Refine Image option directly
+ * below the native image editor toolbar. Applies AI edits to the
+ * existing attachment and saves the result as a new attachment.
+ */
+
+/**
+ * WordPress dependencies
+ */
+import { useState, useEffect } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import {
+ Button,
+ TextareaControl,
+ Spinner,
+ Notice,
+ Icon,
+} from '@wordpress/components';
+import { chevronLeft, chevronRight } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import { runAbility } from '../../../utils/run-ability';
+import { urlToBase64, prepareExpandCanvas } from '../../../utils/image';
+import { uploadImage } from '../functions/upload-image';
+import { useImageHistory } from '../hooks/useImageHistory';
+import type {
+ GeneratedImageData,
+ ImageGenerationAbilityInput,
+ UploadedImage,
+} from '../types';
+
+type EditorState = 'idle' | 'generating' | 'preview' | 'refining' | 'saving';
+
+interface Preset {
+ label: string;
+ prompt: string;
+ icon: JSX.Element;
+ prepare?: ( url: string ) => Promise< string >;
+}
+
+const PRESETS: Preset[] = [
+ {
+ label: __( 'Expand Background', 'ai' ),
+ prompt: __(
+ 'Outpaint the image to create a wider panoramic view. Expand the scene outward in all directions to fill the empty transparent border while preserving the original style, lighting, colors, and perspective. Continue textures, structures, and environmental elements naturally so the extension blends seamlessly with the original image. Preserve the original image exactly and only generate content in the empty area.',
+ 'ai'
+ ),
+ icon: ,
+ prepare: ( url: string ) => prepareExpandCanvas( url ),
+ },
+ {
+ label: __( 'Remove Background', 'ai' ),
+ prompt: __(
+ 'Remove the entire background and isolate the main subject. Replace the background with a pure solid white (#FFFFFF) background. Preserve all details of the subject and maintain natural, clean edges around the silhouette. Ensure there are no remaining environmental elements, textures, gradients, or shadows from the original background. The final result should look like a professional studio product photo with a perfectly clean white backdrop.',
+ 'ai'
+ ),
+ icon: ,
+ },
+];
+
+interface Props {
+ postId: number;
+ attachmentUrl: string;
+ imagePanel?: HTMLElement;
+}
+
+/**
+ * AI editing panel for the WordPress Media Library image editor.
+ *
+ * Shows preset action buttons and a Refine Image option directly in the panel
+ * below the native image editor toolbar.
+ *
+ * @param {Props} props Component props.
+ */
+export function MediaLibraryImageEditor( {
+ attachmentUrl,
+ imagePanel,
+}: Props ) {
+ const [ state, setState ] = useState< EditorState >( 'idle' );
+
+ const [ prompt, setPrompt ] = useState( '' );
+ const [ refinePrompt, setRefinePrompt ] = useState( '' );
+ const [ savedUpload, setSavedUpload ] = useState< UploadedImage | null >(
+ null
+ );
+ const [ error, setError ] = useState< string | null >( null );
+
+ const {
+ history,
+ historyIndex,
+ activeEntry,
+ canGoBack,
+ canGoForward,
+ addToHistory,
+ goBack,
+ goForward,
+ resetHistory,
+ } = useImageHistory();
+
+ // Hide the native image canvas once we have a generated image.
+ useEffect( () => {
+ if ( ! imagePanel ) {
+ return;
+ }
+ const hasGeneratedImage = historyIndex >= 0;
+ imagePanel.style.display = hasGeneratedImage ? 'none' : '';
+ return () => {
+ imagePanel.style.display = '';
+ };
+ }, [ historyIndex, imagePanel ] );
+
+ /**
+ * Generates an AI-refined version of the image.
+ *
+ * When `referenceOverride` is provided it is used directly as the
+ * reference image. Otherwise the attachment URL is fetched and
+ * converted to a data URI.
+ *
+ * @param {string} activePrompt Prompt to use for generation.
+ * @param {string|undefined} referenceOverride Data URI to use as reference; omit for fresh edits.
+ * @param {boolean} isRefinement True when refining a previously generated image.
+ * @param {number|undefined} refHistoryIndex History index of the entry whose image is the reference.
+ */
+ async function handleGenerate(
+ activePrompt: string = prompt.trim(),
+ referenceOverride?: string,
+ isRefinement: boolean = false,
+ refHistoryIndex?: number
+ ): Promise< void > {
+ setError( null );
+ setState( 'generating' );
+
+ try {
+ const reference =
+ referenceOverride ?? ( await urlToBase64( attachmentUrl ) );
+
+ const input: ImageGenerationAbilityInput = {
+ prompt: activePrompt,
+ reference,
+ };
+
+ const response = ( await runAbility(
+ 'ai/image-generation',
+ input
+ ) ) as GeneratedImageData;
+
+ if ( ! response?.image ) {
+ throw new Error(
+ __( 'Invalid response from image generation.', 'ai' )
+ );
+ }
+
+ const prevData = activeEntry?.generatedData;
+ const previousPrompts = referenceOverride
+ ? prevData?.prompts ??
+ ( prevData?.prompt ? [ prevData.prompt ] : [] )
+ : [];
+ const promptHistory = previousPrompts.filter( Boolean );
+ const lastPrompt = promptHistory[ promptHistory.length - 1 ];
+ const prompts =
+ lastPrompt === activePrompt
+ ? promptHistory
+ : [ ...promptHistory, activePrompt ];
+
+ addToHistory(
+ { ...response, prompt: activePrompt, prompts },
+ referenceOverride,
+ isRefinement,
+ refHistoryIndex
+ );
+
+ setSavedUpload( null );
+ setState( 'preview' );
+ } catch ( err: any ) {
+ setError(
+ err?.message ||
+ __( 'An error occurred during image generation.', 'ai' )
+ );
+ // Return to whichever state triggered the generation.
+ setState( isRefinement ? 'refining' : 'idle' );
+ }
+ }
+
+ /**
+ * Saves the active generated image to the Media Library.
+ */
+ async function handleSave(): Promise< void > {
+ if ( ! activeEntry ) {
+ return;
+ }
+
+ setError( null );
+ setState( 'saving' );
+
+ try {
+ const uploaded = await uploadImage( activeEntry.generatedData );
+ setSavedUpload( uploaded );
+ setState( 'preview' );
+ } catch ( err: any ) {
+ setError( err?.message || __( 'Failed to save image.', 'ai' ) );
+ setState( 'preview' );
+ }
+ }
+
+ /**
+ * Resets the panel back to the idle state.
+ */
+ function handleReset(): void {
+ resetHistory();
+ setSavedUpload( null );
+ setPrompt( '' );
+ setRefinePrompt( '' );
+ setError( null );
+ setState( 'idle' );
+ }
+
+ const previewSrc = activeEntry?.generatedData?.image?.data
+ ? `data:image/png;base64,${ activeEntry.generatedData.image.data }`
+ : null;
+
+ // Left comparison image = the reference used to generate the active entry,
+ // falling back to the original attachment URL.
+ const comparisonLeftSrc = activeEntry?.referenceSrc ?? attachmentUrl;
+ const comparisonLeftLabel =
+ activeEntry?.referenceHistoryIndex === undefined
+ ? __( 'Original image', 'ai' )
+ : sprintf(
+ /* translators: %d: version number */
+ __( 'Version %d', 'ai' ),
+ activeEntry.referenceHistoryIndex + 1
+ );
+ const comparisonRightLabel = sprintf(
+ /* translators: %d: version number */
+ __( 'Version %d', 'ai' ),
+ historyIndex + 1
+ );
+
+ const [ showPrompt, setShowPrompt ] = useState( false );
+
+ return (
+
+ { state === 'idle' && (
+
+
+ }
+ onClick={ () =>
+ setShowPrompt( ( show ) => ! show )
+ }
+ >
+ { __( 'Refine Image', 'ai' ) }
+
+ { PRESETS.map( ( preset ) => (
+
+ ) ) }
+
+ { showPrompt && (
+ <>
+
+
+
+
+ >
+ ) }
+ { error && (
+
+ { error }
+
+ ) }
+
+ ) }
+
+ { state === 'generating' && (
+
+ { previewSrc && (
+

+ ) }
+
+
+ { __( 'Generating image…', 'ai' ) }
+
+
+ ) }
+
+ { state === 'preview' && previewSrc && (
+
+ { savedUpload && (
+
setSavedUpload( null ) }
+ >
+ { __( 'Image saved!', 'ai' ) }{ ' ' }
+
+ { __( 'View new image', 'ai' ) }
+
+
+ ) }
+
+
+
+
+
+
+ { comparisonLeftLabel }
+
+

+
+
+
+ { comparisonRightLabel }
+
+

+
+
+
+
+
+ { history.length > 1 && (
+
+ { sprintf(
+ /* translators: 1: current position, 2: total count */
+ __( '%1$d / %2$d', 'ai' ),
+ historyIndex + 1,
+ history.length
+ ) }
+
+ ) }
+
+
+
+
+
+
+ { error && (
+
+ { error }
+
+ ) }
+
+ ) }
+
+ { state === 'refining' && previewSrc && (
+
+

+
+ { PRESETS.map( ( preset ) => (
+
+ ) ) }
+
+
+
+
+
+
+ { error && (
+
+ { error }
+
+ ) }
+
+ ) }
+
+ { state === 'saving' && (
+
+
+
+ { __( 'Saving to Media Library…', 'ai' ) }
+
+
+ ) }
+
+ );
+}
diff --git a/src/experiments/image-generation/hooks/useImageHistory.ts b/src/experiments/image-generation/hooks/useImageHistory.ts
new file mode 100644
index 00000000..6cfa5589
--- /dev/null
+++ b/src/experiments/image-generation/hooks/useImageHistory.ts
@@ -0,0 +1,124 @@
+/**
+ * Custom hook for managing image generation history.
+ */
+
+/**
+ * WordPress dependencies
+ */
+import { useReducer, useCallback } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import type { GeneratedImageData, HistoryEntry } from '../types';
+
+interface UseImageHistoryReturn {
+ history: HistoryEntry[];
+ historyIndex: number;
+ activeEntry: HistoryEntry | null;
+ canGoBack: boolean;
+ canGoForward: boolean;
+ addToHistory: (
+ data: GeneratedImageData,
+ referenceSrc?: string,
+ isRefinement?: boolean,
+ referenceHistoryIndex?: number
+ ) => void;
+ goBack: () => void;
+ goForward: () => void;
+ resetHistory: () => void;
+}
+
+type HistoryState = {
+ history: HistoryEntry[];
+ historyIndex: number;
+};
+
+type HistoryAction =
+ | { type: 'ADD'; entry: HistoryEntry }
+ | { type: 'GO_BACK' }
+ | { type: 'GO_FORWARD' }
+ | { type: 'RESET' };
+
+function historyReducer(
+ state: HistoryState,
+ action: HistoryAction
+): HistoryState {
+ switch ( action.type ) {
+ case 'ADD':
+ return {
+ history: [ ...state.history, action.entry ],
+ historyIndex: state.history.length,
+ };
+ case 'GO_BACK':
+ return {
+ ...state,
+ historyIndex: Math.max( 0, state.historyIndex - 1 ),
+ };
+ case 'GO_FORWARD':
+ return {
+ ...state,
+ historyIndex: Math.min(
+ state.history.length - 1,
+ state.historyIndex + 1
+ ),
+ };
+ case 'RESET':
+ return { history: [], historyIndex: -1 };
+ }
+}
+
+/**
+ * Manages image generation history with navigation support.
+ *
+ * @return {UseImageHistoryReturn} History state and navigation helpers.
+ */
+export function useImageHistory(): UseImageHistoryReturn {
+ const [ { history, historyIndex }, dispatch ] = useReducer(
+ historyReducer,
+ { history: [], historyIndex: -1 }
+ );
+
+ const activeEntry =
+ historyIndex >= 0 ? history[ historyIndex ] ?? null : null;
+ const canGoBack = historyIndex > 0;
+ const canGoForward = historyIndex < history.length - 1;
+
+ const addToHistory = useCallback(
+ (
+ data: GeneratedImageData,
+ referenceSrc?: string,
+ isRefinement: boolean = false,
+ referenceHistoryIndex?: number
+ ) => {
+ const entry: HistoryEntry = { generatedData: data, isRefinement };
+ if ( referenceSrc !== undefined ) {
+ entry.referenceSrc = referenceSrc;
+ }
+ if ( referenceHistoryIndex !== undefined ) {
+ entry.referenceHistoryIndex = referenceHistoryIndex;
+ }
+ dispatch( { type: 'ADD', entry } );
+ },
+ []
+ );
+
+ const goBack = useCallback( () => dispatch( { type: 'GO_BACK' } ), [] );
+ const goForward = useCallback(
+ () => dispatch( { type: 'GO_FORWARD' } ),
+ []
+ );
+ const resetHistory = useCallback( () => dispatch( { type: 'RESET' } ), [] );
+
+ return {
+ history,
+ historyIndex,
+ activeEntry,
+ canGoBack,
+ canGoForward,
+ addToHistory,
+ goBack,
+ goForward,
+ resetHistory,
+ };
+}
diff --git a/src/experiments/image-generation/index.scss b/src/experiments/image-generation/index.scss
index e0bcb85e..21ec806e 100644
--- a/src/experiments/image-generation/index.scss
+++ b/src/experiments/image-generation/index.scss
@@ -1,3 +1,37 @@
+// Shared history navigation wrapper — three-column grid: arrow | content | arrow.
+.ai-image-history-nav {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: 4px;
+
+ &__arrow {
+ align-self: center;
+
+ &:disabled {
+ opacity: 0.3;
+ }
+ }
+
+ &__content {
+ min-width: 0;
+ }
+
+ img.is-active {
+ outline: 2px solid #3858e9;
+ outline-offset: 3px;
+ border-radius: 2px;
+ }
+
+ &__counter {
+ text-align: center;
+ font-size: 12px;
+ color: #757575;
+ font-variant-numeric: tabular-nums;
+ margin: 4px 0 0;
+ }
+}
+
.ai-generate-image-standalone {
max-width: 950px;
@@ -60,6 +94,84 @@
}
}
+.ai-media-library-editor-btn-slot {
+
+ .ai-media-library-editor__toggle-btn {
+ &:before {
+ content: '\f327';
+ }
+
+ &.active {
+ background: #f0f0f1;
+ border-color: #8c8f94;
+ box-shadow: inset 0 2px 5px -3px rgba(0, 0, 0, 0.5);
+ }
+ }
+}
+
+.ai-media-library-editor {
+ padding-top: 0 !important;
+ max-width: 1050px;
+ min-width: 600px;
+
+ .components-notice {
+ margin-top: 10px;
+ }
+
+ &__presets {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 20px;
+ }
+
+ &__actions {
+ display: flex;
+ gap: 10px;
+ margin-top: 15px;
+ }
+
+ &__spinner-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+
+ span {
+ color: #757575;
+ }
+ }
+
+ &__comparison {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+ }
+
+ &__comparison-label {
+ font-weight: 600;
+ margin: 0 0 8px;
+ }
+
+ &__preview-image {
+ max-width: 100%;
+ height: auto;
+ display: block;
+ margin-bottom: 15px;
+ }
+
+ &__generating {
+ .ai-media-library-editor__preview-image {
+ opacity: 0.5;
+ }
+ }
+
+ &__refining {
+ .ai-media-library-editor__presets {
+ margin-top: 12px;
+ }
+ }
+}
+
.ai-generate-image-inline-modal {
.components-notice {
margin-top: 15px;
diff --git a/src/experiments/image-generation/index.ts b/src/experiments/image-generation/index.ts
index 320ac433..7fe2ea6f 100644
--- a/src/experiments/image-generation/index.ts
+++ b/src/experiments/image-generation/index.ts
@@ -7,4 +7,5 @@
*/
import './featured-image';
import './media-library';
+import './media-library-editor';
import './inline';
diff --git a/src/experiments/image-generation/media-library-editor.ts b/src/experiments/image-generation/media-library-editor.ts
new file mode 100644
index 00000000..d2b25c15
--- /dev/null
+++ b/src/experiments/image-generation/media-library-editor.ts
@@ -0,0 +1,182 @@
+/**
+ * Media Library Image Editor — AI panel injection.
+ *
+ * Uses a MutationObserver to detect when the WordPress image editor opens then
+ * mounts the MediaLibraryImageEditor React component in a panel between the
+ * toolbar row and the image canvas. Unmounts and removes the panel when the
+ * editor closes.
+ */
+
+/**
+ * WordPress dependencies
+ */
+import { createRoot, createElement } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { MediaLibraryImageEditor } from './components/MediaLibraryImageEditor';
+
+let currentRoot: ReturnType< typeof createRoot > | null = null;
+let currentContainer: HTMLElement | null = null;
+
+/**
+ * Extracts the attachment post ID from the imgedit-panel element ID.
+ *
+ * The native WordPress image editor injects ``.
+ *
+ * @param {Element} imgeditWrap The `.imgedit-wrap` element.
+ * @return {number|null} The post ID, or null if not found.
+ */
+function getPostIdFromWrap( imgeditWrap: Element ): number | null {
+ const panel = imgeditWrap.querySelector( '[id^="imgedit-panel-"]' );
+
+ if ( ! panel ) {
+ return null;
+ }
+
+ const match = panel.id.match( /imgedit-panel-(\d+)/ );
+ return match?.[ 1 ] ? parseInt( match[ 1 ], 10 ) : null;
+}
+
+/**
+ * Retrieves the full-size URL for an attachment using the wp.media API.
+ *
+ * @param {number} postId The attachment post ID.
+ * @return {Promise} The attachment URL, or null if unavailable.
+ */
+async function getAttachmentUrl( postId: number ): Promise< string | null > {
+ const wp = ( window as any ).wp;
+
+ if ( ! wp?.media?.attachment ) {
+ return null;
+ }
+
+ const attachment = wp.media.attachment( postId );
+
+ // The URL may already be cached; fetch only if needed.
+ if ( ! attachment.get( 'url' ) ) {
+ await new Promise< void >( ( resolve ) => {
+ attachment.fetch( { success: resolve, error: resolve } );
+ } );
+ }
+
+ return attachment.get( 'url' ) ?? null;
+}
+
+/**
+ * Mounts the AI editing UI into the WordPress image editor.
+ *
+ * Appends a button slot to the `.imgedit-menu` toolbar and inserts a panel
+ * container between the toolbar row and the image canvas row.
+ *
+ * @param {Element} imgeditWrap The `.imgedit-wrap` element that just appeared.
+ */
+async function mountPanel( imgeditWrap: Element ): Promise< void > {
+ // Unmount any existing panel first.
+ unmountPanel();
+
+ const postId = getPostIdFromWrap( imgeditWrap );
+ if ( ! postId ) {
+ return;
+ }
+
+ const attachmentUrl = await getAttachmentUrl( postId );
+ if ( ! attachmentUrl ) {
+ return;
+ }
+
+ // Query the image canvas + settings row.
+ const imagePanel = imgeditWrap.querySelector< HTMLElement >(
+ '.imgedit-panel-content:not(.imgedit-panel-tools)'
+ );
+
+ // Inject panel container between the toolbar row and the image canvas row.
+ const toolbarRow = imgeditWrap.querySelector(
+ '.imgedit-panel-content.imgedit-panel-tools'
+ );
+
+ currentContainer = document.createElement( 'div' );
+ currentContainer.className = 'ai-media-library-editor-root';
+
+ if ( toolbarRow ) {
+ toolbarRow.insertAdjacentElement( 'afterend', currentContainer );
+ } else {
+ // Fallback: append after imgedit-wrap.
+ imgeditWrap.parentElement?.insertBefore(
+ currentContainer,
+ imgeditWrap.nextSibling
+ );
+ }
+
+ const props = {
+ postId,
+ attachmentUrl,
+ ...( imagePanel ? { imagePanel } : {} ),
+ };
+
+ currentRoot = createRoot( currentContainer );
+ currentRoot.render( createElement( MediaLibraryImageEditor, props ) );
+}
+
+/**
+ * Unmounts the AI panel, removes the panel container, and removes the
+ * button slot from the toolbar.
+ */
+function unmountPanel(): void {
+ if ( currentRoot ) {
+ currentRoot.unmount();
+ currentRoot = null;
+ }
+
+ if ( currentContainer ) {
+ currentContainer.remove();
+ currentContainer = null;
+ }
+}
+
+/**
+ * Starts observing the document body for the image editor appearing/disappearing.
+ */
+function observeImageEditor(): void {
+ const observer = new MutationObserver( ( mutations ) => {
+ for ( const mutation of mutations ) {
+ // Check added nodes for .imgedit-wrap.
+ for ( const node of Array.from( mutation.addedNodes ) ) {
+ if ( ! ( node instanceof Element ) ) {
+ continue;
+ }
+
+ // The node itself may be .imgedit-wrap, or it may contain one.
+ const wrap = node.classList.contains( 'imgedit-wrap' )
+ ? node
+ : node.querySelector( '.imgedit-wrap' );
+
+ if ( wrap ) {
+ mountPanel( wrap );
+ return;
+ }
+ }
+
+ // Check removed nodes — unmount if .imgedit-wrap was removed.
+ for ( const node of Array.from( mutation.removedNodes ) ) {
+ if ( ! ( node instanceof Element ) ) {
+ continue;
+ }
+
+ const hadWrap =
+ node.classList.contains( 'imgedit-wrap' ) ||
+ node.querySelector( '.imgedit-wrap' );
+
+ if ( hadWrap && currentRoot ) {
+ unmountPanel();
+ return;
+ }
+ }
+ }
+ } );
+
+ observer.observe( document.body, { childList: true, subtree: true } );
+}
+
+document.addEventListener( 'DOMContentLoaded', observeImageEditor );
diff --git a/src/experiments/image-generation/types.ts b/src/experiments/image-generation/types.ts
index c570ef2d..b0600675 100644
--- a/src/experiments/image-generation/types.ts
+++ b/src/experiments/image-generation/types.ts
@@ -121,3 +121,16 @@ export interface GetPostDetailsAbilityInput {
* Callback type for image generation progress messages.
*/
export type ImageProgressCallback = ( message: string ) => void;
+
+/**
+ * A single entry in the image generation history.
+ */
+export interface HistoryEntry {
+ generatedData: GeneratedImageData;
+ /** Data URI used as reference input; undefined for fresh text-prompt generations. */
+ referenceSrc?: string;
+ /** History index of the entry whose image was used as the reference. */
+ referenceHistoryIndex?: number;
+ /** True when this entry was generated by refining a previously generated image. */
+ isRefinement: boolean;
+}
diff --git a/src/utils/image.ts b/src/utils/image.ts
new file mode 100644
index 00000000..6a8918db
--- /dev/null
+++ b/src/utils/image.ts
@@ -0,0 +1,84 @@
+/**
+ * Collection of image utilities.
+ */
+
+/**
+ * Fetches an image from a URL and returns it as a base64 data URI
+ * (e.g. `data:image/jpeg;base64,...`).
+ *
+ * @param {string} url The URL of the image to convert.
+ * @return {Promise} Base64 data URI string.
+ */
+export async function urlToBase64( url: string ): Promise< string > {
+ const response = await fetch( url );
+ const blob = await response.blob();
+ const mimeType = blob.type || 'image/jpeg';
+ const buffer = await blob.arrayBuffer();
+ const bytes = new Uint8Array( buffer );
+ let binary = '';
+
+ for ( let i = 0; i < bytes.byteLength; i++ ) {
+ binary += String.fromCharCode( bytes[ i ] as number );
+ }
+
+ return `data:${ mimeType };base64,${ btoa( binary ) }`;
+}
+
+/**
+ * Loads an image URL onto a larger canvas (with transparent borders) and
+ * returns the result as a PNG data URI. The original image is centered on the
+ * new canvas. The transparent borders act as an implicit mask so image-editing
+ * models know which areas to fill.
+ *
+ * @param {string} url URL of the source image. Must be CORS-accessible.
+ * @param {number} scale Factor by which to multiply each dimension (default 1.5).
+ * @return {Promise} PNG data URI of the expanded canvas.
+ */
+export async function prepareExpandCanvas(
+ url: string,
+ scale: number = 1.5
+): Promise< string > {
+ const MAX_DIMENSION = 4096;
+
+ const img = await new Promise< HTMLImageElement >( ( resolve, reject ) => {
+ const el = new Image();
+ el.crossOrigin = 'anonymous';
+ el.onload = () => resolve( el );
+ el.onerror = () =>
+ reject( new Error( `Failed to load image: ${ url }` ) );
+ el.src = url;
+ } );
+
+ const srcW = img.naturalWidth;
+ const srcH = img.naturalHeight;
+
+ const rawW = Math.round( srcW * scale );
+ const rawH = Math.round( srcH * scale );
+
+ // Cap both dimensions at MAX_DIMENSION while preserving aspect ratio.
+ const capScale = Math.min( 1, MAX_DIMENSION / rawW, MAX_DIMENSION / rawH );
+ const canvasW = Math.round( rawW * capScale );
+ const canvasH = Math.round( rawH * capScale );
+
+ // Scale the original image to fit the new canvas proportionally.
+ const imgScale = capScale * scale; // combined scaling from src to canvas slot
+ const slotW = Math.round( srcW * imgScale );
+ const slotH = Math.round( srcH * imgScale );
+
+ const offsetX = Math.round( ( canvasW - slotW ) / 2 );
+ const offsetY = Math.round( ( canvasH - slotH ) / 2 );
+
+ const canvas = document.createElement( 'canvas' );
+ canvas.width = canvasW;
+ canvas.height = canvasH;
+
+ const ctx = canvas.getContext( '2d' );
+ if ( ! ctx ) {
+ throw new Error( 'Could not get 2D canvas context.' );
+ }
+
+ // Leave canvas transparent (default) — transparent borders are the mask.
+ ctx.drawImage( img, offsetX, offsetY, slotW, slotH );
+
+ return canvas.toDataURL( 'image/png' );
+}
diff --git a/tests/Integration/Includes/Abilities/Image_GenerationTest.php b/tests/Integration/Includes/Abilities/Image_GenerationTest.php
index 9eced9c2..99dea7ee 100644
--- a/tests/Integration/Includes/Abilities/Image_GenerationTest.php
+++ b/tests/Integration/Includes/Abilities/Image_GenerationTest.php
@@ -31,7 +31,7 @@ public static function get_id(): string {
protected function load_metadata(): array {
return array(
'label' => 'Image Generation and Editing',
- 'description' => 'Generate and edit featured images and inline images with AI',
+ 'description' => 'Generate and edit images using AI',
);
}
diff --git a/tests/bin/add-mocking-plugin.js b/tests/bin/add-mocking-plugin.js
index 4ea43610..9f6c37d3 100644
--- a/tests/bin/add-mocking-plugin.js
+++ b/tests/bin/add-mocking-plugin.js
@@ -12,6 +12,7 @@ const config = fs.existsSync( path ) ? require( path ) : {};
config.plugins = [
'.',
+ 'https://downloads.wordpress.org/plugin/ai-provider-for-google.zip',
'https://downloads.wordpress.org/plugin/ai-provider-for-openai.zip',
'./tests/e2e-request-mocking',
];
diff --git a/tests/e2e-request-mocking/e2e-request-mocking.php b/tests/e2e-request-mocking/e2e-request-mocking.php
index c4a78aa0..f6009ab9 100644
--- a/tests/e2e-request-mocking/e2e-request-mocking.php
+++ b/tests/e2e-request-mocking/e2e-request-mocking.php
@@ -39,6 +39,29 @@ function ai_e2e_test_request_mocking( $preempt, $parsed_args, $url ) {
$response = file_get_contents( __DIR__ . '/responses/OpenAI/models.json' );
}
+ // Mock the Google models API response.
+ if ( str_contains( $url, 'https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000' ) ) {
+ // Handle invalid API key.
+ if (
+ isset( $parsed_args['headers']['X-Goog-Api-Key'] ) &&
+ str_contains( $parsed_args['headers']['X-Goog-Api-Key'], 'invalid-api-key' )
+ ) {
+ return $preempt;
+ }
+
+ $response = file_get_contents( __DIR__ . '/responses/Google/models.json' );
+ }
+
+ // Mock the Google Imagen API response.
+ if ( str_contains( $url, 'https://generativelanguage.googleapis.com/v1beta/models/imagen-4.0-generate-001:predict' ) ) {
+ $response = file_get_contents( __DIR__ . '/responses/Google/imagen.json' );
+ }
+
+ // Mock the Google Gemini image API response.
+ if ( str_contains( $url, 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-image-preview:generateContent' ) ) {
+ $response = file_get_contents( __DIR__ . '/responses/Google/gemini-image.json' );
+ }
+
// Mock the OpenAI responses API response.
if ( str_contains( $url, 'https://api.openai.com/v1/responses' ) ) {
$body = $parsed_args['body'] ?? '';
diff --git a/tests/e2e-request-mocking/responses/Google/gemini-image.json b/tests/e2e-request-mocking/responses/Google/gemini-image.json
new file mode 100644
index 00000000..2ca26cb4
--- /dev/null
+++ b/tests/e2e-request-mocking/responses/Google/gemini-image.json
@@ -0,0 +1,38 @@
+{
+ "candidates": [
+ {
+ "content": {
+ "parts": [
+ {
+ "inlineData": {
+ "mimeType": "image/png",
+ "data": "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII="
+ }
+ }
+ ],
+ "role": "model"
+ },
+ "finishReason": "STOP",
+ "index": 0
+ }
+ ],
+ "usageMetadata": {
+ "promptTokenCount": 6,
+ "candidatesTokenCount": 1259,
+ "totalTokenCount": 1265,
+ "promptTokensDetails": [
+ {
+ "modality": "TEXT",
+ "tokenCount": 6
+ }
+ ],
+ "candidatesTokensDetails": [
+ {
+ "modality": "IMAGE",
+ "tokenCount": 1120
+ }
+ ]
+ },
+ "modelVersion": "gemini-3.1-flash-image-preview",
+ "responseId": "mzSzaZL3LO2r_PUP2bfGkAE"
+}
diff --git a/tests/e2e-request-mocking/responses/Google/imagen.json b/tests/e2e-request-mocking/responses/Google/imagen.json
new file mode 100644
index 00000000..e33abbe1
--- /dev/null
+++ b/tests/e2e-request-mocking/responses/Google/imagen.json
@@ -0,0 +1,8 @@
+{
+ "predictions": [
+ {
+ "mimeType": "image/png",
+ "bytesBase64Encoded": "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII="
+ }
+ ]
+}
diff --git a/tests/e2e-request-mocking/responses/Google/models.json b/tests/e2e-request-mocking/responses/Google/models.json
new file mode 100644
index 00000000..95dc73f1
--- /dev/null
+++ b/tests/e2e-request-mocking/responses/Google/models.json
@@ -0,0 +1,724 @@
+{
+ "models": [
+ {
+ "name": "models/gemini-2.5-flash",
+ "version": "001",
+ "displayName": "Gemini 2.5 Flash",
+ "description": "Stable version of Gemini 2.5 Flash, our mid-size multimodal model that supports up to 1 million tokens, released in June of 2025.",
+ "inputTokenLimit": 1048576,
+ "outputTokenLimit": 65536,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens",
+ "createCachedContent",
+ "batchGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 2,
+ "thinking": true
+ },
+ {
+ "name": "models/gemini-2.5-pro",
+ "version": "2.5",
+ "displayName": "Gemini 2.5 Pro",
+ "description": "Stable release (June 17th, 2025) of Gemini 2.5 Pro",
+ "inputTokenLimit": 1048576,
+ "outputTokenLimit": 65536,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens",
+ "createCachedContent",
+ "batchGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 2,
+ "thinking": true
+ },
+ {
+ "name": "models/gemini-2.0-flash",
+ "version": "2.0",
+ "displayName": "Gemini 2.0 Flash",
+ "description": "Gemini 2.0 Flash",
+ "inputTokenLimit": 1048576,
+ "outputTokenLimit": 8192,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens",
+ "createCachedContent",
+ "batchGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 40,
+ "maxTemperature": 2
+ },
+ {
+ "name": "models/gemini-2.0-flash-001",
+ "version": "2.0",
+ "displayName": "Gemini 2.0 Flash 001",
+ "description": "Stable version of Gemini 2.0 Flash, our fast and versatile multimodal model for scaling across diverse tasks, released in January of 2025.",
+ "inputTokenLimit": 1048576,
+ "outputTokenLimit": 8192,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens",
+ "createCachedContent",
+ "batchGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 40,
+ "maxTemperature": 2
+ },
+ {
+ "name": "models/gemini-2.0-flash-lite-001",
+ "version": "2.0",
+ "displayName": "Gemini 2.0 Flash-Lite 001",
+ "description": "Stable version of Gemini 2.0 Flash-Lite",
+ "inputTokenLimit": 1048576,
+ "outputTokenLimit": 8192,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens",
+ "createCachedContent",
+ "batchGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 40,
+ "maxTemperature": 2
+ },
+ {
+ "name": "models/gemini-2.0-flash-lite",
+ "version": "2.0",
+ "displayName": "Gemini 2.0 Flash-Lite",
+ "description": "Gemini 2.0 Flash-Lite",
+ "inputTokenLimit": 1048576,
+ "outputTokenLimit": 8192,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens",
+ "createCachedContent",
+ "batchGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 40,
+ "maxTemperature": 2
+ },
+ {
+ "name": "models/gemini-2.5-flash-preview-tts",
+ "version": "gemini-2.5-flash-exp-tts-2025-05-19",
+ "displayName": "Gemini 2.5 Flash Preview TTS",
+ "description": "Gemini 2.5 Flash Preview TTS",
+ "inputTokenLimit": 8192,
+ "outputTokenLimit": 16384,
+ "supportedGenerationMethods": [
+ "countTokens",
+ "generateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 2
+ },
+ {
+ "name": "models/gemini-2.5-pro-preview-tts",
+ "version": "gemini-2.5-pro-preview-tts-2025-05-19",
+ "displayName": "Gemini 2.5 Pro Preview TTS",
+ "description": "Gemini 2.5 Pro Preview TTS",
+ "inputTokenLimit": 8192,
+ "outputTokenLimit": 16384,
+ "supportedGenerationMethods": [
+ "countTokens",
+ "generateContent",
+ "batchGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 2
+ },
+ {
+ "name": "models/gemma-3-1b-it",
+ "version": "001",
+ "displayName": "Gemma 3 1B",
+ "inputTokenLimit": 32768,
+ "outputTokenLimit": 8192,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64
+ },
+ {
+ "name": "models/gemma-3-4b-it",
+ "version": "001",
+ "displayName": "Gemma 3 4B",
+ "inputTokenLimit": 32768,
+ "outputTokenLimit": 8192,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64
+ },
+ {
+ "name": "models/gemma-3-12b-it",
+ "version": "001",
+ "displayName": "Gemma 3 12B",
+ "inputTokenLimit": 32768,
+ "outputTokenLimit": 8192,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64
+ },
+ {
+ "name": "models/gemma-3-27b-it",
+ "version": "001",
+ "displayName": "Gemma 3 27B",
+ "inputTokenLimit": 131072,
+ "outputTokenLimit": 8192,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64
+ },
+ {
+ "name": "models/gemma-3n-e4b-it",
+ "version": "001",
+ "displayName": "Gemma 3n E4B",
+ "inputTokenLimit": 8192,
+ "outputTokenLimit": 2048,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64
+ },
+ {
+ "name": "models/gemma-3n-e2b-it",
+ "version": "001",
+ "displayName": "Gemma 3n E2B",
+ "inputTokenLimit": 8192,
+ "outputTokenLimit": 2048,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64
+ },
+ {
+ "name": "models/gemini-flash-latest",
+ "version": "Gemini Flash Latest",
+ "displayName": "Gemini Flash Latest",
+ "description": "Latest release of Gemini Flash",
+ "inputTokenLimit": 1048576,
+ "outputTokenLimit": 65536,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens",
+ "createCachedContent",
+ "batchGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 2,
+ "thinking": true
+ },
+ {
+ "name": "models/gemini-flash-lite-latest",
+ "version": "Gemini Flash-Lite Latest",
+ "displayName": "Gemini Flash-Lite Latest",
+ "description": "Latest release of Gemini Flash-Lite",
+ "inputTokenLimit": 1048576,
+ "outputTokenLimit": 65536,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens",
+ "createCachedContent",
+ "batchGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 2,
+ "thinking": true
+ },
+ {
+ "name": "models/gemini-pro-latest",
+ "version": "Gemini Pro Latest",
+ "displayName": "Gemini Pro Latest",
+ "description": "Latest release of Gemini Pro",
+ "inputTokenLimit": 1048576,
+ "outputTokenLimit": 65536,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens",
+ "createCachedContent",
+ "batchGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 2,
+ "thinking": true
+ },
+ {
+ "name": "models/gemini-2.5-flash-lite",
+ "version": "001",
+ "displayName": "Gemini 2.5 Flash-Lite",
+ "description": "Stable version of Gemini 2.5 Flash-Lite, released in July of 2025",
+ "inputTokenLimit": 1048576,
+ "outputTokenLimit": 65536,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens",
+ "createCachedContent",
+ "batchGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 2,
+ "thinking": true
+ },
+ {
+ "name": "models/gemini-2.5-flash-image",
+ "version": "2.0",
+ "displayName": "Nano Banana",
+ "description": "Gemini 2.5 Flash Preview Image",
+ "inputTokenLimit": 32768,
+ "outputTokenLimit": 32768,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens",
+ "batchGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 1
+ },
+ {
+ "name": "models/gemini-2.5-flash-lite-preview-09-2025",
+ "version": "2.5-preview-09-25",
+ "displayName": "Gemini 2.5 Flash-Lite Preview Sep 2025",
+ "description": "Preview release (Septempber 25th, 2025) of Gemini 2.5 Flash-Lite",
+ "inputTokenLimit": 1048576,
+ "outputTokenLimit": 65536,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens",
+ "createCachedContent",
+ "batchGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 2,
+ "thinking": true
+ },
+ {
+ "name": "models/gemini-3-pro-preview",
+ "version": "3-pro-preview-11-2025",
+ "displayName": "Gemini 3 Pro Preview",
+ "description": "Gemini 3 Pro Preview",
+ "inputTokenLimit": 1048576,
+ "outputTokenLimit": 65536,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens",
+ "createCachedContent",
+ "batchGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 2,
+ "thinking": true
+ },
+ {
+ "name": "models/gemini-3-flash-preview",
+ "version": "3-flash-preview-12-2025",
+ "displayName": "Gemini 3 Flash Preview",
+ "description": "Gemini 3 Flash Preview",
+ "inputTokenLimit": 1048576,
+ "outputTokenLimit": 65536,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens",
+ "createCachedContent",
+ "batchGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 2,
+ "thinking": true
+ },
+ {
+ "name": "models/gemini-3.1-pro-preview",
+ "version": "3.1-pro-preview-01-2026",
+ "displayName": "Gemini 3.1 Pro Preview",
+ "description": "Gemini 3.1 Pro Preview",
+ "inputTokenLimit": 1048576,
+ "outputTokenLimit": 65536,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens",
+ "createCachedContent",
+ "batchGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 2,
+ "thinking": true
+ },
+ {
+ "name": "models/gemini-3.1-pro-preview-customtools",
+ "version": "3.1-pro-preview-01-2026",
+ "displayName": "Gemini 3.1 Pro Preview Custom Tools",
+ "description": "Gemini 3.1 Pro Preview optimized for custom tool usage",
+ "inputTokenLimit": 1048576,
+ "outputTokenLimit": 65536,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens",
+ "createCachedContent",
+ "batchGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 2,
+ "thinking": true
+ },
+ {
+ "name": "models/gemini-3.1-flash-lite-preview",
+ "version": "3.1-flash-lite-preview-03-2026",
+ "displayName": "Gemini 3.1 Flash Lite Preview",
+ "description": "Gemini 3.1 Flash Lite Preview",
+ "inputTokenLimit": 1048576,
+ "outputTokenLimit": 65536,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens",
+ "createCachedContent",
+ "batchGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 2,
+ "thinking": true
+ },
+ {
+ "name": "models/gemini-3-pro-image-preview",
+ "version": "3.0",
+ "displayName": "Nano Banana Pro",
+ "description": "Gemini 3 Pro Image Preview",
+ "inputTokenLimit": 131072,
+ "outputTokenLimit": 32768,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens",
+ "batchGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 1,
+ "thinking": true
+ },
+ {
+ "name": "models/nano-banana-pro-preview",
+ "version": "3.0",
+ "displayName": "Nano Banana Pro",
+ "description": "Gemini 3 Pro Image Preview",
+ "inputTokenLimit": 131072,
+ "outputTokenLimit": 32768,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens",
+ "batchGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 1,
+ "thinking": true
+ },
+ {
+ "name": "models/gemini-3.1-flash-image-preview",
+ "version": "3.0",
+ "displayName": "Nano Banana 2",
+ "description": "Gemini 3.1 Flash Image Preview.",
+ "inputTokenLimit": 65536,
+ "outputTokenLimit": 65536,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens",
+ "batchGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 1,
+ "thinking": true
+ },
+ {
+ "name": "models/gemini-robotics-er-1.5-preview",
+ "version": "1.5-preview",
+ "displayName": "Gemini Robotics-ER 1.5 Preview",
+ "description": "Gemini Robotics-ER 1.5 Preview",
+ "inputTokenLimit": 1048576,
+ "outputTokenLimit": 65536,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 2,
+ "thinking": true
+ },
+ {
+ "name": "models/gemini-2.5-computer-use-preview-10-2025",
+ "version": "Gemini 2.5 Computer Use Preview 10-2025",
+ "displayName": "Gemini 2.5 Computer Use Preview 10-2025",
+ "description": "Gemini 2.5 Computer Use Preview 10-2025",
+ "inputTokenLimit": 131072,
+ "outputTokenLimit": 65536,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 2,
+ "thinking": true
+ },
+ {
+ "name": "models/deep-research-pro-preview-12-2025",
+ "version": "deepthink-exp-05-20",
+ "displayName": "Deep Research Pro Preview (Dec-12-2025)",
+ "description": "Preview release (December 12th, 2025) of Deep Research Pro",
+ "inputTokenLimit": 131072,
+ "outputTokenLimit": 65536,
+ "supportedGenerationMethods": [
+ "generateContent",
+ "countTokens"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 2,
+ "thinking": true
+ },
+ {
+ "name": "models/gemini-embedding-001",
+ "version": "001",
+ "displayName": "Gemini Embedding 001",
+ "description": "Obtain a distributed representation of a text.",
+ "inputTokenLimit": 2048,
+ "outputTokenLimit": 1,
+ "supportedGenerationMethods": [
+ "embedContent",
+ "countTextTokens",
+ "countTokens",
+ "asyncBatchEmbedContent"
+ ]
+ },
+ {
+ "name": "models/gemini-embedding-2-preview",
+ "version": "2",
+ "displayName": "Gemini Embedding 2 Preview",
+ "description": "Obtain a distributed representation of multimodal content.",
+ "inputTokenLimit": 8192,
+ "outputTokenLimit": 1,
+ "supportedGenerationMethods": [
+ "embedContent",
+ "countTextTokens",
+ "countTokens",
+ "asyncBatchEmbedContent"
+ ]
+ },
+ {
+ "name": "models/aqa",
+ "version": "001",
+ "displayName": "Model that performs Attributed Question Answering.",
+ "description": "Model trained to return answers to questions that are grounded in provided sources, along with estimating answerable probability.",
+ "inputTokenLimit": 7168,
+ "outputTokenLimit": 1024,
+ "supportedGenerationMethods": [
+ "generateAnswer"
+ ],
+ "temperature": 0.2,
+ "topP": 1,
+ "topK": 40
+ },
+ {
+ "name": "models/imagen-4.0-generate-001",
+ "version": "001",
+ "displayName": "Imagen 4",
+ "description": "Vertex served Imagen 4.0 model",
+ "inputTokenLimit": 480,
+ "outputTokenLimit": 8192,
+ "supportedGenerationMethods": [
+ "predict"
+ ]
+ },
+ {
+ "name": "models/imagen-4.0-ultra-generate-001",
+ "version": "001",
+ "displayName": "Imagen 4 Ultra",
+ "description": "Vertex served Imagen 4.0 ultra model",
+ "inputTokenLimit": 480,
+ "outputTokenLimit": 8192,
+ "supportedGenerationMethods": [
+ "predict"
+ ]
+ },
+ {
+ "name": "models/imagen-4.0-fast-generate-001",
+ "version": "001",
+ "displayName": "Imagen 4 Fast",
+ "description": "Vertex served Imagen 4.0 Fast model",
+ "inputTokenLimit": 480,
+ "outputTokenLimit": 8192,
+ "supportedGenerationMethods": [
+ "predict"
+ ]
+ },
+ {
+ "name": "models/veo-2.0-generate-001",
+ "version": "2.0",
+ "displayName": "Veo 2",
+ "description": "Vertex served Veo 2 model. Access to this model requires billing to be enabled on the associated Google Cloud Platform account. Please visit https://console.cloud.google.com/billing to enable it.",
+ "inputTokenLimit": 480,
+ "outputTokenLimit": 8192,
+ "supportedGenerationMethods": [
+ "predictLongRunning"
+ ]
+ },
+ {
+ "name": "models/veo-3.0-generate-001",
+ "version": "3.0",
+ "displayName": "Veo 3",
+ "description": "Veo 3",
+ "inputTokenLimit": 480,
+ "outputTokenLimit": 8192,
+ "supportedGenerationMethods": [
+ "predictLongRunning"
+ ]
+ },
+ {
+ "name": "models/veo-3.0-fast-generate-001",
+ "version": "3.0",
+ "displayName": "Veo 3 fast",
+ "description": "Veo 3 fast",
+ "inputTokenLimit": 480,
+ "outputTokenLimit": 8192,
+ "supportedGenerationMethods": [
+ "predictLongRunning"
+ ]
+ },
+ {
+ "name": "models/veo-3.1-generate-preview",
+ "version": "3.1",
+ "displayName": "Veo 3.1",
+ "description": "Veo 3.1",
+ "inputTokenLimit": 480,
+ "outputTokenLimit": 8192,
+ "supportedGenerationMethods": [
+ "predictLongRunning"
+ ]
+ },
+ {
+ "name": "models/veo-3.1-fast-generate-preview",
+ "version": "3.1",
+ "displayName": "Veo 3.1 fast",
+ "description": "Veo 3.1 fast",
+ "inputTokenLimit": 480,
+ "outputTokenLimit": 8192,
+ "supportedGenerationMethods": [
+ "predictLongRunning"
+ ]
+ },
+ {
+ "name": "models/gemini-2.5-flash-native-audio-latest",
+ "version": "Gemini 2.5 Flash Native Audio Latest",
+ "displayName": "Gemini 2.5 Flash Native Audio Latest",
+ "description": "Latest release of Gemini 2.5 Flash Native Audio",
+ "inputTokenLimit": 131072,
+ "outputTokenLimit": 8192,
+ "supportedGenerationMethods": [
+ "countTokens",
+ "bidiGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 2,
+ "thinking": true
+ },
+ {
+ "name": "models/gemini-2.5-flash-native-audio-preview-09-2025",
+ "version": "gemini-2.5-flash-preview-native-audio-dialog-2025-05-19",
+ "displayName": "Gemini 2.5 Flash Native Audio Preview 09-2025",
+ "description": "Gemini 2.5 Flash Native Audio Preview 09-2025",
+ "inputTokenLimit": 131072,
+ "outputTokenLimit": 8192,
+ "supportedGenerationMethods": [
+ "countTokens",
+ "bidiGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 2,
+ "thinking": true
+ },
+ {
+ "name": "models/gemini-2.5-flash-native-audio-preview-12-2025",
+ "version": "12-2025",
+ "displayName": "Gemini 2.5 Flash Native Audio Preview 12-2025",
+ "description": "Gemini 2.5 Flash Native Audio Preview 12-2025",
+ "inputTokenLimit": 131072,
+ "outputTokenLimit": 8192,
+ "supportedGenerationMethods": [
+ "countTokens",
+ "bidiGenerateContent"
+ ],
+ "temperature": 1,
+ "topP": 0.95,
+ "topK": 64,
+ "maxTemperature": 2,
+ "thinking": true
+ }
+ ]
+}
diff --git a/tests/e2e/specs/experiments/image-editing.spec.js b/tests/e2e/specs/experiments/image-editing.spec.js
new file mode 100644
index 00000000..a7395f1c
--- /dev/null
+++ b/tests/e2e/specs/experiments/image-editing.spec.js
@@ -0,0 +1,595 @@
+/**
+ * External dependencies
+ */
+const path = require( 'path' );
+
+/**
+ * WordPress dependencies
+ */
+const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
+
+/**
+ * Internal dependencies
+ */
+const {
+ clearConnector,
+ enableExperiment,
+ enableExperiments,
+ visitAdminPage,
+ visitConnectorsPage,
+} = require( '../../utils/helpers' );
+
+// Path to a test image (1x1 PNG) used for media upload in E2E tests.
+const TEST_IMAGE_PATH = path.join( __dirname, '../../../data/sample.png' );
+
+test.describe( 'Image Editing Experiment', () => {
+ test( 'Can enable the image generation/editing experiment', async ( {
+ admin,
+ page,
+ } ) => {
+ // Globally turn on Experiments.
+ await enableExperiments( admin, page );
+
+ // Enable the Image Generation Experiment (which contains editing).
+ await enableExperiment( admin, page, 'image-generation' );
+
+ await visitConnectorsPage( admin );
+
+ // Add dummy credentials for Google.
+ await page
+ .locator( '.connector-item--ai-provider-for-google button' )
+ .click();
+ await page
+ .locator(
+ '.connector-item--ai-provider-for-google input[type="text"]'
+ )
+ .fill( 'valid-api-key' );
+
+ // Save the credentials.
+ await page
+ .locator(
+ '.connector-item--ai-provider-for-google .connector-settings button'
+ )
+ .click();
+ } );
+
+ test( 'Can refine an image within a block', async ( {
+ admin,
+ editor,
+ page,
+ } ) => {
+ // Globally turn on Experiments.
+ await enableExperiments( admin, page );
+
+ // Enable the Image Generation Experiment.
+ await enableExperiment( admin, page, 'image-generation' );
+
+ // Create a new post.
+ await admin.createNewPost( {
+ postType: 'post',
+ title: 'Test Inline Image Editing Experiment',
+ content:
+ 'This is some test content for the Image Editing Experiment.',
+ } );
+
+ // Save the post.
+ await editor.saveDraft();
+
+ // Insert a blank image block.
+ await editor.insertBlock( {
+ name: 'core/image',
+ } );
+
+ // Find the inline Generate Image button.
+ const generateImageButton = editor.canvas.locator(
+ '.wp-block-image button',
+ {
+ hasText: 'Generate Image',
+ }
+ );
+ await expect( generateImageButton ).toBeVisible();
+
+ // Click the generate image inline button.
+ await generateImageButton.click();
+
+ // Add a prompt and generate the image.
+ await page
+ .locator( '.ai-generate-image-inline-modal__idle textarea' )
+ .fill( 'A smiling face emoji' );
+ await page
+ .locator( '.ai-generate-image-inline-modal__idle button' )
+ .click();
+
+ // Ensure the Refine Image button is visible.
+ const refineButton = page.locator(
+ '.ai-generate-image-inline-modal__actions button',
+ {
+ hasText: 'Refine Image',
+ }
+ );
+ await expect( refineButton ).toBeVisible();
+
+ // Click the Refine Image button.
+ await refineButton.click();
+
+ // Ensure the modal is in the refining state.
+ await expect(
+ page.locator( '.ai-generate-image-inline-modal__refining' )
+ ).toBeVisible();
+
+ // Ensure the refine prompt textarea is visible.
+ await expect(
+ page.locator( '.ai-generate-image-inline-modal__refining textarea' )
+ ).toBeVisible();
+
+ // Add our refine prompt and generate the image.
+ await page
+ .locator( '.ai-generate-image-inline-modal__refining textarea' )
+ .fill( 'Add a red hat' );
+ await page
+ .locator( '.ai-generate-image-inline-modal__refining button' )
+ .filter( { hasText: 'Refine' } )
+ .first()
+ .click();
+
+ // Ensure the images are visible in the modal.
+ await expect(
+ page.locator( '.ai-generate-image-inline-modal__preview-image' )
+ ).toHaveCount( 2 );
+
+ // Click the previous image navigation arrow.
+ const previousBtn = page
+ .locator( '.ai-image-history-nav' )
+ .getByRole( 'button', {
+ name: 'Previous version',
+ } );
+ await previousBtn.click();
+
+ // Ensure the navigation shows correctly.
+ await expect(
+ page.locator( '.ai-image-history-nav__counter' )
+ ).toHaveText( '1 / 2' );
+
+ // Click the next image navigation arrow.
+ const nextBtn = page
+ .locator( '.ai-image-history-nav' )
+ .getByRole( 'button', {
+ name: 'Next version',
+ } );
+ await nextBtn.click();
+
+ // Ensure the navigation shows correctly.
+ await expect(
+ page.locator( '.ai-image-history-nav__counter' )
+ ).toHaveText( '2 / 2' );
+
+ const useImageButton = page.locator(
+ '.ai-generate-image-inline-modal__actions button',
+ {
+ hasText: 'Use Image',
+ }
+ );
+ await expect( useImageButton ).toBeVisible();
+
+ useImageButton.click();
+
+ // Ensure the image is inserted into the block.
+ await expect(
+ editor.canvas.locator( '.wp-block-image img' )
+ ).toBeVisible();
+
+ // Ensure the image is in the Media Library.
+ await visitAdminPage( admin, 'upload.php' );
+
+ const imageContainer = page
+ .locator( '.attachments-wrapper li' )
+ .first();
+
+ await expect( imageContainer ).toHaveAttribute(
+ 'aria-label',
+ 'Add a red hat'
+ );
+
+ await expect( imageContainer.locator( 'img' ) ).toBeVisible();
+ } );
+
+ test( 'Can refine an image in the stand-alone Generate Image page', async ( {
+ admin,
+ page,
+ } ) => {
+ // Globally turn on Experiments.
+ await enableExperiments( admin, page );
+
+ // Enable the Image Generation Experiment.
+ await enableExperiment( admin, page, 'image-generation' );
+
+ // Visit the Media Library.
+ await visitAdminPage( admin, 'upload.php' );
+
+ // Click the Generate Image button.
+ await page.locator( '.ai-generate-image-btn' ).click();
+
+ // Add a prompt and generate the image.
+ await page
+ .locator( '.ai-generate-image-standalone__idle textarea' )
+ .fill( 'A smiley face' );
+ await page
+ .locator( '.ai-generate-image-standalone__actions button' )
+ .click();
+
+ const refineButton = page.locator(
+ '.ai-generate-image-standalone__actions button',
+ {
+ hasText: 'Refine Image',
+ }
+ );
+ await expect( refineButton ).toBeVisible();
+
+ // Click the refine button.
+ await refineButton.click();
+
+ // Ensure the modal is in the refining state.
+ await expect(
+ page.locator( '.ai-generate-image-standalone__refining' )
+ ).toBeVisible();
+
+ // Ensure the refine prompt textarea is visible.
+ await expect(
+ page.locator( '.ai-generate-image-standalone__refining textarea' )
+ ).toBeVisible();
+
+ // Add our refine prompt and generate the image.
+ await page
+ .locator( '.ai-generate-image-standalone__refining textarea' )
+ .fill( 'Add a red hat' );
+ await page
+ .locator( '.ai-generate-image-standalone__refining button' )
+ .filter( { hasText: 'Refine' } )
+ .first()
+ .click();
+
+ // Ensure the images are visible in the modal.
+ await expect(
+ page.locator( '.ai-generate-image-standalone__preview-image' )
+ ).toHaveCount( 2 );
+
+ // Click the previous image navigation arrow.
+ const previousBtn = page
+ .locator( '.ai-image-history-nav' )
+ .getByRole( 'button', {
+ name: 'Previous version',
+ } );
+ await previousBtn.click();
+
+ // Ensure the navigation shows correctly.
+ await expect(
+ page.locator( '.ai-image-history-nav__counter' )
+ ).toHaveText( '1 / 2' );
+
+ // Click the next image navigation arrow.
+ const nextBtn = page
+ .locator( '.ai-image-history-nav' )
+ .getByRole( 'button', {
+ name: 'Next version',
+ } );
+ await nextBtn.click();
+
+ // Ensure the navigation shows correctly.
+ await expect(
+ page.locator( '.ai-image-history-nav__counter' )
+ ).toHaveText( '2 / 2' );
+
+ const saveImageButton = page.locator(
+ '.ai-generate-image-standalone__actions button',
+ {
+ hasText: 'Save to Media Library',
+ }
+ );
+ await expect( saveImageButton ).toBeVisible();
+
+ saveImageButton.click();
+
+ // Ensure a success message is visible.
+ await expect(
+ page.locator( '.components-notice.is-success' )
+ ).toBeVisible();
+
+ // View the image in the Media Library.
+ page.locator( '.components-notice.is-success a' ).click();
+
+ // Ensure alt text is set.
+ await expect(
+ page.locator( '#attachment-details-two-column-alt-text' )
+ ).toHaveValue( 'Add a red hat' );
+ } );
+
+ test( 'Can refine an existing image in the Media Library', async ( {
+ admin,
+ page,
+ requestUtils,
+ } ) => {
+ // Globally turn on Experiments.
+ await enableExperiments( admin, page );
+
+ // Enable the Image Generation Experiment.
+ await enableExperiment( admin, page, 'image-generation' );
+
+ // Upload a test image so we have a URL the editor can load.
+ await requestUtils.uploadMedia( TEST_IMAGE_PATH );
+
+ // Visit the Media Library.
+ await visitAdminPage( admin, 'upload.php' );
+
+ // Click on the first image in the Media Library.
+ await page
+ .locator( '.media-frame-content ul.attachments li:first-child' )
+ .click();
+
+ // Find the Edit Image button and click on it.
+ const editImageButton = page
+ .locator( '.attachment-actions button', { hasText: 'Edit Image' } )
+ .first();
+ await editImageButton.click();
+
+ // Find and click on the Refine Image button.
+ const refineImageButton = page.locator(
+ '.ai-media-library-editor__presets button',
+ {
+ hasText: 'Refine Image',
+ }
+ );
+ await refineImageButton.click();
+
+ // Add a prompt and generate the image.
+ await page
+ .locator( '.ai-media-library-editor__idle textarea' )
+ .fill( 'A smiley face' );
+ await page
+ .locator( '.ai-media-library-editor__actions button' )
+ .click();
+
+ // Ensure the images are visible in the modal.
+ await expect(
+ page.locator( '.ai-media-library-editor__preview-image' )
+ ).toHaveCount( 2 );
+
+ // Ensure the buttons we want are there.
+ await expect(
+ page.locator( '.ai-media-library-editor__actions button' )
+ ).toHaveCount( 4 );
+
+ let saveImageBtn = page.locator(
+ '.ai-media-library-editor__actions button',
+ {
+ hasText: 'Save to Media Library',
+ }
+ );
+ await expect( saveImageBtn ).toBeVisible();
+
+ const refineBtn = page.locator(
+ '.ai-media-library-editor__actions button',
+ {
+ hasText: 'Refine Image',
+ }
+ );
+ await expect( refineBtn ).toBeVisible();
+
+ const generateAnotherBtn = page.locator(
+ '.ai-media-library-editor__actions button',
+ {
+ hasText: 'Generate Another Image',
+ }
+ );
+ await expect( generateAnotherBtn ).toBeVisible();
+
+ // Ensure there's a Start over button.
+ const startOverBtn = page.locator(
+ '.ai-media-library-editor__actions button',
+ {
+ hasText: 'Start over',
+ }
+ );
+ await expect( startOverBtn ).toBeVisible();
+
+ startOverBtn.click();
+
+ // Add a prompt and generate the image.
+ await page
+ .locator( '.ai-media-library-editor__idle textarea' )
+ .fill( 'A smiley face' );
+ await page
+ .locator( '.ai-media-library-editor__actions button' )
+ .click();
+
+ // Ensure the images are visible in the modal.
+ await expect(
+ page.locator( '.ai-media-library-editor__preview-image' )
+ ).toHaveCount( 2 );
+
+ // Ensure the buttons we want are there.
+ await expect(
+ page.locator( '.ai-media-library-editor__actions button' )
+ ).toHaveCount( 4 );
+
+ saveImageBtn = page.locator(
+ '.ai-media-library-editor__actions button',
+ {
+ hasText: 'Save to Media Library',
+ }
+ );
+ await expect( saveImageBtn ).toBeVisible();
+
+ saveImageBtn.click();
+
+ // Ensure a success message is visible.
+ await expect(
+ page.locator( '.components-notice.is-success' )
+ ).toBeVisible();
+
+ // View the image in the Media Library.
+ page.locator( '.components-notice.is-success a' ).click();
+
+ // Ensure alt text is set.
+ await expect(
+ page.locator( '#attachment-details-two-column-alt-text' )
+ ).toHaveValue( 'A smiley face' );
+ } );
+
+ test( 'Can use preset refine buttons on an existing image in the Media Library', async ( {
+ admin,
+ page,
+ requestUtils,
+ } ) => {
+ // Globally turn on Experiments.
+ await enableExperiments( admin, page );
+
+ // Enable the Image Generation Experiment.
+ await enableExperiment( admin, page, 'image-generation' );
+
+ // Upload a test image so we have a URL the editor can load.
+ await requestUtils.uploadMedia( TEST_IMAGE_PATH );
+
+ // Visit the Media Library.
+ await visitAdminPage( admin, 'upload.php' );
+
+ // Click on the first image in the Media Library.
+ await page
+ .locator( '.media-frame-content ul.attachments li:first-child' )
+ .click();
+
+ // Find the Edit Image button and click on it.
+ const editImageButton = page
+ .locator( '.attachment-actions button', { hasText: 'Edit Image' } )
+ .first();
+ await editImageButton.click();
+
+ // Ensure there are three preset buttons.
+ await expect(
+ page.locator( '.ai-media-library-editor__presets button' )
+ ).toHaveCount( 3 );
+
+ // Find the Expand Background button and click on it.
+ const expandBGBtn = page
+ .locator( '.ai-media-library-editor__presets button', {
+ hasText: 'Expand Background',
+ } )
+ .first();
+ await expandBGBtn.click();
+
+ // Ensure the images are visible in the modal.
+ await expect(
+ page.locator( '.ai-media-library-editor__preview-image' )
+ ).toHaveCount( 2 );
+
+ // Ensure the buttons we want are there.
+ await expect(
+ page.locator( '.ai-media-library-editor__actions button' )
+ ).toHaveCount( 4 );
+
+ // Ensure there's a Start over button.
+ const startOverBtn = page.locator(
+ '.ai-media-library-editor__actions button',
+ {
+ hasText: 'Start over',
+ }
+ );
+ await expect( startOverBtn ).toBeVisible();
+
+ startOverBtn.click();
+
+ // Find the Remove background button and click on it.
+ const removeBGBtn = page
+ .locator( '.ai-media-library-editor__presets button', {
+ hasText: 'Remove Background',
+ } )
+ .first();
+ await removeBGBtn.click();
+
+ // Ensure the images are visible in the modal.
+ await expect(
+ page.locator( '.ai-media-library-editor__preview-image' )
+ ).toHaveCount( 2 );
+
+ // Ensure the buttons we want are there.
+ await expect(
+ page.locator( '.ai-media-library-editor__actions button' )
+ ).toHaveCount( 4 );
+
+ const generateAnotherBtn = page.locator(
+ '.ai-media-library-editor__actions button',
+ {
+ hasText: 'Generate Another Image',
+ }
+ );
+ await expect( generateAnotherBtn ).toBeVisible();
+
+ generateAnotherBtn.click();
+
+ // Ensure the images are visible in the modal.
+ await expect(
+ page.locator( '.ai-media-library-editor__preview-image' )
+ ).toHaveCount( 2 );
+
+ // Click the previous image navigation arrow.
+ let previousImgBtn = page
+ .locator( '.ai-image-history-nav' )
+ .getByRole( 'button', {
+ name: 'Previous version',
+ } );
+ await previousImgBtn.click();
+
+ // Ensure the navigation shows correctly.
+ await expect(
+ page.locator( '.ai-image-history-nav__counter' )
+ ).toHaveText( '1 / 2' );
+
+ // Click the next image navigation arrow.
+ let nextImgBtn = page
+ .locator( '.ai-image-history-nav' )
+ .getByRole( 'button', {
+ name: 'Next version',
+ } );
+ await nextImgBtn.click();
+
+ // Ensure the navigation shows correctly.
+ await expect(
+ page.locator( '.ai-image-history-nav__counter' )
+ ).toHaveText( '2 / 2' );
+
+ // Generate another image.
+ await generateAnotherBtn.click();
+
+ // Ensure the images are visible in the modal.
+ await expect(
+ page.locator( '.ai-media-library-editor__preview-image' )
+ ).toHaveCount( 2 );
+
+ // Click the previous image navigation arrow.
+ previousImgBtn = page
+ .locator( '.ai-image-history-nav' )
+ .getByRole( 'button', {
+ name: 'Previous version',
+ } );
+ await previousImgBtn.click();
+
+ // Ensure the navigation shows correctly.
+ await expect(
+ page.locator( '.ai-image-history-nav__counter' )
+ ).toHaveText( '2 / 3' );
+
+ // Click the next image navigation arrow.
+ nextImgBtn = page
+ .locator( '.ai-image-history-nav' )
+ .getByRole( 'button', {
+ name: 'Next version',
+ } );
+ await nextImgBtn.click();
+
+ // Ensure the navigation shows correctly.
+ await expect(
+ page.locator( '.ai-image-history-nav__counter' )
+ ).toHaveText( '3 / 3' );
+
+ await clearConnector( admin, page, 'ai-provider-for-google' );
+ } );
+} );
diff --git a/tests/e2e/specs/experiments/image-generation.spec.js b/tests/e2e/specs/experiments/image-generation.spec.js
index 41ea1d0b..678fa33e 100644
--- a/tests/e2e/specs/experiments/image-generation.spec.js
+++ b/tests/e2e/specs/experiments/image-generation.spec.js
@@ -534,20 +534,11 @@ test.describe( 'Image Generation Experiment', () => {
// Ensure a success message is visible.
await expect(
- page.locator( '.ai-generate-image-standalone__success' )
+ page.locator( '.components-notice.is-success' )
).toBeVisible();
- // Ensure we have two new buttons.
- await expect(
- page.locator( '.ai-generate-image-standalone__success button' )
- ).toHaveCount( 1 );
-
- await expect(
- page.locator( '.ai-generate-image-standalone__success a' )
- ).toHaveCount( 1 );
-
// View the image in the Media Library.
- page.locator( '.ai-generate-image-standalone__success a' ).click();
+ page.locator( '.components-notice.is-success a' ).click();
// Ensure alt text is set.
await expect(
diff --git a/tests/e2e/utils/helpers.ts b/tests/e2e/utils/helpers.ts
index c87f07a8..c3ac4ea9 100644
--- a/tests/e2e/utils/helpers.ts
+++ b/tests/e2e/utils/helpers.ts
@@ -48,12 +48,53 @@ export const clearConnectors = async ( admin: Admin, page: Page ) => {
// Wait for page to fully load before finding button
await page.waitForTimeout( 1000 );
- const editBtn = page.locator(
- '.connector-item--ai-provider-for-openai button',
- {
+ const providers = [
+ 'ai-provider-for-openai',
+ 'ai-provider-for-google',
+ 'ai-provider-for-anthropic',
+ ];
+
+ for ( const provider of providers ) {
+ const editBtn = page.locator( `.connector-item--${ provider } button`, {
hasText: 'Edit',
+ } );
+
+ if ( ( await editBtn.count() ) === 0 ) {
+ continue;
}
- );
+
+ await editBtn.click();
+ await page
+ .locator(
+ `.connector-item--${ provider } .connector-settings button`
+ )
+ .click();
+ }
+
+ // Wait for save.
+ await page.waitForTimeout( 1000 );
+};
+
+/**
+ * Clears out a specific existing Connector.
+ *
+ * @param admin The admin fixture from the test context.
+ * @param page The page object.
+ * @param connectorId The ID of the connector to clear.
+ */
+export const clearConnector = async (
+ admin: Admin,
+ page: Page,
+ connectorId: string
+) => {
+ await visitConnectorsPage( admin );
+
+ // Wait for page to fully load before finding button
+ await page.waitForTimeout( 1000 );
+
+ const editBtn = page.locator( `.connector-item--${ connectorId } button`, {
+ hasText: 'Edit',
+ } );
if ( ( await editBtn.count() ) === 0 ) {
return;
@@ -62,7 +103,7 @@ export const clearConnectors = async ( admin: Admin, page: Page ) => {
await editBtn.click();
await page
.locator(
- '.connector-item--ai-provider-for-openai .connector-settings button'
+ `.connector-item--${ connectorId } .connector-settings button`
)
.click();