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',