diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml
index f29971747..18942c83a 100644
--- a/.buildkite/pipeline.yml
+++ b/.buildkite/pipeline.yml
@@ -19,6 +19,12 @@ steps:
command: make test-js
plugins: *plugins
+ - label: ':performing_arts: Test E2E'
+ command: |
+ npx playwright install chromium
+ make test-e2e
+ plugins: *plugins
+
- label: ':android: Publish Android Library'
command: |
make build REFRESH_L10N=1 REFRESH_JS_BUILD=1
diff --git a/.gitignore b/.gitignore
index 152f10959..8b3b69210 100644
--- a/.gitignore
+++ b/.gitignore
@@ -196,5 +196,9 @@ local.properties
# Translation files
src/translations/
+# Playwright
+e2e/test-results/
+playwright-report/
+
# Claude
.claude/settings.local.json
diff --git a/Makefile b/Makefile
index 79bb8ef1f..70fde73f8 100644
--- a/Makefile
+++ b/Makefile
@@ -7,7 +7,7 @@ help: ## Display this help menu
@echo "Usage: make [target]"
@echo ""
@echo "Available targets:"
- @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \
+ @grep -E '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-25s\033[0m %s\n", $$1, $$2}' | \
sort
@echo ""
@@ -136,6 +136,14 @@ lint-swift: ## Lint Swift code
# Testing Targets
################################################################################
+.PHONY: test-e2e
+test-e2e: npm-dependencies ## Run end-to-end tests
+ npm run test:e2e
+
+.PHONY: test-e2e-ui
+test-e2e-ui: npm-dependencies ## Run end-to-end tests in UI mode
+ npm run test:e2e:ui
+
.PHONY: test-js
test-js: npm-dependencies ## Run JavaScript tests
npm run test:unit
diff --git a/docs/test-cases.md b/docs/test-cases.md
index 91e722c2c..a2d9eab79 100644
--- a/docs/test-cases.md
+++ b/docs/test-cases.md
@@ -4,15 +4,7 @@
**Purpose:** Verify the editor's core functionality: writing/formatting text, uploading media, saving/publishing, and basic block manipulation.
-### S.1. Write and format text
-
-- **Steps:**
- - Add a Paragraph, List, or Heading block.
- - Type some text.
- - Apply bold, italic, and strikethrough formatting using the toolbar.
-- **Expected Outcome:** Text is entered and formatting is applied as expected.
-
-### S.2. Add a link to a paragraph
+### S.1. Add a link to a paragraph
- **Steps:**
- Add a Paragraph, List, or Heading block.
@@ -20,36 +12,28 @@
- Apply a link to the text.
- **Expected Outcome:** Link is applied to the text as expected.
-### S.3. Merge and split blocks
-
-- **Steps:**
- - Write a long paragraph or list of multiple items.
- - Place the cursor somwewhere in the middle and split the block into two blocks using Enter.
- - Merge them back by deleting content at the start of the second block.
-- **Expected Outcome:** Blocks split and merge as expected; content remains intact.
-
-### S.4. Undo/Redo Actions
+### S.2. 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.5. Upload an image
+### S.3. 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.6. Upload an video
+### S.4. 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.7. Reorder blocks
+### S.5. Reorder blocks
- **Steps:**
- Add several content blocks to a post.
@@ -57,7 +41,7 @@
- Use the up/down arrows in the block toolbar to relocate the block.
- **Expected Outcome:** The block ordering is updated as expected.
-### S.8. Save and publish a post
+### S.6. Save and publish a post
- **Steps:**
- Create a new post with text and media.
diff --git a/e2e/.eslintrc.cjs b/e2e/.eslintrc.cjs
new file mode 100644
index 000000000..e6297875b
--- /dev/null
+++ b/e2e/.eslintrc.cjs
@@ -0,0 +1,28 @@
+module.exports = {
+ extends: [ 'plugin:@wordpress/eslint-plugin/test-playwright' ],
+ rules: {
+ // Allow non-literal titles for test.describe/test parameterized tests.
+ 'playwright/valid-title': 'off',
+ // The WordPress ESLint config enforces jsdoc on all functions, but
+ // test files don't benefit from it.
+ 'jsdoc/require-jsdoc': 'off',
+ // Discourage deprecated Playwright APIs in favor of locators, aligned
+ // with the upstream Gutenberg ESLint configuration.
+ 'no-restricted-syntax': [
+ 'error',
+ {
+ selector: 'CallExpression[callee.property.name="$"]',
+ message: '`$` is discouraged, please use `locator` instead.',
+ },
+ {
+ selector: 'CallExpression[callee.property.name="$$"]',
+ message: '`$$` is discouraged, please use `locator` instead.',
+ },
+ {
+ selector:
+ 'CallExpression[callee.object.name="page"][callee.property.name="waitForTimeout"]',
+ message: 'Prefer page.locator instead.',
+ },
+ ],
+ },
+};
diff --git a/e2e/editor-load.spec.js b/e2e/editor-load.spec.js
new file mode 100644
index 000000000..5f33d175f
--- /dev/null
+++ b/e2e/editor-load.spec.js
@@ -0,0 +1,67 @@
+/**
+ * External dependencies
+ */
+import { test, expect } from '@playwright/test';
+
+/**
+ * Internal dependencies
+ */
+import { setupEditor, getBlocks } from './editor-setup';
+
+test.describe( 'Editor Load', () => {
+ test( 'should load the editor and reach ready state', async ( {
+ page,
+ } ) => {
+ await setupEditor( page );
+
+ await expect(
+ page.locator( '.gutenberg-kit-visual-editor' )
+ ).toBeVisible();
+
+ await expect(
+ page.locator( '.editor-visual-editor__post-title-wrapper' )
+ ).toBeVisible();
+
+ await expect(
+ page.locator( '.block-editor-block-list__layout.is-root-container' )
+ ).toBeVisible();
+
+ await expect(
+ page.locator( '.gutenberg-kit-editor-toolbar' )
+ ).toBeVisible();
+
+ await expect(
+ page.locator( 'button.gutenberg-kit-default-block-appender' )
+ ).toBeAttached();
+ } );
+
+ test( 'should display an empty editor with no initial content', async ( {
+ page,
+ } ) => {
+ await setupEditor( page );
+
+ const blocks = await getBlocks( page );
+ expect( blocks ).toHaveLength( 0 );
+ } );
+
+ test( 'should load the editor with initial content', async ( { page } ) => {
+ const contentHtml =
+ '\n
Hello from E2E
\n';
+
+ await setupEditor( page, {
+ post: {
+ id: 1,
+ type: 'post',
+ status: 'draft',
+ title: '',
+ content: contentHtml,
+ },
+ } );
+
+ const blocks = await getBlocks( page );
+ expect( blocks ).toHaveLength( 1 );
+ expect( blocks[ 0 ].name ).toBe( 'core/paragraph' );
+
+ await expect( page.getByText( 'Hello from E2E' ) ).toBeVisible();
+ } );
+} );
diff --git a/e2e/editor-setup.js b/e2e/editor-setup.js
new file mode 100644
index 000000000..4ca117c25
--- /dev/null
+++ b/e2e/editor-setup.js
@@ -0,0 +1,64 @@
+/**
+ * Shared editor setup helper for E2E tests.
+ *
+ * Navigates to the editor in dev mode, injects the GBKit config, and waits
+ * for the editor to reach a ready state.
+ */
+
+/**
+ * Default GBKit configuration for dev-mode testing.
+ *
+ * @type {Object}
+ */
+const DEFAULT_GBKIT = {
+ post: {
+ id: -1,
+ type: 'post',
+ status: 'draft',
+ title: '',
+ content: '',
+ },
+};
+
+/**
+ * Navigate to the editor and wait for it to be fully ready.
+ *
+ * @param {import('@playwright/test').Page} page Playwright page object.
+ * @param {Object} [gbkit] Optional GBKit config override.
+ */
+export async function setupEditor( page, gbkit = DEFAULT_GBKIT ) {
+ // Inject GBKit config before page scripts run.
+ await page.addInitScript( ( config ) => {
+ window.GBKit = config;
+ }, gbkit );
+
+ // Navigate to the editor in dev mode.
+ await page.goto( '/?dev_mode=1' );
+
+ // Wait for the visual editor container to be visible.
+ await page.locator( '.gutenberg-kit-visual-editor' ).waitFor( {
+ state: 'visible',
+ timeout: 30_000,
+ } );
+
+ // Wait for WordPress editor data store to report ready.
+ await page.waitForFunction(
+ () =>
+ window.wp?.data
+ ?.select( 'core/editor' )
+ ?.__unstableIsEditorReady?.(),
+ { timeout: 30_000 }
+ );
+}
+
+/**
+ * Retrieve all blocks from the editor via the WP data store.
+ *
+ * @param {import('@playwright/test').Page} page Playwright page object.
+ * @return {Promise} Array of block objects.
+ */
+export async function getBlocks( page ) {
+ return await page.evaluate( () =>
+ window.wp.data.select( 'core/block-editor' ).getBlocks()
+ );
+}
diff --git a/e2e/split-merge.spec.js b/e2e/split-merge.spec.js
new file mode 100644
index 000000000..73d2e04ad
--- /dev/null
+++ b/e2e/split-merge.spec.js
@@ -0,0 +1,126 @@
+/**
+ * External dependencies
+ */
+import { test, expect } from '@playwright/test';
+
+/**
+ * Internal dependencies
+ */
+import { setupEditor, getBlocks } from './editor-setup';
+
+test.describe( 'Split and Merge Blocks', () => {
+ test( 'should split a paragraph block with Enter', async ( { page } ) => {
+ await setupEditor( page );
+
+ await page
+ .locator( 'button.gutenberg-kit-default-block-appender' )
+ .click();
+ await page.keyboard.type( 'FirstSecond' );
+
+ // Move caret between "First" and "Second".
+ for ( let i = 0; i < 6; i++ ) {
+ await page.keyboard.press( 'ArrowLeft' );
+ }
+
+ await page.keyboard.press( 'Enter' );
+
+ const blocks = await getBlocks( page );
+ expect( blocks ).toHaveLength( 2 );
+ expect( blocks[ 0 ].attributes.content ).toBe( 'First' );
+ expect( blocks[ 1 ].attributes.content ).toBe( 'Second' );
+ } );
+
+ test( 'should merge two paragraph blocks with Backspace', async ( {
+ page,
+ } ) => {
+ await setupEditor( page );
+
+ // Create two blocks by typing and splitting.
+ await page
+ .locator( 'button.gutenberg-kit-default-block-appender' )
+ .click();
+ await page.keyboard.type( 'FirstSecond' );
+ for ( let i = 0; i < 6; i++ ) {
+ await page.keyboard.press( 'ArrowLeft' );
+ }
+ await page.keyboard.press( 'Enter' );
+
+ // Cursor is now at the start of the second block. Press Backspace to merge.
+ await page.keyboard.press( 'Home' );
+ await page.keyboard.press( 'Backspace' );
+
+ const blocks = await getBlocks( page );
+ expect( blocks ).toHaveLength( 1 );
+ expect( blocks[ 0 ].attributes.content ).toBe( 'FirstSecond' );
+ } );
+
+ test( 'should preserve content after split and merge roundtrip', async ( {
+ page,
+ } ) => {
+ await setupEditor( page );
+
+ const originalText = 'The quick brown fox';
+
+ await page
+ .locator( 'button.gutenberg-kit-default-block-appender' )
+ .click();
+ await page.keyboard.type( originalText );
+
+ // Split in the middle ("The quick" | " brown fox").
+ for ( let i = 0; i < 10; i++ ) {
+ await page.keyboard.press( 'ArrowLeft' );
+ }
+ await page.keyboard.press( 'Enter' );
+
+ let blocks = await getBlocks( page );
+ expect( blocks ).toHaveLength( 2 );
+
+ // Merge back.
+ await page.keyboard.press( 'Home' );
+ await page.keyboard.press( 'Backspace' );
+
+ blocks = await getBlocks( page );
+ expect( blocks ).toHaveLength( 1 );
+ expect( blocks[ 0 ].attributes.content ).toBe( originalText );
+ } );
+
+ test( 'should split and merge preserving inline formatting', async ( {
+ page,
+ } ) => {
+ await setupEditor( page );
+
+ await page
+ .locator( 'button.gutenberg-kit-default-block-appender' )
+ .click();
+
+ // Type "Hello " then bold "World".
+ await page.keyboard.type( 'Hello ' );
+ await page.keyboard.press( 'ControlOrMeta+b' );
+ await page.keyboard.type( 'World' );
+ await page.keyboard.press( 'ControlOrMeta+b' );
+
+ // Split between "Hello " and "World".
+ await page.keyboard.press( 'Home' );
+ for ( let i = 0; i < 6; i++ ) {
+ await page.keyboard.press( 'ArrowRight' );
+ }
+ await page.keyboard.press( 'Enter' );
+
+ let blocks = await getBlocks( page );
+ expect( blocks ).toHaveLength( 2 );
+ expect( blocks[ 0 ].attributes.content ).toBe( 'Hello ' );
+ expect( blocks[ 1 ].attributes.content ).toBe(
+ 'World'
+ );
+
+ // Merge back.
+ await page.keyboard.press( 'Home' );
+ await page.keyboard.press( 'Backspace' );
+
+ blocks = await getBlocks( page );
+ expect( blocks ).toHaveLength( 1 );
+ expect( blocks[ 0 ].attributes.content ).toBe(
+ 'Hello World'
+ );
+ } );
+} );
diff --git a/e2e/text-formatting.spec.js b/e2e/text-formatting.spec.js
new file mode 100644
index 000000000..cfec3c584
--- /dev/null
+++ b/e2e/text-formatting.spec.js
@@ -0,0 +1,74 @@
+/**
+ * External dependencies
+ */
+import { test, expect } from '@playwright/test';
+
+/**
+ * Internal dependencies
+ */
+import { setupEditor, getBlocks } from './editor-setup';
+
+test.describe( 'Text Formatting', () => {
+ test( 'should type text into a new paragraph block', async ( { page } ) => {
+ await setupEditor( page );
+
+ await page
+ .locator( 'button.gutenberg-kit-default-block-appender' )
+ .click();
+ await page.keyboard.type( 'Hello World' );
+
+ const blocks = await getBlocks( page );
+ expect( blocks ).toHaveLength( 1 );
+ expect( blocks[ 0 ].attributes.content ).toBe( 'Hello World' );
+ } );
+
+ test( 'should apply bold formatting', async ( { page } ) => {
+ await setupEditor( page );
+
+ await page
+ .locator( 'button.gutenberg-kit-default-block-appender' )
+ .click();
+ await page.keyboard.type( 'Bold text' );
+ await page.keyboard.press( 'ControlOrMeta+a' );
+ await page.keyboard.press( 'ControlOrMeta+b' );
+
+ const blocks = await getBlocks( page );
+ expect( blocks[ 0 ].attributes.content ).toBe(
+ 'Bold text'
+ );
+ } );
+
+ test( 'should apply italic formatting', async ( { page } ) => {
+ await setupEditor( page );
+
+ await page
+ .locator( 'button.gutenberg-kit-default-block-appender' )
+ .click();
+ await page.keyboard.type( 'Italic text' );
+ await page.keyboard.press( 'ControlOrMeta+a' );
+ await page.keyboard.press( 'ControlOrMeta+i' );
+
+ const blocks = await getBlocks( page );
+ expect( blocks[ 0 ].attributes.content ).toBe( 'Italic text' );
+ } );
+
+ test( 'should apply combined formatting (bold + italic)', async ( {
+ page,
+ } ) => {
+ await setupEditor( page );
+
+ await page
+ .locator( 'button.gutenberg-kit-default-block-appender' )
+ .click();
+ await page.keyboard.type( 'Styled text' );
+ await page.keyboard.press( 'ControlOrMeta+a' );
+ await page.keyboard.press( 'ControlOrMeta+b' );
+ await page.keyboard.press( 'ControlOrMeta+i' );
+
+ const blocks = await getBlocks( page );
+ const content = blocks[ 0 ].attributes.content;
+ expect( content ).toContain( '' );
+ expect( content ).toContain( '' );
+ expect( content ).toContain( 'Styled text' );
+ } );
+} );
diff --git a/package-lock.json b/package-lock.json
index e3860f185..d5f45278a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -68,6 +68,7 @@
"react-dom": "^18.3.1"
},
"devDependencies": {
+ "@playwright/test": "^1.58.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
@@ -3591,6 +3592,22 @@
"url": "https://opencollective.com/unts"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
+ "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@preact/signals": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@preact/signals/-/signals-1.3.2.tgz",
@@ -14713,6 +14730,53 @@
"node": ">=10"
}
},
+ "node_modules/playwright": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
+ "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
+ "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
diff --git a/package.json b/package.json
index 636df1d58..a6b7b759b 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,8 @@
"postinstall": "patch-package && npm run prep-translations && npm run generate-version",
"prep-translations": "node bin/prep-translations.js",
"preview": "vite preview --host",
+ "test:e2e": "playwright test",
+ "test:e2e:ui": "playwright test --ui",
"test:unit": "vitest run",
"test:unit:watch": "vitest"
},
@@ -82,6 +84,7 @@
"react-dom": "^18.3.1"
},
"devDependencies": {
+ "@playwright/test": "^1.58.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
diff --git a/playwright.config.js b/playwright.config.js
new file mode 100644
index 000000000..a856ee123
--- /dev/null
+++ b/playwright.config.js
@@ -0,0 +1,31 @@
+/**
+ * External dependencies
+ */
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig( {
+ testDir: './e2e',
+ outputDir: './e2e/test-results',
+ fullyParallel: false,
+ workers: 1,
+ retries: process.env.CI ? 2 : 0,
+ timeout: 60_000,
+ reporter: process.env.CI ? 'list' : 'html',
+ use: {
+ baseURL: 'http://localhost:5173',
+ actionTimeout: 10_000,
+ trace: 'on-first-retry',
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices[ 'Desktop Chrome' ] },
+ },
+ ],
+ webServer: {
+ command: 'npm run dev',
+ url: 'http://localhost:5173',
+ reuseExistingServer: true,
+ timeout: 30_000,
+ },
+} );
diff --git a/vitest.config.js b/vitest.config.js
index 283dafd82..5680b58cc 100644
--- a/vitest.config.js
+++ b/vitest.config.js
@@ -7,6 +7,7 @@ import react from '@vitejs/plugin-react';
export default defineConfig( {
plugins: [ react() ],
test: {
+ exclude: [ 'e2e/**', 'node_modules/**' ],
setupFiles: [ './vitest.setup.js' ],
css: false,
environment: 'jsdom',