From 4db3f88ce3ae284a9317bf2f0ed8dc5bd8e2eba3 Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Wed, 4 Oct 2023 15:07:27 +0800 Subject: [PATCH 01/14] Fix clipboard read/write on normal elements --- .../src/page-utils/press-keys.ts | 66 +++++++++++++------ packages/scripts/config/playwright.config.js | 8 ++- test/e2e/playwright.config.ts | 19 +++++- test/e2e/specs/editor/blocks/code.spec.js | 11 +++- test/e2e/specs/editor/blocks/gallery.spec.js | 2 +- .../editor/various/copy-cut-paste.spec.js | 6 +- 6 files changed, 82 insertions(+), 30 deletions(-) diff --git a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts index 3b187625fd47c..7d16ae6524e43 100644 --- a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts +++ b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts @@ -38,7 +38,7 @@ let clipboardDataHolder: { * @param clipboardData.plainText * @param clipboardData.html */ -export function setClipboardData( +export async function setClipboardData( this: PageUtils, { plainText = '', html = '' } ) { @@ -47,14 +47,25 @@ export function setClipboardData( 'text/html': html, 'rich-text': '', }; + await this.page.evaluate( + async ( data ) => { + const items: Record< string, Blob > = {}; + for ( const [ type, text ] of Object.entries( data ) ) { + items[ type ] = new Blob( [ text ], { type } ); + } + await navigator.clipboard.write( [ new ClipboardItem( items ) ] ); + }, + { 'text/plain': plainText, 'text/html': html } + ); } async function emulateClipboard( page: Page, type: 'copy' | 'cut' | 'paste' ) { clipboardDataHolder = await page.evaluate( ( [ _type, _clipboardData ] ) => { const canvasDoc = - // @ts-ignore - document.activeElement?.contentDocument ?? document; + document.activeElement instanceof HTMLIFrameElement + ? document.activeElement.contentDocument! + : document; const clipboardDataTransfer = new DataTransfer(); if ( _type === 'paste' ) { @@ -71,7 +82,7 @@ async function emulateClipboard( page: Page, type: 'copy' | 'cut' | 'paste' ) { _clipboardData[ 'rich-text' ] ); } else { - const selection = canvasDoc.defaultView.getSelection()!; + const selection = canvasDoc.defaultView!.getSelection()!; const plainText = selection.toString(); let html = plainText; if ( selection.rangeCount ) { @@ -152,29 +163,40 @@ export async function pressKeys( this.page ); - let command: () => Promise< void >; + const keys = key.split( '+' ).flatMap( ( keyCode ) => { + if ( Object.prototype.hasOwnProperty.call( modifiers, keyCode ) ) { + return modifiers[ keyCode as keyof typeof modifiers ]( + isAppleOS + ).map( ( modifier ) => + modifier === CTRL ? 'Control' : capitalCase( modifier ) + ); + } else if ( keyCode === 'Tab' && ! hasNaturalTabNavigation ) { + return [ 'Alt', 'Tab' ]; + } + return keyCode; + } ); + const normalizedKeys = keys.join( '+' ); + + let command = () => this.page.keyboard.press( normalizedKeys ); if ( key.toLowerCase() === 'primary+c' ) { command = () => emulateClipboard( this.page, 'copy' ); } else if ( key.toLowerCase() === 'primary+x' ) { command = () => emulateClipboard( this.page, 'cut' ); } else if ( key.toLowerCase() === 'primary+v' ) { - command = () => emulateClipboard( this.page, 'paste' ); - } else { - const keys = key.split( '+' ).flatMap( ( keyCode ) => { - if ( Object.prototype.hasOwnProperty.call( modifiers, keyCode ) ) { - return modifiers[ keyCode as keyof typeof modifiers ]( - isAppleOS - ).map( ( modifier ) => - modifier === CTRL ? 'Control' : capitalCase( modifier ) - ); - } else if ( keyCode === 'Tab' && ! hasNaturalTabNavigation ) { - return [ 'Alt', 'Tab' ]; - } - return keyCode; - } ); - const normalizedKeys = keys.join( '+' ); - command = () => this.page.keyboard.press( normalizedKeys ); + command = async () => { + /** + * Do both the emulation and the actual key press for pasting. + * If the element has a `paste` event handler that calls `event.preventDefault()`, + * the `primary+v` key press will not work and be ignored. + * On the other hand, if the element doesn't have a `paste` event handler, + * then the clipboard emulation will not work and be ignored. + * This doesn't work in *all* cases, but it works in most cases we support. + * (The order matters here for unknown reasons.) + */ + await emulateClipboard( this.page, 'paste' ); + await this.page.keyboard.press( normalizedKeys ); + }; } times = times ?? 1; @@ -182,6 +204,8 @@ export async function pressKeys( await command(); if ( times > 1 && pressOptions.delay ) { + // Disable reason: We explicitly want to wait for a specific amount of time. + // eslint-disable-next-line playwright/no-wait-for-timeout await this.page.waitForTimeout( pressOptions.delay ); } } diff --git a/packages/scripts/config/playwright.config.js b/packages/scripts/config/playwright.config.js index 03ccbb848554b..bac6a66d2f657 100644 --- a/packages/scripts/config/playwright.config.js +++ b/packages/scripts/config/playwright.config.js @@ -52,7 +52,13 @@ const config = defineConfig( { projects: [ { name: 'chromium', - use: { ...devices[ 'Desktop Chrome' ] }, + use: { + ...devices[ 'Desktop Chrome' ], + contextOptions: { + // Chromium-specific permissions for clipboard read/write. + permissions: [ 'clipboard-read', 'clipboard-write' ], + }, + }, }, ], } ); diff --git a/test/e2e/playwright.config.ts b/test/e2e/playwright.config.ts index cbbfbac3be415..f0d6ad459c148 100644 --- a/test/e2e/playwright.config.ts +++ b/test/e2e/playwright.config.ts @@ -22,7 +22,13 @@ const config = defineConfig( { projects: [ { name: 'chromium', - use: { ...devices[ 'Desktop Chrome' ] }, + use: { + ...devices[ 'Desktop Chrome' ], + contextOptions: { + // Chromium-specific permissions for clipboard read/write. + permissions: [ 'clipboard-read', 'clipboard-write' ], + }, + }, grepInvert: /-chromium/, }, { @@ -44,7 +50,16 @@ const config = defineConfig( { }, { name: 'firefox', - use: { ...devices[ 'Desktop Firefox' ] }, + use: { + ...devices[ 'Desktop Firefox' ], + launchOptions: { + // Firefox-specific permissions for clipboard read/write. + firefoxUserPrefs: { + 'dom.events.asyncClipboard.clipboardItem': true, + 'dom.events.asyncClipboard.read': true, + }, + }, + }, grep: /@firefox/, grepInvert: /-firefox/, }, diff --git a/test/e2e/specs/editor/blocks/code.spec.js b/test/e2e/specs/editor/blocks/code.spec.js index 6abfb15d10b83..9be5716a6b9cb 100644 --- a/test/e2e/specs/editor/blocks/code.spec.js +++ b/test/e2e/specs/editor/blocks/code.spec.js @@ -40,10 +40,17 @@ test.describe( 'Code', () => { await editor.insertBlock( { name: 'core/code' } ); // Test to see if HTML and white space is kept. - pageUtils.setClipboardData( { plainText: '\n\t
' } ); + await pageUtils.setClipboardData( { + plainText: '\n\t
', + } ); await pageUtils.pressKeys( 'primary+v' ); - expect( await editor.getEditedPostContent() ).toMatchSnapshot(); + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/code', + attributes: { content: '<img />\n\t<br>' }, + }, + ] ); } ); } ); diff --git a/test/e2e/specs/editor/blocks/gallery.spec.js b/test/e2e/specs/editor/blocks/gallery.spec.js index ef693cb8b5bb9..97ab7a0ca2e2e 100644 --- a/test/e2e/specs/editor/blocks/gallery.spec.js +++ b/test/e2e/specs/editor/blocks/gallery.spec.js @@ -47,7 +47,7 @@ test.describe( 'Gallery', () => { } ) => { await admin.createNewPost(); - pageUtils.setClipboardData( { + await pageUtils.setClipboardData( { plainText: `[gallery ids="${ uploadedMedia.id }"]`, } ); diff --git a/test/e2e/specs/editor/various/copy-cut-paste.spec.js b/test/e2e/specs/editor/various/copy-cut-paste.spec.js index 04113e013930b..d7c7f5afcb047 100644 --- a/test/e2e/specs/editor/various/copy-cut-paste.spec.js +++ b/test/e2e/specs/editor/various/copy-cut-paste.spec.js @@ -450,7 +450,7 @@ test.describe( 'Copy/cut/paste', () => { // back to default browser behaviour, allowing the browser to insert // unfiltered HTML. When we swap out the post title in the post editor // with the proper block, this test can be removed. - pageUtils.setClipboardData( { + await pageUtils.setClipboardData( { html: 'Hello World', } ); await pageUtils.pressKeys( 'primary+v' ); @@ -469,7 +469,7 @@ test.describe( 'Copy/cut/paste', () => { } ) => { await page.keyboard.type( 'ab' ); await page.keyboard.press( 'ArrowLeft' ); - pageUtils.setClipboardData( { + await pageUtils.setClipboardData( { html: 'x', } ); await pageUtils.pressKeys( 'primary+v' ); @@ -487,7 +487,7 @@ test.describe( 'Copy/cut/paste', () => { pageUtils, editor, } ) => { - pageUtils.setClipboardData( { + await pageUtils.setClipboardData( { html: '
x
', } ); await editor.insertBlock( { name: 'core/list' } ); From 0987f2680ab33cc69169149e7e929a180a8df105 Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Fri, 6 Oct 2023 17:46:07 +0800 Subject: [PATCH 02/14] Try timeout --- .../src/page-utils/press-keys.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts index 7d16ae6524e43..bbe3cfd047487 100644 --- a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts +++ b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts @@ -194,8 +194,26 @@ export async function pressKeys( * This doesn't work in *all* cases, but it works in most cases we support. * (The order matters here for unknown reasons.) */ - await emulateClipboard( this.page, 'paste' ); + const promise = this.page.evaluate( () => { + return new Promise( ( resolve ) => { + const timeout = setTimeout( () => { + resolve( false ); + }, 500 ); + document.addEventListener( + 'paste', + ( event ) => { + clearTimeout( timeout ); + resolve( !! event.defaultPrevented ); + }, + { once: true } + ); + } ); + } ); await this.page.keyboard.press( normalizedKeys ); + const isDefaultPrevented = await promise; + if ( ! isDefaultPrevented ) { + await emulateClipboard( this.page, 'paste' ); + } }; } From 7474ce6bfe6d9e1e4dfd39c71ece0799edb8e2ed Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Sat, 7 Oct 2023 16:44:35 +0800 Subject: [PATCH 03/14] Fix cross-browser --- .../src/page-utils/press-keys.ts | 58 ++++++++++--------- packages/scripts/config/playwright.config.js | 8 +-- test/e2e/playwright.config.ts | 19 +----- 3 files changed, 35 insertions(+), 50 deletions(-) diff --git a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts index bbe3cfd047487..dc606ce210934 100644 --- a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts +++ b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts @@ -47,16 +47,35 @@ export async function setClipboardData( 'text/html': html, 'rich-text': '', }; - await this.page.evaluate( - async ( data ) => { - const items: Record< string, Blob > = {}; - for ( const [ type, text ] of Object.entries( data ) ) { - items[ type ] = new Blob( [ text ], { type } ); - } - await navigator.clipboard.write( [ new ClipboardItem( items ) ] ); - }, - { 'text/plain': plainText, 'text/html': html } + + const activeElement = await this.page.evaluateHandle( () => + document.activeElement instanceof HTMLIFrameElement + ? document.activeElement.contentDocument!.activeElement + : document.activeElement ); + const inputHandle = await this.page.evaluateHandle( ( data ) => { + const dummyInput = document.createElement( 'input' ); + dummyInput.style.position = 'absolute'; + dummyInput.style.top = '-9999px'; + dummyInput.style.left = '-9999px'; + dummyInput.ariaHidden = 'true'; + dummyInput.addEventListener( 'copy', ( event ) => { + event.preventDefault(); + Object.entries( data ).forEach( ( [ type, text ] ) => { + event.clipboardData?.setData( type, text ); + } ); + } ); + document.body.appendChild( dummyInput ); + return dummyInput; + }, clipboardDataHolder ); + await inputHandle.focus(); + await this.page.keyboard.press( + isAppleOS() ? 'Meta+KeyC' : 'Control+KeyC' + ); + await inputHandle.evaluate( ( input ) => input.remove() ); + await inputHandle.dispose(); + await activeElement.asElement()?.focus(); + await activeElement.dispose(); } async function emulateClipboard( page: Page, type: 'copy' | 'cut' | 'paste' ) { @@ -194,24 +213,11 @@ export async function pressKeys( * This doesn't work in *all* cases, but it works in most cases we support. * (The order matters here for unknown reasons.) */ - const promise = this.page.evaluate( () => { - return new Promise( ( resolve ) => { - const timeout = setTimeout( () => { - resolve( false ); - }, 500 ); - document.addEventListener( - 'paste', - ( event ) => { - clearTimeout( timeout ); - resolve( !! event.defaultPrevented ); - }, - { once: true } - ); - } ); - } ); await this.page.keyboard.press( normalizedKeys ); - const isDefaultPrevented = await promise; - if ( ! isDefaultPrevented ) { + if ( + isAppleOS() && + this.browser.browserType().name() === 'chromium' + ) { await emulateClipboard( this.page, 'paste' ); } }; diff --git a/packages/scripts/config/playwright.config.js b/packages/scripts/config/playwright.config.js index bac6a66d2f657..03ccbb848554b 100644 --- a/packages/scripts/config/playwright.config.js +++ b/packages/scripts/config/playwright.config.js @@ -52,13 +52,7 @@ const config = defineConfig( { projects: [ { name: 'chromium', - use: { - ...devices[ 'Desktop Chrome' ], - contextOptions: { - // Chromium-specific permissions for clipboard read/write. - permissions: [ 'clipboard-read', 'clipboard-write' ], - }, - }, + use: { ...devices[ 'Desktop Chrome' ] }, }, ], } ); diff --git a/test/e2e/playwright.config.ts b/test/e2e/playwright.config.ts index f0d6ad459c148..cbbfbac3be415 100644 --- a/test/e2e/playwright.config.ts +++ b/test/e2e/playwright.config.ts @@ -22,13 +22,7 @@ const config = defineConfig( { projects: [ { name: 'chromium', - use: { - ...devices[ 'Desktop Chrome' ], - contextOptions: { - // Chromium-specific permissions for clipboard read/write. - permissions: [ 'clipboard-read', 'clipboard-write' ], - }, - }, + use: { ...devices[ 'Desktop Chrome' ] }, grepInvert: /-chromium/, }, { @@ -50,16 +44,7 @@ const config = defineConfig( { }, { name: 'firefox', - use: { - ...devices[ 'Desktop Firefox' ], - launchOptions: { - // Firefox-specific permissions for clipboard read/write. - firefoxUserPrefs: { - 'dom.events.asyncClipboard.clipboardItem': true, - 'dom.events.asyncClipboard.read': true, - }, - }, - }, + use: { ...devices[ 'Desktop Firefox' ] }, grep: /@firefox/, grepInvert: /-firefox/, }, From 8941952aa336a97400da1c0c49cb051954d2d9db Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Sun, 8 Oct 2023 01:00:38 +0800 Subject: [PATCH 04/14] Fix focus selection and unstable test --- .../src/page-utils/press-keys.ts | 29 +++++++++++++++---- test/e2e/specs/editor/blocks/cover.spec.js | 22 ++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts index dc606ce210934..d29085582271d 100644 --- a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts +++ b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts @@ -53,6 +53,14 @@ export async function setClipboardData( ? document.activeElement.contentDocument!.activeElement : document.activeElement ); + const frame = await activeElement.asElement()!.ownerFrame(); + const rangeHandle = await frame!.evaluateHandle( () => { + const selection = document.getSelection()!; + if ( ! selection.rangeCount ) { + return null; + } + return selection.getRangeAt( 0 ); + } ); const inputHandle = await this.page.evaluateHandle( ( data ) => { const dummyInput = document.createElement( 'input' ); dummyInput.style.position = 'absolute'; @@ -62,7 +70,9 @@ export async function setClipboardData( dummyInput.addEventListener( 'copy', ( event ) => { event.preventDefault(); Object.entries( data ).forEach( ( [ type, text ] ) => { - event.clipboardData?.setData( type, text ); + if ( text ) { + event.clipboardData?.setData( type, text ); + } } ); } ); document.body.appendChild( dummyInput ); @@ -76,6 +86,14 @@ export async function setClipboardData( await inputHandle.dispose(); await activeElement.asElement()?.focus(); await activeElement.dispose(); + await frame!.evaluate( ( range ) => { + const selection = document.getSelection()!; + if ( range ) { + selection.removeAllRanges(); + selection.addRange( range ); + } + }, rangeHandle ); + await rangeHandle.dispose(); } async function emulateClipboard( page: Page, type: 'copy' | 'cut' | 'paste' ) { @@ -213,11 +231,10 @@ export async function pressKeys( * This doesn't work in *all* cases, but it works in most cases we support. * (The order matters here for unknown reasons.) */ - await this.page.keyboard.press( normalizedKeys ); - if ( - isAppleOS() && - this.browser.browserType().name() === 'chromium' - ) { + if ( isAppleOS() ) { + await this.page.keyboard.press( normalizedKeys ); + } + if ( this.browser.browserType().name() === 'chromium' ) { await emulateClipboard( this.page, 'paste' ); } }; diff --git a/test/e2e/specs/editor/blocks/cover.spec.js b/test/e2e/specs/editor/blocks/cover.spec.js index fa5103ebaa4ee..d9168047581e4 100644 --- a/test/e2e/specs/editor/blocks/cover.spec.js +++ b/test/e2e/specs/editor/blocks/cover.spec.js @@ -72,6 +72,7 @@ test.describe( 'Cover', () => { } ); test( 'dims background image down by 50% with the average image color when an image is uploaded', async ( { + page, editor, coverBlockUtils, } ) => { @@ -80,6 +81,16 @@ test.describe( 'Cover', () => { name: 'Block: Cover', } ); + const deferred = defer(); + + await page.route( + new RegExp( encodeURIComponent( '/wp/v2/media' ) ), + async ( route ) => { + await deferred; + await route.continue(); + } + ); + await coverBlockUtils.upload( coverBlock.getByTestId( 'form-file-upload-input' ) ); @@ -92,6 +103,8 @@ test.describe( 'Cover', () => { 'rgb(179, 179, 179)' ); await expect( overlay ).toHaveCSS( 'opacity', '0.5' ); + + deferred.resolve(); } ); test( 'can have the title edited', async ( { editor } ) => { @@ -256,3 +269,12 @@ class CoverBlockUtils { return filename; } } + +function defer() { + let resolve; + const deferred = new Promise( ( res ) => { + resolve = res; + } ); + deferred.resolve = resolve; + return deferred; +} From 14ab6d60847ba956dba7e9f0996a2eb1908af11c Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Sun, 8 Oct 2023 17:04:08 +0800 Subject: [PATCH 05/14] Rearrange test --- .../src/page-utils/press-keys.ts | 8 ++++---- test/e2e/specs/editor/various/copy-cut-paste.spec.js | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts index d29085582271d..c080aa1f7fd23 100644 --- a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts +++ b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts @@ -82,10 +82,6 @@ export async function setClipboardData( await this.page.keyboard.press( isAppleOS() ? 'Meta+KeyC' : 'Control+KeyC' ); - await inputHandle.evaluate( ( input ) => input.remove() ); - await inputHandle.dispose(); - await activeElement.asElement()?.focus(); - await activeElement.dispose(); await frame!.evaluate( ( range ) => { const selection = document.getSelection()!; if ( range ) { @@ -93,6 +89,10 @@ export async function setClipboardData( selection.addRange( range ); } }, rangeHandle ); + await activeElement.asElement()?.focus(); + await activeElement.dispose(); + await inputHandle.evaluate( ( input ) => input.remove() ); + await inputHandle.dispose(); await rangeHandle.dispose(); } diff --git a/test/e2e/specs/editor/various/copy-cut-paste.spec.js b/test/e2e/specs/editor/various/copy-cut-paste.spec.js index d7c7f5afcb047..772e011d92c8c 100644 --- a/test/e2e/specs/editor/various/copy-cut-paste.spec.js +++ b/test/e2e/specs/editor/various/copy-cut-paste.spec.js @@ -467,11 +467,12 @@ test.describe( 'Copy/cut/paste', () => { pageUtils, editor, } ) => { - await page.keyboard.type( 'ab' ); - await page.keyboard.press( 'ArrowLeft' ); await pageUtils.setClipboardData( { html: 'x', } ); + + await page.keyboard.type( 'ab' ); + await page.keyboard.press( 'ArrowLeft' ); await pageUtils.pressKeys( 'primary+v' ); // Ensure the selection is correct. await page.keyboard.type( 'y' ); From 5b98d2c328ba6ef4249b2762fa9055c6e90ac718 Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Mon, 9 Oct 2023 14:38:32 +0800 Subject: [PATCH 06/14] Different approach --- .../src/page-utils/press-keys.ts | 105 +++++++++--------- .../editor/various/copy-cut-paste.spec.js | 5 +- 2 files changed, 54 insertions(+), 56 deletions(-) diff --git a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts index c080aa1f7fd23..0d9eaa8c3c246 100644 --- a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts +++ b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts @@ -48,52 +48,34 @@ export async function setClipboardData( 'rich-text': '', }; - const activeElement = await this.page.evaluateHandle( () => - document.activeElement instanceof HTMLIFrameElement - ? document.activeElement.contentDocument!.activeElement - : document.activeElement - ); - const frame = await activeElement.asElement()!.ownerFrame(); - const rangeHandle = await frame!.evaluateHandle( () => { - const selection = document.getSelection()!; - if ( ! selection.rangeCount ) { - return null; - } - return selection.getRangeAt( 0 ); - } ); - const inputHandle = await this.page.evaluateHandle( ( data ) => { - const dummyInput = document.createElement( 'input' ); - dummyInput.style.position = 'absolute'; - dummyInput.style.top = '-9999px'; - dummyInput.style.left = '-9999px'; - dummyInput.ariaHidden = 'true'; - dummyInput.addEventListener( 'copy', ( event ) => { - event.preventDefault(); - Object.entries( data ).forEach( ( [ type, text ] ) => { - if ( text ) { - event.clipboardData?.setData( type, text ); - } - } ); - } ); - document.body.appendChild( dummyInput ); - return dummyInput; + // Set the clipboard data for the keyboard press below. + // This is needed for the `paste` event to be fired in case of a real key press. + await this.page.evaluate( ( data ) => { + const activeElement = + document.activeElement instanceof HTMLIFrameElement + ? document.activeElement.contentDocument!.activeElement + : document.activeElement; + activeElement?.addEventListener( + 'copy', + ( event ) => { + event.preventDefault(); + event.stopImmediatePropagation(); + Object.entries( data ).forEach( ( [ type, text ] ) => { + if ( text ) { + ( event as ClipboardEvent ).clipboardData?.setData( + type, + text + ); + } + } ); + }, + { once: true, capture: true } + ); }, clipboardDataHolder ); - await inputHandle.focus(); + await this.page.keyboard.press( isAppleOS() ? 'Meta+KeyC' : 'Control+KeyC' ); - await frame!.evaluate( ( range ) => { - const selection = document.getSelection()!; - if ( range ) { - selection.removeAllRanges(); - selection.addRange( range ); - } - }, rangeHandle ); - await activeElement.asElement()?.focus(); - await activeElement.dispose(); - await inputHandle.evaluate( ( input ) => input.remove() ); - await inputHandle.dispose(); - await rangeHandle.dispose(); } async function emulateClipboard( page: Page, type: 'copy' | 'cut' | 'paste' ) { @@ -223,20 +205,37 @@ export async function pressKeys( } else if ( key.toLowerCase() === 'primary+v' ) { command = async () => { /** - * Do both the emulation and the actual key press for pasting. - * If the element has a `paste` event handler that calls `event.preventDefault()`, - * the `primary+v` key press will not work and be ignored. - * On the other hand, if the element doesn't have a `paste` event handler, - * then the clipboard emulation will not work and be ignored. - * This doesn't work in *all* cases, but it works in most cases we support. - * (The order matters here for unknown reasons.) + * `emulateClipboard()` will not work if the element doesn't have a + * `paste` event handler. In that case, we need to do a real key press. + * We listen for the bubbled `paste` event on the active document to + * determine if the element has a `paste` event handler. This won't work + * if the event handler calls `event.stopPropagation()`, but it's good + * enough for our use cases for now. */ - if ( isAppleOS() ) { + const handledPromise = this.page.evaluate( () => { + const activeElement = + document.activeElement instanceof HTMLIFrameElement + ? document.activeElement.contentDocument!.activeElement + : document.activeElement; + return new Promise( ( resolve ) => { + const animationFrame = requestAnimationFrame( () => { + resolve( false ); + } ); + activeElement?.ownerDocument.addEventListener( + 'paste', + ( event ) => { + cancelAnimationFrame( animationFrame ); + resolve( !! event.defaultPrevented ); + }, + { once: true } + ); + } ); + } ); + await emulateClipboard( this.page, 'paste' ); + const handled = await handledPromise; + if ( ! handled ) { await this.page.keyboard.press( normalizedKeys ); } - if ( this.browser.browserType().name() === 'chromium' ) { - await emulateClipboard( this.page, 'paste' ); - } }; } diff --git a/test/e2e/specs/editor/various/copy-cut-paste.spec.js b/test/e2e/specs/editor/various/copy-cut-paste.spec.js index 772e011d92c8c..d7c7f5afcb047 100644 --- a/test/e2e/specs/editor/various/copy-cut-paste.spec.js +++ b/test/e2e/specs/editor/various/copy-cut-paste.spec.js @@ -467,12 +467,11 @@ test.describe( 'Copy/cut/paste', () => { pageUtils, editor, } ) => { + await page.keyboard.type( 'ab' ); + await page.keyboard.press( 'ArrowLeft' ); await pageUtils.setClipboardData( { html: 'x', } ); - - await page.keyboard.type( 'ab' ); - await page.keyboard.press( 'ArrowLeft' ); await pageUtils.pressKeys( 'primary+v' ); // Ensure the selection is correct. await page.keyboard.type( 'y' ); From c51966620cfd3e7c2a4855f387093f0e3ffd812a Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Mon, 9 Oct 2023 15:30:27 +0800 Subject: [PATCH 07/14] Change to timeout --- .../e2e-test-utils-playwright/src/page-utils/press-keys.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts index 0d9eaa8c3c246..db331205671f1 100644 --- a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts +++ b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts @@ -218,13 +218,13 @@ export async function pressKeys( ? document.activeElement.contentDocument!.activeElement : document.activeElement; return new Promise( ( resolve ) => { - const animationFrame = requestAnimationFrame( () => { + const timeout = setTimeout( () => { resolve( false ); - } ); + }, 50 ); activeElement?.ownerDocument.addEventListener( 'paste', ( event ) => { - cancelAnimationFrame( animationFrame ); + clearTimeout( timeout ); resolve( !! event.defaultPrevented ); }, { once: true } From cb0992ffc1c52db789cadb5351b735a2d94ba961 Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Wed, 11 Oct 2023 13:46:02 +0800 Subject: [PATCH 08/14] Fix copy and cut too --- .../src/page-utils/press-keys.ts | 178 +++++++++++------- 1 file changed, 107 insertions(+), 71 deletions(-) diff --git a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts index db331205671f1..52e6b6a9ae057 100644 --- a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts +++ b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts @@ -79,58 +79,93 @@ export async function setClipboardData( } async function emulateClipboard( page: Page, type: 'copy' | 'cut' | 'paste' ) { - clipboardDataHolder = await page.evaluate( + return await page.evaluateHandle( ( [ _type, _clipboardData ] ) => { - const canvasDoc = + const activeElement = document.activeElement instanceof HTMLIFrameElement - ? document.activeElement.contentDocument! - : document; - const clipboardDataTransfer = new DataTransfer(); - - if ( _type === 'paste' ) { - clipboardDataTransfer.setData( - 'text/plain', - _clipboardData[ 'text/plain' ] - ); - clipboardDataTransfer.setData( - 'text/html', - _clipboardData[ 'text/html' ] - ); - clipboardDataTransfer.setData( - 'rich-text', - _clipboardData[ 'rich-text' ] - ); - } else { - const selection = canvasDoc.defaultView!.getSelection()!; - const plainText = selection.toString(); - let html = plainText; - if ( selection.rangeCount ) { - const range = selection.getRangeAt( 0 ); - const fragment = range.cloneContents(); - html = Array.from( fragment.childNodes ) - .map( - ( node ) => - ( node as Element ).outerHTML ?? - ( node as Element ).nodeValue - ) - .join( '' ); - } - clipboardDataTransfer.setData( 'text/plain', plainText ); - clipboardDataTransfer.setData( 'text/html', html ); - } - - canvasDoc.activeElement?.dispatchEvent( - new ClipboardEvent( _type, { - bubbles: true, - cancelable: true, - clipboardData: clipboardDataTransfer, - } ) - ); + ? document.activeElement.contentDocument!.activeElement + : document.activeElement; + // Return an object with the promise handle to bypass the auto-resolving + // feature of `evaluateHandle()`. return { - 'text/plain': clipboardDataTransfer.getData( 'text/plain' ), - 'text/html': clipboardDataTransfer.getData( 'text/html' ), - 'rich-text': clipboardDataTransfer.getData( 'rich-text' ), + promise: new Promise< false | typeof clipboardDataHolder >( + ( resolve ) => { + const timeout = setTimeout( () => { + resolve( false ); + }, 50 ); + + activeElement?.ownerDocument.addEventListener( + _type, + ( event ) => { + clearTimeout( timeout ); + if ( + _type === 'paste' && + ! event.defaultPrevented + ) { + resolve( false ); + } else { + const selection = + activeElement.ownerDocument.getSelection()!; + const plainText = selection.toString(); + let html = plainText; + if ( selection.rangeCount ) { + const range = selection.getRangeAt( 0 ); + const fragment = range.cloneContents(); + html = Array.from( fragment.childNodes ) + .map( + ( node ) => + ( node as Element ) + .outerHTML ?? + ( node as Element ) + .nodeValue + ) + .join( '' ); + } + resolve( { + 'text/plain': + event.clipboardData?.getData( + 'text/plain' + ) || plainText, + 'text/html': + event.clipboardData?.getData( + 'text/html' + ) || html, + 'rich-text': + event.clipboardData?.getData( + 'rich-text' + ) || '', + } ); + } + }, + { once: true } + ); + + if ( _type === 'paste' ) { + const clipboardDataTransfer = new DataTransfer(); + clipboardDataTransfer.setData( + 'text/plain', + _clipboardData[ 'text/plain' ] + ); + clipboardDataTransfer.setData( + 'text/html', + _clipboardData[ 'text/html' ] + ); + clipboardDataTransfer.setData( + 'rich-text', + _clipboardData[ 'rich-text' ] + ); + + activeElement?.dispatchEvent( + new ClipboardEvent( _type, { + bubbles: true, + cancelable: true, + clipboardData: clipboardDataTransfer, + } ) + ); + } + } + ), }; }, [ type, clipboardDataHolder ] as const @@ -199,9 +234,27 @@ export async function pressKeys( let command = () => this.page.keyboard.press( normalizedKeys ); if ( key.toLowerCase() === 'primary+c' ) { - command = () => emulateClipboard( this.page, 'copy' ); + command = async () => { + const promiseHandle = await emulateClipboard( this.page, 'copy' ); + await this.page.keyboard.press( normalizedKeys ); + const clipboardData = await promiseHandle.evaluate( + ( { promise } ) => promise + ); + if ( clipboardData ) { + clipboardDataHolder = clipboardData; + } + }; } else if ( key.toLowerCase() === 'primary+x' ) { - command = () => emulateClipboard( this.page, 'cut' ); + command = async () => { + const promiseHandle = await emulateClipboard( this.page, 'cut' ); + await this.page.keyboard.press( normalizedKeys ); + const clipboardData = await promiseHandle.evaluate( + ( { promise } ) => promise + ); + if ( clipboardData ) { + clipboardDataHolder = clipboardData; + } + }; } else if ( key.toLowerCase() === 'primary+v' ) { command = async () => { /** @@ -212,27 +265,10 @@ export async function pressKeys( * if the event handler calls `event.stopPropagation()`, but it's good * enough for our use cases for now. */ - const handledPromise = this.page.evaluate( () => { - const activeElement = - document.activeElement instanceof HTMLIFrameElement - ? document.activeElement.contentDocument!.activeElement - : document.activeElement; - return new Promise( ( resolve ) => { - const timeout = setTimeout( () => { - resolve( false ); - }, 50 ); - activeElement?.ownerDocument.addEventListener( - 'paste', - ( event ) => { - clearTimeout( timeout ); - resolve( !! event.defaultPrevented ); - }, - { once: true } - ); - } ); - } ); - await emulateClipboard( this.page, 'paste' ); - const handled = await handledPromise; + const promiseHandle = await emulateClipboard( this.page, 'paste' ); + const handled = await promiseHandle.evaluate( + ( { promise } ) => promise + ); if ( ! handled ) { await this.page.keyboard.press( normalizedKeys ); } From fb2836bb5416e95d5db4970eb1e8e8cfb24401f5 Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Thu, 12 Oct 2023 01:29:23 +0800 Subject: [PATCH 09/14] Add comment and cleanup the code --- .../src/page-utils/press-keys.ts | 70 +++++++++---------- 1 file changed, 32 insertions(+), 38 deletions(-) diff --git a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts index 52e6b6a9ae057..ef565e0e6aeb9 100644 --- a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts +++ b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts @@ -79,7 +79,7 @@ export async function setClipboardData( } async function emulateClipboard( page: Page, type: 'copy' | 'cut' | 'paste' ) { - return await page.evaluateHandle( + const promiseHandle = await page.evaluateHandle( ( [ _type, _clipboardData ] ) => { const activeElement = document.activeElement instanceof HTMLIFrameElement @@ -122,6 +122,8 @@ async function emulateClipboard( page: Page, type: 'copy' | 'cut' | 'paste' ) { ) .join( '' ); } + // Get the clipboard data from the native bubbled event if it's set. + // Otherwise, compute the data from the current selection. resolve( { 'text/plain': event.clipboardData?.getData( @@ -141,6 +143,8 @@ async function emulateClipboard( page: Page, type: 'copy' | 'cut' | 'paste' ) { { once: true } ); + // Only dispatch the virtual events for `paste` events. + // `copy` and `cut` events are handled by the native key presses. if ( _type === 'paste' ) { const clipboardDataTransfer = new DataTransfer(); clipboardDataTransfer.setData( @@ -170,6 +174,30 @@ async function emulateClipboard( page: Page, type: 'copy' | 'cut' | 'paste' ) { }, [ type, clipboardDataHolder ] as const ); + + // For `copy` and `cut` events, we first do a real key press to set the + // native clipboard data for the "real" `paste` event. Then, we listen for + // the bubbled event on the document to set the clipboard data for the + // "virtual" `paste` event. This won't work if the event handler calls + // `event.stopPropagation()`, but it's good enough for our use cases for now. + if ( type === 'copy' ) { + await page.keyboard.press( isAppleOS() ? 'Meta+KeyC' : 'Control+KeyC' ); + } else if ( type === 'cut' ) { + await page.keyboard.press( isAppleOS() ? 'Meta+KeyX' : 'Control+KeyX' ); + } + + const clipboardData = await promiseHandle.evaluate( + ( { promise } ) => promise + ); + if ( clipboardData ) { + clipboardDataHolder = clipboardData; + } else if ( type === 'paste' ) { + // For `paste` events, we do the opposite: We first listen for the bubbled + // virtual event on the document and dispatch it to the active element. + // This won't work for native elements that don't have a `paste` event + // handler, so we then fallback to a real key press. + await page.keyboard.press( isAppleOS() ? 'Meta+KeyV' : 'Control+KeyV' ); + } } const isAppleOS = () => process.platform === 'darwin'; @@ -234,45 +262,11 @@ export async function pressKeys( let command = () => this.page.keyboard.press( normalizedKeys ); if ( key.toLowerCase() === 'primary+c' ) { - command = async () => { - const promiseHandle = await emulateClipboard( this.page, 'copy' ); - await this.page.keyboard.press( normalizedKeys ); - const clipboardData = await promiseHandle.evaluate( - ( { promise } ) => promise - ); - if ( clipboardData ) { - clipboardDataHolder = clipboardData; - } - }; + command = () => emulateClipboard( this.page, 'copy' ); } else if ( key.toLowerCase() === 'primary+x' ) { - command = async () => { - const promiseHandle = await emulateClipboard( this.page, 'cut' ); - await this.page.keyboard.press( normalizedKeys ); - const clipboardData = await promiseHandle.evaluate( - ( { promise } ) => promise - ); - if ( clipboardData ) { - clipboardDataHolder = clipboardData; - } - }; + command = () => emulateClipboard( this.page, 'cut' ); } else if ( key.toLowerCase() === 'primary+v' ) { - command = async () => { - /** - * `emulateClipboard()` will not work if the element doesn't have a - * `paste` event handler. In that case, we need to do a real key press. - * We listen for the bubbled `paste` event on the active document to - * determine if the element has a `paste` event handler. This won't work - * if the event handler calls `event.stopPropagation()`, but it's good - * enough for our use cases for now. - */ - const promiseHandle = await emulateClipboard( this.page, 'paste' ); - const handled = await promiseHandle.evaluate( - ( { promise } ) => promise - ); - if ( ! handled ) { - await this.page.keyboard.press( normalizedKeys ); - } - }; + command = () => emulateClipboard( this.page, 'paste' ); } times = times ?? 1; From 04d457151a3e1662b4c191d593037fedf276f732 Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Thu, 12 Oct 2023 14:01:59 +0800 Subject: [PATCH 10/14] Delete unused snapshot --- .../__snapshots__/Code-should-paste-plain-text-1-chromium.txt | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 test/e2e/specs/editor/blocks/__snapshots__/Code-should-paste-plain-text-1-chromium.txt diff --git a/test/e2e/specs/editor/blocks/__snapshots__/Code-should-paste-plain-text-1-chromium.txt b/test/e2e/specs/editor/blocks/__snapshots__/Code-should-paste-plain-text-1-chromium.txt deleted file mode 100644 index 7c76778feef43..0000000000000 --- a/test/e2e/specs/editor/blocks/__snapshots__/Code-should-paste-plain-text-1-chromium.txt +++ /dev/null @@ -1,3 +0,0 @@ - -
<img />
<br>
- \ No newline at end of file From 24c472d72ce41e31f054fd60566b018fcecdf7ca Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Thu, 12 Oct 2023 14:04:12 +0800 Subject: [PATCH 11/14] Revert unused change --- test/e2e/specs/editor/blocks/cover.spec.js | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/test/e2e/specs/editor/blocks/cover.spec.js b/test/e2e/specs/editor/blocks/cover.spec.js index d9168047581e4..012972d41b9a4 100644 --- a/test/e2e/specs/editor/blocks/cover.spec.js +++ b/test/e2e/specs/editor/blocks/cover.spec.js @@ -81,16 +81,6 @@ test.describe( 'Cover', () => { name: 'Block: Cover', } ); - const deferred = defer(); - - await page.route( - new RegExp( encodeURIComponent( '/wp/v2/media' ) ), - async ( route ) => { - await deferred; - await route.continue(); - } - ); - await coverBlockUtils.upload( coverBlock.getByTestId( 'form-file-upload-input' ) ); @@ -103,8 +93,6 @@ test.describe( 'Cover', () => { 'rgb(179, 179, 179)' ); await expect( overlay ).toHaveCSS( 'opacity', '0.5' ); - - deferred.resolve(); } ); test( 'can have the title edited', async ( { editor } ) => { @@ -269,12 +257,3 @@ class CoverBlockUtils { return filename; } } - -function defer() { - let resolve; - const deferred = new Promise( ( res ) => { - resolve = res; - } ); - deferred.resolve = resolve; - return deferred; -} From 55c091d55677867f37ae0835d550a5911806a23a Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Thu, 12 Oct 2023 15:52:10 +0800 Subject: [PATCH 12/14] Remove unused page --- test/e2e/specs/editor/blocks/cover.spec.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/e2e/specs/editor/blocks/cover.spec.js b/test/e2e/specs/editor/blocks/cover.spec.js index 012972d41b9a4..fa5103ebaa4ee 100644 --- a/test/e2e/specs/editor/blocks/cover.spec.js +++ b/test/e2e/specs/editor/blocks/cover.spec.js @@ -72,7 +72,6 @@ test.describe( 'Cover', () => { } ); test( 'dims background image down by 50% with the average image color when an image is uploaded', async ( { - page, editor, coverBlockUtils, } ) => { From 745f8dae97d287f8abf35c9723a437eac9176c1f Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Wed, 15 Nov 2023 17:58:41 +0800 Subject: [PATCH 13/14] Fix code test --- test/e2e/specs/editor/blocks/code.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/specs/editor/blocks/code.spec.js b/test/e2e/specs/editor/blocks/code.spec.js index 9be5716a6b9cb..91381dee1d299 100644 --- a/test/e2e/specs/editor/blocks/code.spec.js +++ b/test/e2e/specs/editor/blocks/code.spec.js @@ -49,7 +49,7 @@ test.describe( 'Code', () => { await expect.poll( editor.getBlocks ).toMatchObject( [ { name: 'core/code', - attributes: { content: '<img />\n\t<br>' }, + attributes: { content: '<img />
\t<br>' }, }, ] ); } ); From 152583bcdb701e74e66aef7445a58a6cb90eecc3 Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Thu, 16 Nov 2023 01:28:16 +0800 Subject: [PATCH 14/14] Migrate more setClipboardData usages --- test/e2e/specs/editor/various/copy-cut-paste.spec.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/e2e/specs/editor/various/copy-cut-paste.spec.js b/test/e2e/specs/editor/various/copy-cut-paste.spec.js index d7c7f5afcb047..62994d6ccb94b 100644 --- a/test/e2e/specs/editor/various/copy-cut-paste.spec.js +++ b/test/e2e/specs/editor/various/copy-cut-paste.spec.js @@ -503,7 +503,7 @@ test.describe( 'Copy/cut/paste', () => { attributes: { content: 'a' }, } ); await pageUtils.pressKeys( 'primary+a' ); - pageUtils.setClipboardData( { + await pageUtils.setClipboardData( { plainText: 'https://wordpress.org/gutenberg', html: 'https://wordpress.org/gutenberg', } ); @@ -523,7 +523,7 @@ test.describe( 'Copy/cut/paste', () => { name: 'core/paragraph', attributes: { content: 'a' }, } ); - pageUtils.setClipboardData( { + await pageUtils.setClipboardData( { plainText: 'https://wordpress.org/gutenberg', html: 'https://wordpress.org/gutenberg', } ); @@ -541,7 +541,7 @@ test.describe( 'Copy/cut/paste', () => { test( 'should embed on paste', async ( { pageUtils, editor } ) => { await editor.insertBlock( { name: 'core/paragraph' } ); - pageUtils.setClipboardData( { + await pageUtils.setClipboardData( { plainText: 'https://www.youtube.com/watch?v=FcTLMTyD2DU', html: 'https://www.youtube.com/watch?v=FcTLMTyD2DU', } ); @@ -562,7 +562,7 @@ test.describe( 'Copy/cut/paste', () => { }, } ); await pageUtils.pressKeys( 'primary+a' ); - pageUtils.setClipboardData( { + await pageUtils.setClipboardData( { plainText: 'movie: b', html: 'movie: b', } );