diff --git a/.gitignore b/.gitignore index 4a7f4708ce399a..19e43aecea7b82 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ coverage *.log yarn.lock /artifacts +/test/e2e/artifacts /perf-envs /composer.lock diff --git a/packages/e2e-test-utils-playwright/src/request-utils/posts.ts b/packages/e2e-test-utils-playwright/src/request-utils/posts.ts index e02dc11d3842a0..5e32c0c877555c 100644 --- a/packages/e2e-test-utils-playwright/src/request-utils/posts.ts +++ b/packages/e2e-test-utils-playwright/src/request-utils/posts.ts @@ -7,6 +7,7 @@ export interface Post { id: number; content: string; status: 'publish' | 'future' | 'draft' | 'pending' | 'private'; + link: string; } export interface CreatePostPayload { diff --git a/packages/e2e-tests/plugins/interactive-blocks.php b/packages/e2e-tests/plugins/interactive-blocks.php new file mode 100644 index 00000000000000..8a44e5a0efd4ac --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks.php @@ -0,0 +1,48 @@ + +
+ + + + + + + + + + + + + + +

+ Some Text +

+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-bind/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-bind/view.js new file mode 100644 index 00000000000000..0cd590f184cc85 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-bind/view.js @@ -0,0 +1,23 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + state: { + url: '/some-url', + checked: true, + show: false, + width: 1, + }, + foo: { + bar: 1, + }, + actions: { + toggle: ( { state, foo } ) => { + state.url = '/some-other-url'; + state.checked = ! state.checked; + state.show = ! state.show; + state.width += foo.bar; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-class/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-class/block.json new file mode 100644 index 00000000000000..af2764db986919 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-class/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-class", + "title": "E2E Interactivity tests - directive class", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-class-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php new file mode 100644 index 00000000000000..19da052dc1148c --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php @@ -0,0 +1,75 @@ + +
+ + + + +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-class/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-class/view.js new file mode 100644 index 00000000000000..bb06cf38412811 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-class/view.js @@ -0,0 +1,21 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + state: { + trueValue: true, + falseValue: false, + }, + actions: { + toggleTrueValue: ( { state } ) => { + state.trueValue = ! state.trueValue; + }, + toggleFalseValue: ( { state } ) => { + state.falseValue = ! state.falseValue; + }, + toggleContextFalseValue: ( { context } ) => { + context.falseValue = ! context.falseValue; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-context/block.json new file mode 100644 index 00000000000000..1b3c448cc62aac --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-context", + "title": "E2E Interactivity tests - directive context", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-context-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php new file mode 100644 index 00000000000000..a9b0402d1b094e --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php @@ -0,0 +1,121 @@ + +
+
+
+			
+		
+ + + + +
+
+				
+			
+ + + + + + +
+
+ + +
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js new file mode 100644 index 00000000000000..46483aaa2ea53d --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js @@ -0,0 +1,22 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + derived: { + renderContext: ( { context } ) => { + return JSON.stringify( context, undefined, 2 ); + }, + }, + actions: { + updateContext: ( { context, event } ) => { + const { name, value } = event.target; + const [ key, ...path ] = name.split( '.' ).reverse(); + const obj = path.reduceRight( ( o, k ) => o[ k ], context ); + obj[ key ] = value; + }, + toggleContextText: ( { context } ) => { + context.text = context.text === 'Text 1' ? 'Text 2' : 'Text 1'; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json new file mode 100644 index 00000000000000..b9cb2f782b2e6f --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-effect", + "title": "E2E Interactivity tests - directive effect", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-effect-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/render.php new file mode 100644 index 00000000000000..243826aae35046 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/render.php @@ -0,0 +1,27 @@ + +
+
+ +
+ +
+ +
+ + +
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/view.js new file mode 100644 index 00000000000000..debb363a9ac0fd --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/view.js @@ -0,0 +1,61 @@ +( ( { wp } ) => { + const { store, directive, useContext, useMemo } = wp.interactivity; + + // Fake `data-wp-fakeshow` directive to test when things are removed from the DOM. + // Replace with `data-wp-show` when it's ready. + directive( + 'fakeshow', + ( { + directives: { + fakeshow: { default: fakeshow }, + }, + element, + evaluate, + context, + } ) => { + const contextValue = useContext( context ); + const children = useMemo( + () => + element.type === 'template' + ? element.props.templateChildren + : element, + [] + ); + if ( ! evaluate( fakeshow, { context: contextValue } ) ) return null; + return children; + } + ); + + store( { + state: { + isOpen: true, + isElementInTheDOM: false, + }, + selectors: { + elementInTheDOM: ( { state } ) => + state.isElementInTheDOM + ? 'element is in the DOM' + : 'element is not in the DOM', + }, + actions: { + toggle( { state } ) { + state.isOpen = ! state.isOpen; + }, + }, + effects: { + elementAddedToTheDOM: ( { state } ) => { + state.isElementInTheDOM = true; + + return () => { + state.isElementInTheDOM = false; + }; + }, + changeFocus: ( { state } ) => { + if ( state.isOpen ) { + document.querySelector( "[data-testid='input']" ).focus(); + } + }, + }, + } ); + +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/block.json new file mode 100644 index 00000000000000..c7361c3d5f121a --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-priorities", + "title": "E2E Interactivity tests - directive priorities", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-priorities-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/render.php new file mode 100644 index 00000000000000..a5f0b045ab0d67 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/render.php @@ -0,0 +1,20 @@ + +
+

+
+	
+	
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js new file mode 100644 index 00000000000000..cedc0c7c1d3ad3 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js @@ -0,0 +1,121 @@ +( ( { wp } ) => { + /** + * WordPress dependencies + */ + const { + store, + directive, + deepSignal, + useContext, + useEffect, + createElement: h + } = wp.interactivity; + + /** + * Util to check that render calls happen in order. + * + * @param {string} n Name passed from the directive being executed. + */ + const executionProof = ( n ) => { + const el = document.querySelector( '[data-testid="execution order"]' ); + if ( ! el.textContent ) el.textContent = n; + else el.textContent += `, ${ n }`; + }; + + /** + * Simple context directive, just for testing purposes. It provides a deep + * signal with these two properties: + * - attribute: 'from context' + * - text: 'from context' + */ + directive( + 'test-context', + ( { context: { Provider }, props: { children } } ) => { + executionProof( 'context' ); + const value = deepSignal( { + attribute: 'from context', + text: 'from context', + } ); + return h( Provider, { value }, children ); + }, + { priority: 8 } + ); + + /** + * Simple attribute directive, for testing purposes. It reads the value of + * `attribute` from context and populates `data-attribute` with it. + */ + directive( 'test-attribute', ( { context, evaluate, element } ) => { + executionProof( 'attribute' ); + const contextValue = useContext( context ); + const attributeValue = evaluate( 'context.attribute', { + context: contextValue, + } ); + useEffect( () => { + element.ref.current.setAttribute( + 'data-attribute', + attributeValue, + ); + }, [] ); + element.props[ 'data-attribute' ] = attributeValue; + } ); + + /** + * Simple text directive, for testing purposes. It reads the value of + * `text` from context and populates `children` with it. + */ + directive( + 'test-text', + ( { context, evaluate, element } ) => { + executionProof( 'text' ); + const contextValue = useContext( context ); + const textValue = evaluate( 'context.text', { + context: contextValue, + } ); + element.props.children = + h( 'p', { 'data-testid': 'text' }, textValue ); + }, + { priority: 12 } + ); + + /** + * Children directive, for testing purposes. It adds a wrapper around + * `children`, including two buttons to modify `text` and `attribute` values + * from the received context. + */ + directive( + 'test-children', + ( { context, evaluate, element } ) => { + executionProof( 'children' ); + const contextValue = useContext( context ); + const updateAttribute = () => { + evaluate( + 'actions.updateAttribute', + { context: contextValue } + ); + }; + const updateText = () => { + evaluate( 'actions.updateText', { context: contextValue } ); + }; + element.props.children = h( + 'div', + {}, + element.props.children, + h( 'button', { onClick: updateAttribute }, 'Update attribute' ), + h( 'button', { onClick: updateText }, 'Update text' ) + ); + }, + { priority: 14 } + ); + + store( { + actions: { + updateText( { context } ) { + context.text = 'updated'; + }, + updateAttribute( { context } ) { + context.attribute = 'updated'; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-show/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-show/block.json new file mode 100644 index 00000000000000..ab6801843a7cae --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-show/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-show", + "title": "E2E Interactivity tests - directive show", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-show-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-show/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-show/render.php new file mode 100644 index 00000000000000..5818f3b7c10b63 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-show/render.php @@ -0,0 +1,53 @@ + +
+ + + + +
+

trueValue children

+
+ +
+

falseValue children

+
+ +
+
+ falseValue +
+ +
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-show/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-show/view.js new file mode 100644 index 00000000000000..9398321b4cfe43 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-show/view.js @@ -0,0 +1,24 @@ +( ( { wp } ) => { + /** + * WordPress dependencies + */ + const { store } = wp.interactivity; + + store( { + state: { + trueValue: true, + falseValue: false, + }, + actions: { + toggleTrueValue: ( { state } ) => { + state.trueValue = ! state.trueValue; + }, + toggleFalseValue: ( { state } ) => { + state.falseValue = ! state.falseValue; + }, + toggleContextFalseValue: ( { context } ) => { + context.falseValue = ! context.falseValue; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-text/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-text/block.json new file mode 100644 index 00000000000000..7295849b9912d7 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-text/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-text", + "title": "E2E Interactivity tests - directive text", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-text-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-text/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-text/render.php new file mode 100644 index 00000000000000..54ac9c09e7d863 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-text/render.php @@ -0,0 +1,35 @@ + +
+
+ + +
+ +
+ + +
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-text/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-text/view.js new file mode 100644 index 00000000000000..49121213f2b04e --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-text/view.js @@ -0,0 +1,17 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + state: { + text: 'Text 1', + }, + actions: { + toggleStateText: ( { state } ) => { + state.text = state.text === 'Text 1' ? 'Text 2' : 'Text 1'; + }, + toggleContextText: ( { context } ) => { + context.text = context.text === 'Text 1' ? 'Text 2' : 'Text 1'; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/negation-operator/block.json b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/block.json new file mode 100644 index 00000000000000..68da53367ad63a --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/negation-operator", + "title": "E2E Interactivity tests - negation operator", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "negation-operator-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/negation-operator/render.php b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/render.php new file mode 100644 index 00000000000000..e087a5eceb8369 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/render.php @@ -0,0 +1,26 @@ + +
+ + +
+ +
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js new file mode 100644 index 00000000000000..64a84269c356e3 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js @@ -0,0 +1,22 @@ +( ( { wp } ) => { + /** + * WordPress dependencies + */ + const { store } = wp.interactivity; + + store( { + selectors: { + active: ( { state } ) => { + return state.active; + }, + }, + state: { + active: false, + }, + actions: { + toggle: ( { state } ) => { + state.active = ! state.active; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/block.json b/packages/e2e-tests/plugins/interactive-blocks/store-tag/block.json new file mode 100644 index 00000000000000..4611288de796c9 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/store-tag", + "title": "E2E Interactivity tests - store tag", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "store-tag-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php b/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php new file mode 100644 index 00000000000000..9bc8126720b9b9 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php @@ -0,0 +1,64 @@ + +
+
+ Counter: + +
+ Double: + +
+ + 0 + clicks +
+
+ + $test_store_tag_json + +HTML; +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js b/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js new file mode 100644 index 00000000000000..140cab6463137f --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js @@ -0,0 +1,24 @@ +( ( { wp } ) => { + /** + * WordPress dependencies + */ + const { store } = wp.interactivity; + + store( { + state: { + counter: { + // `value` is defined in the server. + double: ( { state } ) => state.counter.value * 2, + clicks: 0, + }, + }, + actions: { + counter: { + increment: ( { state } ) => { + state.counter.value += 1; + state.counter.clicks += 1; + }, + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/block.json b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/block.json new file mode 100644 index 00000000000000..fb852acefb9116 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/tovdom-islands", + "title": "E2E Interactivity tests - tovdom islands", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "tovdom-islands-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php new file mode 100644 index 00000000000000..aa0f2e68d3b7cf --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php @@ -0,0 +1,66 @@ + +
+
+ + This should be shown because it is inside an island. + +
+ +
+
+ + This should not be shown because it is inside an island. + +
+
+ +
+
+
+ + This should be shown because it is inside an inner + block of an isolated island. + +
+
+
+ +
+
+
+ + This should not have two template wrappers because + that means we hydrated twice. + +
+
+
+ +
+
+
+
+ + This should not be shown because even though it + is inside an inner block of an isolated island, + it's inside an new island. + +
+
+
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/view.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/view.js new file mode 100644 index 00000000000000..3ccb09203e6bb6 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/view.js @@ -0,0 +1,9 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + state: { + falseValue: false, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/block.json b/packages/e2e-tests/plugins/interactive-blocks/tovdom/block.json new file mode 100644 index 00000000000000..b685919e164821 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/tovdom", + "title": "E2E Interactivity tests - tovdom", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "tovdom-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/cdata.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom/cdata.js new file mode 100644 index 00000000000000..506e899e42850c --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/cdata.js @@ -0,0 +1,15 @@ +const cdata = ` +
+ +
+ +
+
+ `; + +const cdataElement = new DOMParser() + .parseFromString( cdata, 'text/xml' ) + .querySelector( 'div' ); +document + .getElementById( 'replace-with-cdata' ) + .replaceWith( cdataElement ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/processing-instructions.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom/processing-instructions.js new file mode 100644 index 00000000000000..b65095ef6cde4f --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/processing-instructions.js @@ -0,0 +1,16 @@ +const processingInstructions = ` +
+ +
+ Processing instructions inner node + +
+
+ `; + +const processingInstructionsElement = new DOMParser() + .parseFromString( processingInstructions, 'text/xml' ) + .querySelector( 'div' ); +document + .getElementById( 'replace-with-processing-instructions' ) + .replaceWith( processingInstructionsElement ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php b/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php new file mode 100644 index 00000000000000..952a4f6c0a455d --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php @@ -0,0 +1,33 @@ + + +
+
+ +
+ Comments inner node + +
+
+ +
+
+
+ + + +
+
+
+ + +
diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js new file mode 100644 index 00000000000000..734ccbd801bb1e --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js @@ -0,0 +1,5 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( {} ); +} )( window ); diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js index 5ecdcf6c4d6b93..ca87f7655dba68 100644 --- a/packages/interactivity/src/directives.js +++ b/packages/interactivity/src/directives.js @@ -175,21 +175,23 @@ export default () => { } ); - // data-wp-text + // data-wp-show directive( - 'text', + 'show', ( { directives: { - text: { default: text }, + show: { default: show }, }, element, evaluate, context, } ) => { const contextValue = useContext( context ); - element.props.children = evaluate( text, { - context: contextValue, - } ); + + if ( ! evaluate( show, { context: contextValue } ) ) + element.props.children = ( + + ); } ); @@ -212,4 +214,22 @@ export default () => { ); } ); + + // data-wp-text + directive( + 'text', + ( { + directives: { + text: { default: text }, + }, + element, + evaluate, + context, + } ) => { + const contextValue = useContext( context ); + element.props.children = evaluate( text, { + context: contextValue, + } ); + } + ); }; diff --git a/packages/interactivity/src/index.js b/packages/interactivity/src/index.js index 71b6a2b790704d..9d1b6b695b66b7 100644 --- a/packages/interactivity/src/index.js +++ b/packages/interactivity/src/index.js @@ -4,6 +4,10 @@ import registerDirectives from './directives'; import { init } from './hydration'; export { store } from './store'; +export { directive } from './hooks'; +export { h as createElement } from 'preact'; +export { useEffect, useContext, useMemo } from 'preact/hooks'; +export { deepSignal } from 'deepsignal'; /** * Initialize the Interactivity API. diff --git a/test/e2e/specs/interactivity/directive-bind.spec.ts b/test/e2e/specs/interactivity/directive-bind.spec.ts new file mode 100644 index 00000000000000..67ee1232a6798e --- /dev/null +++ b/test/e2e/specs/interactivity/directive-bind.spec.ts @@ -0,0 +1,96 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-bind', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-bind' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-bind' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'add missing href at hydration', async ( { page } ) => { + const el = page.getByTestId( 'add missing href at hydration' ); + await expect( el ).toHaveAttribute( 'href', '/some-url' ); + } ); + + test( 'change href at hydration', async ( { page } ) => { + const el = page.getByTestId( 'change href at hydration' ); + await expect( el ).toHaveAttribute( 'href', '/some-url' ); + } ); + + test( 'update missing href at hydration', async ( { page } ) => { + const el = page.getByTestId( 'add missing href at hydration' ); + await expect( el ).toHaveAttribute( 'href', '/some-url' ); + await page.getByTestId( 'toggle' ).click(); + await expect( el ).toHaveAttribute( 'href', '/some-other-url' ); + } ); + + test( 'add missing checked at hydration', async ( { page } ) => { + const el = page.getByTestId( 'add missing checked at hydration' ); + await expect( el ).toHaveAttribute( 'checked', '' ); + } ); + + test( 'remove existing checked at hydration', async ( { page } ) => { + const el = page.getByTestId( 'remove existing checked at hydration' ); + await expect( el ).not.toHaveAttribute( 'checked', '' ); + } ); + + test( 'update existing checked', async ( { page } ) => { + const el = page.getByTestId( 'add missing checked at hydration' ); + const el2 = page.getByTestId( 'remove existing checked at hydration' ); + let checked = await el.evaluate( + ( element: HTMLInputElement ) => element.checked + ); + let checked2 = await el2.evaluate( + ( element: HTMLInputElement ) => element.checked + ); + expect( checked ).toBe( true ); + expect( checked2 ).toBe( false ); + await page.getByTestId( 'toggle' ).click(); + checked = await el.evaluate( + ( element: HTMLInputElement ) => element.checked + ); + checked2 = await el2.evaluate( + ( element: HTMLInputElement ) => element.checked + ); + expect( checked ).toBe( false ); + expect( checked2 ).toBe( true ); + } ); + + test( 'nested binds', async ( { page } ) => { + const el = page.getByTestId( 'nested binds - 1' ); + await expect( el ).toHaveAttribute( 'href', '/some-url' ); + const el2 = page.getByTestId( 'nested binds - 2' ); + await expect( el2 ).toHaveAttribute( 'width', '1' ); + await page.getByTestId( 'toggle' ).click(); + await expect( el ).toHaveAttribute( 'href', '/some-other-url' ); + await expect( el2 ).toHaveAttribute( 'width', '2' ); + } ); + + test( 'check enumerated attributes with true/false values', async ( { + page, + } ) => { + const el = page.getByTestId( + 'check enumerated attributes with true/false exist and have a string value' + ); + await expect( el ).toHaveAttribute( 'hidden', '' ); + await expect( el ).toHaveAttribute( 'aria-hidden', 'true' ); + await expect( el ).toHaveAttribute( 'aria-expanded', 'false' ); + await expect( el ).toHaveAttribute( 'data-some-value', 'false' ); + await page.getByTestId( 'toggle' ).click(); + await expect( el ).not.toHaveAttribute( 'hidden', '' ); + await expect( el ).toHaveAttribute( 'aria-hidden', 'false' ); + await expect( el ).toHaveAttribute( 'aria-expanded', 'true' ); + await expect( el ).toHaveAttribute( 'data-some-value', 'true' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directive-effect.spec.ts b/test/e2e/specs/interactivity/directive-effect.spec.ts new file mode 100644 index 00000000000000..2ec52444cfd9b8 --- /dev/null +++ b/test/e2e/specs/interactivity/directive-effect.spec.ts @@ -0,0 +1,39 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-effect', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-effect' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-effect' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'check that effect runs when it is added', async ( { page } ) => { + const el = page.getByTestId( 'element in the DOM' ); + await expect( el ).toContainText( 'element is in the DOM' ); + } ); + + test( 'check that effect runs when it is removed', async ( { page } ) => { + await page.getByTestId( 'toggle' ).click(); + const el = page.getByTestId( 'element in the DOM' ); + await expect( el ).toContainText( 'element is not in the DOM' ); + } ); + + test( 'change focus after DOM changes', async ( { page } ) => { + const el = page.getByTestId( 'input' ); + await expect( el ).toBeFocused(); + await page.getByTestId( 'toggle' ).click(); + await page.getByTestId( 'toggle' ).click(); + await expect( el ).toBeFocused(); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directive-priorities.spec.ts b/test/e2e/specs/interactivity/directive-priorities.spec.ts new file mode 100644 index 00000000000000..1734d6f5aecc36 --- /dev/null +++ b/test/e2e/specs/interactivity/directive-priorities.spec.ts @@ -0,0 +1,84 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'Directives (w/ priority)', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-priorities' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-priorities' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'should run in priority order', async ( { page } ) => { + const executionOrder = page.getByTestId( 'execution order' ); + await expect( executionOrder ).toHaveText( + 'context, attribute, text, children' + ); + } ); + + test( 'should wrap those with less priority', async ( { page } ) => { + // Check that attribute value is correctly received from Provider. + const element = page.getByTestId( 'test directives' ); + await expect( element ).toHaveAttribute( + 'data-attribute', + 'from context' + ); + + // Check that text value is correctly received from Provider, and text + // wrapped with an element with `data-testid=text`. + const text = element.getByTestId( 'text' ); + await expect( text ).toHaveText( 'from context' ); + } ); + + test( 'should propagate element modifications top-down', async ( { + page, + } ) => { + const executionOrder = page.getByTestId( 'execution order' ); + const element = page.getByTestId( 'test directives' ); + const text = element.getByTestId( 'text' ); + + // Get buttons. + const updateAttribute = element.getByRole( 'button', { + name: 'Update attribute', + } ); + const updateText = element.getByRole( 'button', { + name: 'Update text', + } ); + + // Modify `attribute` inside context. This triggers a re-render for the + // component that wraps the `attribute` directive, evaluating it again. + // Nested components are re-rendered as well, so their directives are + // also re-evaluated (note how `text` and `children` have run). + await updateAttribute.click(); + await expect( element ).toHaveAttribute( 'data-attribute', 'updated' ); + await expect( executionOrder ).toHaveText( + [ + 'context, attribute, text, children', + 'attribute, text, children', + ].join( ', ' ) + ); + + // Modify `text` inside context. This triggers a re-render of the + // component that wraps the `text` directive. In this case, only + // `children` run as well, right after `text`. + await updateText.click(); + await expect( element ).toHaveAttribute( 'data-attribute', 'updated' ); + await expect( text ).toHaveText( 'updated' ); + await expect( executionOrder ).toHaveText( + [ + 'context, attribute, text, children', + 'attribute, text, children', + 'text, children', + ].join( ', ' ) + ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directives-class.spec.ts b/test/e2e/specs/interactivity/directives-class.spec.ts new file mode 100644 index 00000000000000..c0ec77e14d0567 --- /dev/null +++ b/test/e2e/specs/interactivity/directives-class.spec.ts @@ -0,0 +1,101 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-class', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-class' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-class' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'remove class if callback returns falsy value', async ( { + page, + } ) => { + const el = page.getByTestId( + 'remove class if callback returns falsy value' + ); + await expect( el ).toHaveClass( 'bar' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( 'foo bar' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( 'bar' ); + } ); + + test( 'add class if callback returns truthy value', async ( { page } ) => { + const el = page.getByTestId( + 'add class if callback returns truthy value' + ); + await expect( el ).toHaveClass( 'foo bar' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo bar' ); + } ); + + test( 'handles multiple classes and callbacks', async ( { page } ) => { + const el = page.getByTestId( 'handles multiple classes and callbacks' ); + await expect( el ).toHaveClass( 'bar baz' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( '' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'bar baz' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( 'foo bar baz' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo' ); + } ); + + test( 'handles class names that are contained inside other class names', async ( { + page, + } ) => { + const el = page.getByTestId( + 'handles class names that are contained inside other class names' + ); + await expect( el ).toHaveClass( 'foo-bar' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( 'foo foo-bar' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo' ); + } ); + + test( 'can toggle class in the middle', async ( { page } ) => { + const el = page.getByTestId( 'can toggle class in the middle' ); + await expect( el ).toHaveClass( 'foo bar baz' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo baz' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo bar baz' ); + } ); + + test( 'can toggle class when class attribute is missing', async ( { + page, + } ) => { + const el = page.getByTestId( + 'can toggle class when class attribute is missing' + ); + await expect( el ).toHaveClass( '' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( 'foo' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( '' ); + } ); + + test( 'can use context values', async ( { page } ) => { + const el = page.getByTestId( 'can use context values' ); + await expect( el ).toHaveClass( '' ); + await page.getByTestId( 'toggle context false value' ).click(); + await expect( el ).toHaveClass( 'foo' ); + await page.getByTestId( 'toggle context false value' ).click(); + await expect( el ).toHaveClass( '' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directives-context.spec.ts b/test/e2e/specs/interactivity/directives-context.spec.ts new file mode 100644 index 00000000000000..5c74e8054bf19d --- /dev/null +++ b/test/e2e/specs/interactivity/directives-context.spec.ts @@ -0,0 +1,165 @@ +/** + * External dependencies + */ +import type { Locator } from '@playwright/test'; +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +const parseContent = async ( loc: Locator ) => + JSON.parse( ( await loc.textContent() ) || '' ); + +test.describe( 'data-wp-context', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-context' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-context' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'is correctly initialized', async ( { page } ) => { + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext ).toMatchObject( { + prop1: 'parent', + prop2: 'parent', + obj: { prop4: 'parent', prop5: 'parent' }, + array: [ 1, 2, 3 ], + } ); + } ); + + test( 'is correctly extended', async ( { page } ) => { + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext ).toMatchObject( { + prop1: 'parent', + prop2: 'child', + prop3: 'child', + obj: { prop4: 'parent', prop5: 'child', prop6: 'child' }, + array: [ 4, 5, 6 ], + } ); + } ); + + test( 'changes in inherited properties are reflected (child)', async ( { + page, + } ) => { + await page.getByTestId( 'child prop1' ).click(); + await page.getByTestId( 'child obj.prop4' ).click(); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext.prop1 ).toBe( 'modifiedFromChild' ); + expect( childContext.obj.prop4 ).toBe( 'modifiedFromChild' ); + + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext.prop1 ).toBe( 'modifiedFromChild' ); + expect( parentContext.obj.prop4 ).toBe( 'modifiedFromChild' ); + } ); + + test( 'changes in inherited properties are reflected (parent)', async ( { + page, + } ) => { + await page.getByTestId( 'parent prop1' ).click(); + await page.getByTestId( 'parent obj.prop4' ).click(); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext.prop1 ).toBe( 'modifiedFromParent' ); + expect( childContext.obj.prop4 ).toBe( 'modifiedFromParent' ); + + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext.prop1 ).toBe( 'modifiedFromParent' ); + expect( parentContext.obj.prop4 ).toBe( 'modifiedFromParent' ); + } ); + + test( 'changes in shadowed properties do not leak (child)', async ( { + page, + } ) => { + await page.getByTestId( 'child prop2' ).click(); + await page.getByTestId( 'child obj.prop5' ).click(); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext.prop2 ).toBe( 'modifiedFromChild' ); + expect( childContext.obj.prop5 ).toBe( 'modifiedFromChild' ); + + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext.prop2 ).toBe( 'parent' ); + expect( parentContext.obj.prop5 ).toBe( 'parent' ); + } ); + + test( 'changes in shadowed properties do not leak (parent)', async ( { + page, + } ) => { + await page.getByTestId( 'parent prop2' ).click(); + await page.getByTestId( 'parent obj.prop5' ).click(); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext.prop2 ).toBe( 'child' ); + expect( childContext.obj.prop5 ).toBe( 'child' ); + + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext.prop2 ).toBe( 'modifiedFromParent' ); + expect( parentContext.obj.prop5 ).toBe( 'modifiedFromParent' ); + } ); + + test( 'Array properties are shadowed', async ( { page } ) => { + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( parentContext.array ).toMatchObject( [ 1, 2, 3 ] ); + expect( childContext.array ).toMatchObject( [ 4, 5, 6 ] ); + } ); + + test( 'can be accessed in other directives on the same element', async ( { + page, + } ) => { + const element = page.getByTestId( 'context & other directives' ); + await expect( element ).toHaveText( 'Text 1' ); + await expect( element ).toHaveAttribute( 'value', 'Text 1' ); + await element.click(); + await expect( element ).toHaveText( 'Text 2' ); + await expect( element ).toHaveAttribute( 'value', 'Text 2' ); + await element.click(); + await expect( element ).toHaveText( 'Text 1' ); + await expect( element ).toHaveAttribute( 'value', 'Text 1' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directives-show.spec.ts b/test/e2e/specs/interactivity/directives-show.spec.ts new file mode 100644 index 00000000000000..7e25332ae143c5 --- /dev/null +++ b/test/e2e/specs/interactivity/directives-show.spec.ts @@ -0,0 +1,59 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-show', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-show' ); + } ); + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-show' ) ); + } ); + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'show if callback returns truthy value', async ( { page } ) => { + const el = page.getByTestId( 'show if callback returns truthy value' ); + await expect( el ).toBeVisible(); + } ); + + test( 'do not show if callback returns falsy value', async ( { page } ) => { + const el = page.getByTestId( + 'do not show if callback returns false value' + ); + await expect( el ).toBeHidden(); + } ); + + test( 'hide when toggling truthy value to falsy', async ( { page } ) => { + const el = page.getByTestId( 'show if callback returns truthy value' ); + await expect( el ).toBeVisible(); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toBeHidden(); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toBeVisible(); + } ); + + test( 'show when toggling false value to truthy', async ( { page } ) => { + const el = page.getByTestId( + 'do not show if callback returns false value' + ); + await expect( el ).toBeHidden(); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toBeVisible(); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toBeHidden(); + } ); + + test( 'can use context values', async ( { page } ) => { + const el = page.getByTestId( 'can use context values' ); + await expect( el ).toBeHidden(); + await page.getByTestId( 'toggle context false value' ).click(); + await expect( el ).toBeVisible(); + await page.getByTestId( 'toggle context false value' ).click(); + await expect( el ).toBeHidden(); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directives-text.spec.ts b/test/e2e/specs/interactivity/directives-text.spec.ts new file mode 100644 index 00000000000000..8e83be26de15ca --- /dev/null +++ b/test/e2e/specs/interactivity/directives-text.spec.ts @@ -0,0 +1,36 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-text', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-text' ); + } ); + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-text' ) ); + } ); + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'show proper text reading from state', async ( { page } ) => { + const el = page.getByTestId( 'show state text' ); + await expect( el ).toHaveText( 'Text 1' ); + await page.getByTestId( 'toggle state text' ).click(); + await expect( el ).toHaveText( 'Text 2' ); + await page.getByTestId( 'toggle state text' ).click(); + await expect( el ).toHaveText( 'Text 1' ); + } ); + + test( 'show proper text reading from context', async ( { page } ) => { + const el = page.getByTestId( 'show context text' ); + await expect( el ).toHaveText( 'Text 1' ); + await page.getByTestId( 'toggle context text' ).click(); + await expect( el ).toHaveText( 'Text 2' ); + await page.getByTestId( 'toggle context text' ).click(); + await expect( el ).toHaveText( 'Text 1' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/fixtures/index.ts b/test/e2e/specs/interactivity/fixtures/index.ts new file mode 100644 index 00000000000000..607221ffb1ec43 --- /dev/null +++ b/test/e2e/specs/interactivity/fixtures/index.ts @@ -0,0 +1,25 @@ +/** + * WordPress dependencies + */ +import { test as base } from '@wordpress/e2e-test-utils-playwright'; +export { expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * Internal dependencies + */ +import InteractivityUtils from './interactivity-utils'; + +type Fixtures = { + interactivityUtils: InteractivityUtils; +}; + +export const test = base.extend< Fixtures >( { + interactivityUtils: [ + async ( { requestUtils }, use ) => { + await use( new InteractivityUtils( { requestUtils } ) ); + }, + // @ts-ignore: The required type is 'test', but can be 'worker' too. See + // https://playwright.dev/docs/test-fixtures#worker-scoped-fixtures + { scope: 'worker' }, + ], +} ); diff --git a/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts b/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts new file mode 100644 index 00000000000000..9d83c93650d403 --- /dev/null +++ b/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts @@ -0,0 +1,60 @@ +/** + * WordPress dependencies + */ +import type { RequestUtils } from '@wordpress/e2e-test-utils-playwright'; + +export default class InteractivityUtils { + links: Map< string, string >; + requestUtils: RequestUtils; + + constructor( { requestUtils }: { requestUtils: RequestUtils } ) { + this.links = new Map(); + this.requestUtils = requestUtils; + } + + getLink( blockName: string ) { + const link = this.links.get( blockName ); + if ( ! link ) { + throw new Error( + `No link found for post with block '${ blockName }'` + ); + } + + // Add an extra param to disable directives SSR. This is required at + // this moment, as SSR for directives is not stabilized yet and we need + // to ensure hydration works, even when the SSR'ed HTML is not correct. + const url = new URL( link ); + url.searchParams.append( 'disable_directives_ssr', 'true' ); + return url.href; + } + + async addPostWithBlock( blockName: string ) { + const payload = { + content: ``, + status: 'publish' as 'publish', + date_gmt: '2023-01-01T00:00:00', + }; + + const { link } = await this.requestUtils.createPost( payload ); + this.links.set( blockName, link ); + } + + async deleteAllPosts() { + await this.requestUtils.deleteAllPosts(); + this.links.clear(); + } + + async activatePlugins() { + await this.requestUtils.activateTheme( 'emptytheme' ); + await this.requestUtils.activatePlugin( + 'gutenberg-test-interactive-blocks' + ); + } + + async deactivatePlugins() { + await this.requestUtils.activateTheme( 'twentytwentyone' ); + await this.requestUtils.deactivatePlugin( + 'gutenberg-test-interactive-blocks' + ); + } +} diff --git a/test/e2e/specs/interactivity/negation-operator.spec.ts b/test/e2e/specs/interactivity/negation-operator.spec.ts new file mode 100644 index 00000000000000..da5670a0e57824 --- /dev/null +++ b/test/e2e/specs/interactivity/negation-operator.spec.ts @@ -0,0 +1,44 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'negation-operator', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/negation-operator' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/negation-operator' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'add hidden attribute when !state.active', async ( { page } ) => { + const el = page.getByTestId( + 'add hidden attribute if state is not active' + ); + + await expect( el ).toHaveAttribute( 'hidden', '' ); + await page.getByTestId( 'toggle active value' ).click(); + await expect( el ).not.toHaveAttribute( 'hidden', '' ); + await page.getByTestId( 'toggle active value' ).click(); + await expect( el ).toHaveAttribute( 'hidden', '' ); + } ); + + test( 'add hidden attribute when !selectors.active', async ( { page } ) => { + const el = page.getByTestId( + 'add hidden attribute if selector is not active' + ); + + await expect( el ).toHaveAttribute( 'hidden', '' ); + await page.getByTestId( 'toggle active value' ).click(); + await expect( el ).not.toHaveAttribute( 'hidden', '' ); + await page.getByTestId( 'toggle active value' ).click(); + await expect( el ).toHaveAttribute( 'hidden', '' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/store-tag.spec.ts b/test/e2e/specs/interactivity/store-tag.spec.ts new file mode 100644 index 00000000000000..80f9acfad93358 --- /dev/null +++ b/test/e2e/specs/interactivity/store-tag.spec.ts @@ -0,0 +1,86 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'store tag', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/store-tag {"condition":"ok"}' ); + await utils.addPostWithBlock( + 'test/store-tag {"condition":"missing"}' + ); + await utils.addPostWithBlock( + 'test/store-tag {"condition":"corrupted-json"}' + ); + await utils.addPostWithBlock( + 'test/store-tag {"condition":"invalid-state"}' + ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'hydrates when it is well defined', async ( { + interactivityUtils: utils, + page, + } ) => { + const block = 'test/store-tag {"condition":"ok"}'; + await page.goto( utils.getLink( block ) ); + + const value = page.getByTestId( 'counter value' ); + const double = page.getByTestId( 'counter double' ); + const clicks = page.getByTestId( 'counter clicks' ); + + await expect( value ).toHaveText( '3' ); + await expect( double ).toHaveText( '6' ); + await expect( clicks ).toHaveText( '0' ); + + await page.getByTestId( 'counter button' ).click(); + + await expect( value ).toHaveText( '4' ); + await expect( double ).toHaveText( '8' ); + await expect( clicks ).toHaveText( '1' ); + } ); + + test( 'does not break the page when missing', async ( { + interactivityUtils: utils, + page, + } ) => { + const block = 'test/store-tag {"condition":"missing"}'; + await page.goto( utils.getLink( block ) ); + + const clicks = page.getByTestId( 'counter clicks' ); + await expect( clicks ).toHaveText( '0' ); + await page.getByTestId( 'counter button' ).click(); + await expect( clicks ).toHaveText( '1' ); + } ); + + test( 'does not break the page when corrupted', async ( { + interactivityUtils: utils, + page, + } ) => { + const block = 'test/store-tag {"condition":"corrupted-json"}'; + await page.goto( utils.getLink( block ) ); + + const clicks = page.getByTestId( 'counter clicks' ); + await expect( clicks ).toHaveText( '0' ); + await page.getByTestId( 'counter button' ).click(); + await expect( clicks ).toHaveText( '1' ); + } ); + + test( 'does not break the page when it contains an invalid state', async ( { + interactivityUtils: utils, + page, + } ) => { + const block = 'test/store-tag {"condition":"invalid-state"}'; + await page.goto( utils.getLink( block ) ); + + const clicks = page.getByTestId( 'counter clicks' ); + await expect( clicks ).toHaveText( '0' ); + await page.getByTestId( 'counter button' ).click(); + await expect( clicks ).toHaveText( '1' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/tovdom-islands.spec.ts b/test/e2e/specs/interactivity/tovdom-islands.spec.ts new file mode 100644 index 00000000000000..fcc7c6081077a6 --- /dev/null +++ b/test/e2e/specs/interactivity/tovdom-islands.spec.ts @@ -0,0 +1,58 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'toVdom - islands', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/tovdom-islands' ); + } ); + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/tovdom-islands' ) ); + } ); + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'directives that are not inside islands should not be hydrated', async ( { + page, + } ) => { + const el = page.getByTestId( 'not inside an island' ); + await expect( el ).toBeVisible(); + } ); + + test( 'directives that are inside islands should be hydrated', async ( { + page, + } ) => { + const el = page.getByTestId( 'inside an island' ); + await expect( el ).toBeHidden(); + } ); + + test( 'directives that are inside inner blocks of isolated islands should not be hydrated', async ( { + page, + } ) => { + const el = page.getByTestId( + 'inside an inner block of an isolated island' + ); + await expect( el ).toBeVisible(); + } ); + + test( 'directives inside islands should not be hydrated twice', async ( { + page, + } ) => { + const el = page.getByTestId( 'island inside another island' ); + const templates = el.locator( 'template' ); + expect( await templates.count() ).toEqual( 1 ); + } ); + + test( 'islands inside inner blocks of isolated islands should be hydrated', async ( { + page, + } ) => { + const el = page.getByTestId( + 'island inside inner block of isolated island' + ); + await expect( el ).toBeHidden(); + } ); +} ); diff --git a/test/e2e/specs/interactivity/tovdom.spec.ts b/test/e2e/specs/interactivity/tovdom.spec.ts new file mode 100644 index 00000000000000..cf08fb115db4fd --- /dev/null +++ b/test/e2e/specs/interactivity/tovdom.spec.ts @@ -0,0 +1,55 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'toVdom', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/tovdom' ); + } ); + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/tovdom' ) ); + } ); + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'it should delete comments', async ( { page } ) => { + const el = page.getByTestId( 'it should delete comments' ); + const c = await el.innerHTML(); + expect( c ).not.toContain( '##1##' ); + expect( c ).not.toContain( '##2##' ); + const el2 = page.getByTestId( + 'it should keep this node between comments' + ); + await expect( el2 ).toBeVisible(); + } ); + + test( 'it should delete processing instructions', async ( { page } ) => { + const el = page.getByTestId( + 'it should delete processing instructions' + ); + const c = await el.innerHTML(); + expect( c ).not.toContain( '##1##' ); + expect( c ).not.toContain( '##2##' ); + const el2 = page.getByTestId( + 'it should keep this node between processing instructions' + ); + await expect( el2 ).toBeVisible(); + } ); + + test( 'it should replace CDATA with text nodes', async ( { page } ) => { + const el = page.getByTestId( + 'it should replace CDATA with text nodes' + ); + const c = await el.innerHTML(); + expect( c ).toContain( '##1##' ); + expect( c ).toContain( '##2##' ); + const el2 = page.getByTestId( + 'it should keep this node between CDATA' + ); + await expect( el2 ).toBeVisible(); + } ); +} );