diff --git a/tests/e2e/cloudinary-image-delivery.spec.js b/tests/e2e/cloudinary-image-delivery.spec.js
new file mode 100644
index 000000000..45c89dbc4
--- /dev/null
+++ b/tests/e2e/cloudinary-image-delivery.spec.js
@@ -0,0 +1,202 @@
+/**
+ * External dependencies
+ */
+const fs = require( 'fs' );
+const path = require( 'path' );
+const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
+
+/**
+ * Internal dependencies
+ */
+const { ensureCloudinaryConnected } = require( './utils/connection' );
+const { wpCli } = require( './utils/wizard' );
+
+const FIXTURE_PATH = path.join( __dirname, 'fixtures', 'test-image.jpg' );
+
+let cloudName;
+
+/**
+ * Per-test scratch space populated by beforeEach.
+ *
+ * @type {{ postId: number, attachmentId: number, postLink: string }|null}
+ */
+let created = null;
+
+/**
+ * Assert that a given image URL is served by Cloudinary under the
+ * expected cloud name. We intentionally do not assert specific
+ * transformations — those are an implementation detail of the plugin
+ * and may change.
+ *
+ * @param {string} rawUrl The src or srcset candidate.
+ * @param {string} expectedCloud The cloud name parsed from CLOUDINARY_E2E_URL.
+ */
+function expectCloudinaryUrl( rawUrl, expectedCloud ) {
+ let parsed;
+ try {
+ parsed = new URL( rawUrl );
+ } catch ( e ) {
+ throw new Error( `Image URL is not parseable: ${ rawUrl }` );
+ }
+ expect( parsed.host, `host of ${ rawUrl }` ).toBe( 'res.cloudinary.com' );
+ expect(
+ parsed.pathname.startsWith( `/${ expectedCloud }/` ),
+ `pathname of ${ rawUrl } should start with /${ expectedCloud }/`
+ ).toBe( true );
+}
+
+test.describe( 'Cloudinary image delivery', () => {
+ test.beforeAll( () => {
+ ( { cloudName } = ensureCloudinaryConnected() );
+ } );
+
+ test.beforeEach( async ( { requestUtils } ) => {
+ // Upload the fixture image via REST.
+ const file = fs.readFileSync( FIXTURE_PATH );
+ const media = await requestUtils.rest( {
+ method: 'POST',
+ path: '/wp/v2/media',
+ headers: {
+ 'Content-Type': 'image/jpeg',
+ 'Content-Disposition': 'attachment; filename="test-image.jpg"',
+ },
+ data: file,
+ } );
+
+ const attachmentId = media.id;
+ const sourceUrl = media.source_url;
+
+ // Create a published post that uses the attachment as both
+ // featured image and an inline image block.
+ const content =
+ `\n` +
+ `
\n` +
+ ``;
+
+ const post = await requestUtils.rest( {
+ method: 'POST',
+ path: '/wp/v2/posts',
+ data: {
+ status: 'publish',
+ title: `Cloudinary e2e ${ Date.now() }`,
+ content,
+ featured_media: attachmentId,
+ },
+ } );
+
+ created = {
+ postId: post.id,
+ attachmentId,
+ postLink: post.link,
+ };
+
+ // The plugin's URL rewriting depends on the asset being synced
+ // to Cloudinary. With auto_sync enabled (wizard default), the
+ // first front-end render queues the sync but renders local
+ // URLs. Driving `wp cloudinary sync` here makes the test
+ // deterministic without relying on the cron-driven queue.
+ wpCli( [ 'cloudinary', 'sync' ] );
+ } );
+
+ test.afterEach( async () => {
+ if ( ! created ) {
+ return;
+ }
+ const { postId, attachmentId } = created;
+ created = null;
+
+ // Best-effort cleanup via WP-CLI. We do not use the REST API
+ // here because the test wp-env runs without pretty permalinks,
+ // which makes appending `force=true` to a `?rest_route=`-style
+ // URL fragile. WP-CLI is unambiguous and we already use it in
+ // other helpers (see utils/wizard.js).
+ try {
+ wpCli( [ 'post', 'delete', String( postId ), '--force' ] );
+ } catch ( e ) {
+ // eslint-disable-next-line no-console
+ console.warn( 'Post cleanup failed:', e.message );
+ }
+ try {
+ wpCli( [ 'post', 'delete', String( attachmentId ), '--force' ] );
+ } catch ( e ) {
+ // eslint-disable-next-line no-console
+ console.warn( 'Media cleanup failed:', e.message );
+ }
+ } );
+
+ test( 'serves featured image and inline image via Cloudinary', async ( {
+ page,
+ } ) => {
+ expect( created, 'post + attachment should be created' ).not.toBeNull();
+
+ await page.goto( created.postLink );
+
+ // Featured image: themes mark it with .wp-post-image.
+ const featured = page.locator( 'img.wp-post-image' ).first();
+ await expect(
+ featured,
+ 'featured image should render on the post page'
+ ).toBeVisible();
+
+ // Inline image from the_content. Scope to .wp-block-image so
+ // the featured image (also tagged wp-image- by some themes)
+ // is not double-counted.
+ const inline = page
+ .locator( `.wp-block-image img.wp-image-${ created.attachmentId }` )
+ .first();
+ await expect(
+ inline,
+ 'inline image block should render in post content'
+ ).toBeVisible();
+
+ // With the wizard's default settings, the plugin lazy-loads
+ // images: the initial server-rendered markup carries a tiny
+ // SVG placeholder in `src` and the real Cloudinary delivery
+ // is encoded in `data-public-id` + `data-transformations` +
+ // `data-version`. The JS then constructs the Cloudinary URL
+ // and swaps it into `src` once the image scrolls into view.
+ //
+ // We assert two things per image:
+ // 1. `data-public-id` is present — proves the plugin marked
+ // the element for Cloudinary delivery (this is its own
+ // explicit signal, independent of lazyload / theme markup).
+ // 2. Any HTTP(S) URL attribute present (`src` after the JS
+ // swap, `srcset` when emitted by the theme) is served
+ // from `res.cloudinary.com//...`.
+ const httpUrls = [];
+ for ( const loc of [ featured, inline ] ) {
+ await loc.scrollIntoViewIfNeeded();
+
+ const publicId = await loc.getAttribute( 'data-public-id' );
+ expect(
+ publicId,
+ 'plugin should mark the image with data-public-id'
+ ).toBeTruthy();
+
+ const src = await loc.getAttribute( 'src' );
+ if ( src && /^https?:\/\//.test( src ) ) {
+ httpUrls.push( src );
+ }
+
+ const srcset = await loc.getAttribute( 'srcset' );
+ if ( srcset ) {
+ const firstCandidate = srcset
+ .split( ',' )[ 0 ]
+ .trim()
+ .split( /\s+/ )[ 0 ];
+ if ( firstCandidate && /^https?:\/\//.test( firstCandidate ) ) {
+ httpUrls.push( firstCandidate );
+ }
+ }
+ }
+
+ expect(
+ httpUrls.length,
+ 'at least one image should expose a Cloudinary URL via src or srcset'
+ ).toBeGreaterThan( 0 );
+
+ for ( const url of httpUrls ) {
+ expectCloudinaryUrl( url, cloudName );
+ }
+ } );
+} );
diff --git a/tests/e2e/fixtures/test-image.jpg b/tests/e2e/fixtures/test-image.jpg
new file mode 100644
index 000000000..5cf40b2f0
Binary files /dev/null and b/tests/e2e/fixtures/test-image.jpg differ
diff --git a/tests/e2e/utils/connection.js b/tests/e2e/utils/connection.js
new file mode 100644
index 000000000..66e27f561
--- /dev/null
+++ b/tests/e2e/utils/connection.js
@@ -0,0 +1,65 @@
+/**
+ * Helpers for putting the Cloudinary plugin into a "connected" state
+ * without driving the wizard UI.
+ *
+ * Setting the connection option directly via WP-CLI is faster and
+ * keeps this spec decoupled from the wizard spec, which exercises
+ * the UI path separately.
+ */
+
+const { wpCli, getCloudinaryUrlFromEnv } = require( './wizard' );
+
+/**
+ * Parse the cloud name out of a `cloudinary://key:secret@cloud_name` URL.
+ *
+ * @param {string} cloudinaryUrl
+ * @return {string} The cloud_name segment.
+ * @throws If the URL does not match the expected shape.
+ */
+function parseCloudName( cloudinaryUrl ) {
+ const match = /^cloudinary:\/\/[^:]+:[^@]+@([A-Za-z0-9_-]+)/.exec(
+ cloudinaryUrl
+ );
+ if ( ! match ) {
+ throw new Error(
+ `Could not parse cloud name from CLOUDINARY_E2E_URL: ${ cloudinaryUrl }`
+ );
+ }
+ return match[ 1 ];
+}
+
+/**
+ * Set the plugin's `cloudinary_connect` option directly so the plugin
+ * is "connected" for the duration of the spec.
+ *
+ * Mirrors what the wizard saves on completion. We deliberately do
+ * NOT pre-populate `cloudinary_connection_signature` or
+ * `cloudinary_status`; the plugin will populate those on first need.
+ *
+ * @return {{ cloudName: string }} The cloud name extracted from the URL.
+ */
+function ensureCloudinaryConnected() {
+ const cloudinaryUrl = getCloudinaryUrlFromEnv();
+ const cloudName = parseCloudName( cloudinaryUrl );
+
+ // Build the JSON payload the plugin expects.
+ const payload = JSON.stringify( { cloudinary_url: cloudinaryUrl } );
+
+ // `wp option update --format=json ` requires the
+ // value to be a valid JSON literal. Wrap in single quotes for the
+ // docker-exec'd shell.
+ wpCli( [
+ 'option',
+ 'update',
+ 'cloudinary_connect',
+ `'${ payload }'`,
+ '--format=json',
+ ] );
+
+ return { cloudName };
+}
+
+module.exports = {
+ parseCloudName,
+ ensureCloudinaryConnected,
+};