diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml
index 29190801..d6630385 100644
--- a/.buildkite/pipeline.yml
+++ b/.buildkite/pipeline.yml
@@ -28,7 +28,40 @@ steps:
command: |
buildkite-agent artifact download dist.tar.gz .
tar -xzf dist.tar.gz
- make test-e2e
+ echo "--- :docker: Starting wp-env"
+ make wp-env-start
+ WP_CONTAINER=$(docker ps -qf "publish=8888" | head -1)
+ echo "--- :performing_arts: Running E2E tests in Playwright container"
+ E2E_EXIT=0
+ docker run --rm \
+ --userns=host \
+ --network host \
+ -v "$(pwd):/work" \
+ -w /work \
+ -e CI=true \
+ mcr.microsoft.com/playwright:v1.58.2-noble \
+ bash -c "npm ci && npx playwright install chromium && npx playwright test" \
+ || E2E_EXIT=$?
+ echo "--- :broom: Fixing ownership of root-owned files"
+ docker run --rm \
+ --userns=host \
+ -v "$(pwd):/work" \
+ -w /work \
+ mcr.microsoft.com/playwright:v1.58.2-noble \
+ chown -R "$(id -u):$(id -g)" /work
+ echo "--- :wordpress: WordPress debug log"
+ if [ -n "$WP_CONTAINER" ]; then
+ docker exec "$WP_CONTAINER" cat /var/www/html/wp-content/debug.log 2>/dev/null || echo "(no debug.log found)"
+ echo "--- :file_folder: Uploads directory permissions"
+ docker exec "$WP_CONTAINER" ls -la /var/www/html/wp-content/uploads/ 2>/dev/null || echo "(no uploads directory)"
+ echo "--- :whale: WordPress container status"
+ docker inspect --format='{{.State.Status}}' "$WP_CONTAINER" 2>/dev/null || echo "(container gone)"
+ else
+ echo "(WordPress container not found before tests)"
+ fi
+ exit $E2E_EXIT
+ agents:
+ queue: default
plugins: *plugins
- label: ':android: Publish Android Library'
diff --git a/bin/wp-env-setup.sh b/bin/wp-env-setup.sh
index f7464810..4eaf3945 100755
--- a/bin/wp-env-setup.sh
+++ b/bin/wp-env-setup.sh
@@ -65,7 +65,31 @@ done
# ---------------------------------------------------------------------------
echo "Flushing rewrite rules..."
-npm run --silent wp-env run cli -- wp rewrite structure '/%postname%/' --hard 2>/dev/null
+npm run --silent wp-env run cli -- wp rewrite structure '/%postname%/' 2>/dev/null
+
+echo "Writing .htaccess for pretty permalinks..."
+WP_CONTAINER=$(docker ps -qf "publish=8888" | head -1)
+if [ -z "$WP_CONTAINER" ]; then
+ echo "Error: Could not find WordPress container on port 8888."
+ echo "Running containers:"
+ docker ps --format "{{.ID}} {{.Image}} {{.Ports}} {{.Names}}"
+ exit 1
+fi
+echo "Found WordPress container: $WP_CONTAINER"
+docker exec -u 0 "$WP_CONTAINER" sh -c 'cat > /var/www/html/.htaccess << "HTACCESS"
+# BEGIN WordPress
+
+RewriteEngine On
+RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
+RewriteBase /
+RewriteRule ^index\.php$ - [L]
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteCond %{REQUEST_FILENAME} !-d
+RewriteRule . /index.php [L]
+
+# END WordPress
+HTACCESS
+'
# ---------------------------------------------------------------------------
# Enable Jetpack blocks module
diff --git a/docs/test-cases.md b/docs/test-cases.md
index 63b2d1c0..374c982a 100644
--- a/docs/test-cases.md
+++ b/docs/test-cases.md
@@ -1,90 +1,10 @@
# Mobile Editor Tests
-## Smoke Tests
+These test cases require the native undo/redo toolbar and must be verified manually on iOS or Android.
-**Purpose:** Verify the editor's core functionality: writing/formatting text, uploading media, saving/publishing, and basic block manipulation.
-
-### S.1. Undo/Redo Actions
+## S.1. Undo/Redo Actions
- **Steps:**
- Add, remove, and edit blocks and text.
- Use Undo and Redo buttons.
- **Expected Outcome:** Editor correctly undoes and redoes actions, restoring previous states.
-
-### S.2. Upload an image
-
-- **Steps:**
- - Add an Image block.
- - Tap "Choose from device" and select an image.
-- **Expected Outcome:** Image uploads and displays in the block. An activity indicator is shown while the image is uploading.
-
-### S.3. Upload an video
-
-- **Steps:**
- - Add a Video block.
- - Tap "Choose from device" and select a video.
-- **Expected Outcome:** Video uploads and displays in the block. An activity indicator is shown while the video is uploading.
-
-### S.4. Save and publish a post
-
-- **Steps:**
- - Create a new post with text and media.
- - Save as draft, then publish.
-- **Expected Outcome:** Post is saved and published successfully; content appears as expected.
-
-## Functionality Tests
-
-**Purpose:** Validate deeper content and formatting features, advanced block settings, and robust editor behaviors.
-
-### F.1. Text alignment options
-
-- **Steps:**
- - Add a Paragraph or Verse block.
- - Type text and use alignment options (left, center, right).
-- **Expected Outcome:** Selected alignment is applied to the block content.
-
-### F.2. Add and preview embedded content
-
-- **Steps:**
- - Add a Shortcode or Embed block.
- - Insert a YouTube or Twitter link.
- - Preview the post.
-- **Expected Outcome:** Embedded content (e.g., YouTube video) displays correctly in preview.
-
-### F.3. Color and gradient customization
-
-- **Steps:**
- - Add a block supporting color (e.g., Buttons, Cover).
- - Open color settings, switch between solid and gradient, pick custom colors, and apply.
-- **Expected Outcome:** Selected colors/gradients are applied; UI updates accordingly.
-
-### F.4. Gallery block: image uploads and captions
-
-- **Steps:**
- - Add a Gallery block, upload multiple images.
- - Add captions to gallery and individual images, apply formatting.
-- **Expected Outcome:** An activity indicator is shown while the images are uploading. Captions and formatting display as expected.
-
-### F.5. Pattern insertion
-
-- **Steps:**
- - Insert a pattern from the inserter.
-- **Expected Outcome:** Pattern content appears.
-
-### F.6. Upload an audio file
-
-Known issue: [Audio block unable to upload expected file formats](https://github.com/wordpress-mobile/GutenbergKit/issues/123)
-
-- **Steps:**
- - Add an Audio block.
- - Tap "Choose from device" and select an audio file.
-- **Expected Outcome:** Audio uploads and displays in the block. An activity indicator is shown while the audio is uploading.
-
-### F.7. Upload a file
-
-Known issue: [File block unable to upload expected file formats](https://github.com/wordpress-mobile/GutenbergKit/issues/124)
-
-- **Steps:**
- - Add a File block.
- - Tap "Choose from device" and select a file.
-- **Expected Outcome:** File uploads, filename and download button appear when upload completes.
diff --git a/e2e/assets/test-audio.mp3 b/e2e/assets/test-audio.mp3
new file mode 100644
index 00000000..269a03e8
Binary files /dev/null and b/e2e/assets/test-audio.mp3 differ
diff --git a/e2e/assets/test-file.pdf b/e2e/assets/test-file.pdf
new file mode 100644
index 00000000..7c249578
Binary files /dev/null and b/e2e/assets/test-file.pdf differ
diff --git a/e2e/assets/test-image-2.png b/e2e/assets/test-image-2.png
new file mode 100644
index 00000000..08cd6f2b
Binary files /dev/null and b/e2e/assets/test-image-2.png differ
diff --git a/e2e/assets/test-image.png b/e2e/assets/test-image.png
new file mode 100644
index 00000000..32fe19d8
Binary files /dev/null and b/e2e/assets/test-image.png differ
diff --git a/e2e/assets/test-video.mp4 b/e2e/assets/test-video.mp4
new file mode 100644
index 00000000..cafab890
Binary files /dev/null and b/e2e/assets/test-video.mp4 differ
diff --git a/e2e/audio-upload.spec.js b/e2e/audio-upload.spec.js
new file mode 100644
index 00000000..c944c281
--- /dev/null
+++ b/e2e/audio-upload.spec.js
@@ -0,0 +1,39 @@
+/**
+ * External dependencies
+ */
+import path from 'node:path';
+import { test, expect } from '@playwright/test';
+
+/**
+ * Internal dependencies
+ */
+import EditorPage from './editor-page';
+
+const TEST_AUDIO = path.resolve( import.meta.dirname, 'assets/test-audio.mp3' );
+
+test.describe( 'Audio Upload', () => {
+ test( 'should upload an audio file via the Audio block', async ( {
+ page,
+ } ) => {
+ const editor = new EditorPage( page );
+ await editor.setup();
+
+ await editor.insertBlock( 'core/audio' );
+
+ // Use the "Upload" button which triggers a file input.
+ const fileChooserPromise = page.waitForEvent( 'filechooser' );
+ await page
+ .getByRole( 'button', { name: 'Upload', exact: true } )
+ .click();
+ const fileChooser = await fileChooserPromise;
+ await fileChooser.setFiles( TEST_AUDIO );
+
+ // Wait for the upload to complete (block gets a numeric media ID).
+ const attrs = await editor.waitForMediaUpload( 0 );
+ expect( attrs.id ).toBeGreaterThan( 0 );
+ expect( attrs.src ).toContain( 'localhost:8888' );
+
+ // Verify the audio element is rendered.
+ await expect( page.locator( '.wp-block-audio audio' ) ).toBeAttached();
+ } );
+} );
diff --git a/e2e/color-gradient.spec.js b/e2e/color-gradient.spec.js
new file mode 100644
index 00000000..ee1400b2
--- /dev/null
+++ b/e2e/color-gradient.spec.js
@@ -0,0 +1,104 @@
+/**
+ * External dependencies
+ */
+import { test, expect } from '@playwright/test';
+
+/**
+ * Internal dependencies
+ */
+import EditorPage from './editor-page';
+
+test.describe( 'Color and Gradient', () => {
+ test( 'should apply a background color to a button via settings', async ( {
+ page,
+ } ) => {
+ const editor = new EditorPage( page );
+ await editor.setup();
+
+ await editor.insertBlock( 'core/buttons' );
+
+ // Type into the button so the inner button block is focused.
+ const buttonText = page.getByRole( 'textbox', {
+ name: 'Button text',
+ } );
+ await buttonText.click();
+ await page.keyboard.type( 'Colored' );
+
+ // Open block settings and navigate to the Styles tab.
+ await editor.openBlockSettings();
+ await page.getByRole( 'tab', { name: 'Styles' } ).click();
+
+ // Click the Background color control.
+ await page
+ .locator( '.block-settings-menu' )
+ .getByRole( 'button', { name: 'Background' } )
+ .click();
+
+ // Pick the first color option in the palette.
+ await page
+ .locator( '.components-circular-option-picker__option' )
+ .first()
+ .click();
+
+ // Close the settings popover before reading attributes.
+ await page.keyboard.press( 'Escape' );
+
+ // Verify the inner button block got a background color.
+ const blocks = await editor.getBlocks();
+ const innerButton = blocks[ 0 ].innerBlocks[ 0 ];
+ expect(
+ innerButton.attributes.backgroundColor ||
+ innerButton.attributes.style?.color?.background
+ ).toBeTruthy();
+ } );
+
+ test( 'should have theme gradients available in editor settings', async ( {
+ page,
+ } ) => {
+ const editor = new EditorPage( page );
+ await editor.setup();
+
+ // Verify that theme gradients are loaded from wp-env editor settings.
+ const hasGradients = await page.evaluate( () => {
+ const settings = window.wp.data
+ .select( 'core/block-editor' )
+ .getSettings();
+ return (
+ Array.isArray( settings.gradients ) &&
+ settings.gradients.length > 0
+ );
+ } );
+ expect( hasGradients ).toBe( true );
+ } );
+
+ test( 'should apply a gradient to a button via data store', async ( {
+ page,
+ } ) => {
+ const editor = new EditorPage( page );
+ await editor.setup();
+
+ await editor.insertBlock( 'core/buttons' );
+
+ // Apply a gradient to the inner button block via the data store.
+ await page.evaluate( () => {
+ const blocks = window.wp.data
+ .select( 'core/block-editor' )
+ .getBlocks();
+ const innerButton = blocks[ 0 ]?.innerBlocks?.[ 0 ];
+ if ( innerButton ) {
+ window.wp.data
+ .dispatch( 'core/block-editor' )
+ .updateBlockAttributes( innerButton.clientId, {
+ gradient: 'vivid-cyan-blue-to-vivid-purple',
+ } );
+ }
+ } );
+
+ // Verify the gradient was applied.
+ const blocks = await editor.getBlocks();
+ const innerButton = blocks[ 0 ].innerBlocks[ 0 ];
+ expect( innerButton.attributes.gradient ).toBe(
+ 'vivid-cyan-blue-to-vivid-purple'
+ );
+ } );
+} );
diff --git a/e2e/editor-error.spec.js b/e2e/editor-error.spec.js
index d2d354eb..94e5c2a9 100644
--- a/e2e/editor-error.spec.js
+++ b/e2e/editor-error.spec.js
@@ -80,10 +80,12 @@ test.describe( 'Editor Error Handling', () => {
test( 'should show plugin load failure notice and keep editor functional', async ( {
page,
} ) => {
- // Enable plugins without providing API endpoints. This causes
+ // Enable plugins with an unreachable API root. This causes
// fetchEditorAssets to fail, resulting in the plugin load notice.
const editor = new EditorPage( page );
await editor.setup( {
+ siteApiRoot: 'http://localhost:1/',
+ authHeader: '',
post: {
id: 1,
type: 'post',
diff --git a/e2e/editor-page.js b/e2e/editor-page.js
index 3dcdf745..b96b891c 100644
--- a/e2e/editor-page.js
+++ b/e2e/editor-page.js
@@ -6,14 +6,25 @@
*/
/**
- * Default GBKit configuration for dev-mode testing.
+ * Internal dependencies
+ */
+import { credentials, getEditorSettings } from './wp-env-fixtures';
+
+/**
+ * Default GBKit configuration for wp-env testing.
*
* @type {Object}
*/
const DEFAULT_GBKIT = {
+ siteApiRoot: credentials.siteApiRoot,
+ authHeader: credentials.authHeader,
+ siteApiNamespace: [],
+ namespaceExcludedPaths: [],
post: {
- id: -1,
+ id: 1,
type: 'post',
+ restBase: 'posts',
+ restNamespace: 'wp/v2',
status: 'draft',
title: '',
content: '',
@@ -34,12 +45,24 @@ export default class EditorPage {
/**
* Navigate to the editor and wait for it to be fully ready.
*
- * @param {Object} [gbkit] Optional GBKit config override.
+ * @param {Object} [gbkit] Optional GBKit config override (merged with defaults).
*/
- async setup( gbkit = DEFAULT_GBKIT ) {
- await this.#page.addInitScript( ( config ) => {
- window.GBKit = config;
- }, gbkit );
+ async setup( gbkit = {} ) {
+ const editorSettings = await getEditorSettings();
+
+ const config = {
+ ...DEFAULT_GBKIT,
+ editorSettings,
+ ...gbkit,
+ post: {
+ ...DEFAULT_GBKIT.post,
+ ...gbkit.post,
+ },
+ };
+
+ await this.#page.addInitScript( ( cfg ) => {
+ window.GBKit = cfg;
+ }, config );
await this.#page.goto( '/?dev_mode=1' );
@@ -165,4 +188,73 @@ export default class EditorPage {
window.wp.data.select( 'core/block-editor' ).getBlocks()
);
}
+
+ /**
+ * Read a single attribute from a root-level block.
+ *
+ * @param {number} index Zero-based block index.
+ * @param {string} attribute Attribute name.
+ * @return {Promise<*>} The attribute value.
+ */
+ async getBlockAttribute( index, attribute ) {
+ return await this.#page.evaluate(
+ ( { idx, attr } ) => {
+ const blocks = window.wp.data
+ .select( 'core/block-editor' )
+ .getBlocks();
+ return blocks[ idx ]?.attributes?.[ attr ];
+ },
+ { idx: index, attr: attribute }
+ );
+ }
+
+ /**
+ * Open the "Align text" dropdown and select an alignment option.
+ *
+ * @param {string} alignment Alignment label (e.g. 'Align text center').
+ */
+ async setTextAlignment( alignment ) {
+ await this.#page.getByRole( 'button', { name: 'Align text' } ).click();
+ await this.#page
+ .getByRole( 'menuitemradio', { name: alignment } )
+ .click();
+ }
+
+ /**
+ * Open the block settings popover (the "Block Settings" cog in the toolbar).
+ *
+ * The popover is only available when a block is selected.
+ */
+ async openBlockSettings() {
+ await this.#page
+ .getByRole( 'button', { name: 'Block Settings' } )
+ .click();
+ }
+
+ /**
+ * Wait for a media upload to complete on a block at the given index.
+ *
+ * Polls the block's `id` attribute until it becomes a positive number,
+ * indicating the upload has finished and the media was assigned a WP ID.
+ *
+ * @param {number} index Zero-based block index.
+ * @param {number} timeout Max wait time in milliseconds.
+ * @return {Promise