From 76e8f6a602b7378f20131bb98aa7e7d7271594ac Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Mon, 20 Feb 2023 14:53:07 +0100 Subject: [PATCH 01/13] Add custom controls decorator --- .../decorators/with-custom-controls.js | 116 ++++++++++++++++++ .../storybook-playwright/storybook/preview.js | 14 ++- 2 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 test/storybook-playwright/storybook/decorators/with-custom-controls.js diff --git a/test/storybook-playwright/storybook/decorators/with-custom-controls.js b/test/storybook-playwright/storybook/decorators/with-custom-controls.js new file mode 100644 index 0000000000000..8232dd44caef3 --- /dev/null +++ b/test/storybook-playwright/storybook/decorators/with-custom-controls.js @@ -0,0 +1,116 @@ +/** + * External dependencies + */ +import styled from '@emotion/styled'; + +/** + * WordPress dependencies + */ +import { useId, useState } from '@wordpress/element'; + +const StyledButton = styled.button` + font-family: monospace; + &[aria-pressed='true'] { + outline: 1px solid red; + } +`; + +/** + * @template T + * @typedef {Object} PropSelectorProps + * @property {string} propName the name of the property + * @property {{label: string, value: T}[]} propValues the list of values to provide controls for + * @property {T | undefined} selectedValue the currently selected value for this prop + * @property {(value: T | undefined) => void} onValueSelected the callback fired when a value gets selected + * @property {boolean=} required Used to show (or hide) an "unset" control + */ + +/** + * @template TValue + * @param {PropSelectorProps} props + * @return {JSX.Element} The controls used in the e2e test + */ +const PropSelector = ( { + propName, + propValues, + selectedValue, + onValueSelected, + required = false, +} ) => { + const titleId = useId(); + + const selectValue = ( newValue ) => { + onValueSelected( newValue ); + }; + + return ( +
+

{ propName } prop controls

+ { ! required && ( + selectValue( undefined ) } + aria-pressed={ selectedValue === undefined } + > + Unset + + ) } + { propValues.map( ( { label, value } ) => ( + selectValue( value ) } + aria-pressed={ selectedValue === value } + > + { label } + + ) ) } +
+ ); +}; + +export const WithCustomControls = ( Story, context ) => { + const [ partialProps, setPartialProps ] = useState( {} ); + + if ( ! context.args.customE2EControlsProps ) { + return ; + } + + const contextWithControlledProps = { + ...context, + // override args with the ones set by custom controls + args: { ...context.args, ...partialProps }, + }; + + const { customE2EControlsProps, ...propsToShow } = + contextWithControlledProps.args; + + return ( + <> + + +

Props:

+
{ JSON.stringify( propsToShow, undefined, 4 ) }
+ + { context.args.customE2EControlsProps.map( + ( { name, required, values } ) => ( + ( { label, value } ) + ) } + onValueSelected={ ( newValue ) => + setPartialProps( ( oldProps ) => ( { + ...oldProps, + [ name ]: newValue, + } ) ) + } + selectedValue={ + contextWithControlledProps.args[ name ] + } + /> + ) + ) } + + ); +}; diff --git a/test/storybook-playwright/storybook/preview.js b/test/storybook-playwright/storybook/preview.js index 911578742e33e..6aa4470ad5810 100644 --- a/test/storybook-playwright/storybook/preview.js +++ b/test/storybook-playwright/storybook/preview.js @@ -1 +1,13 @@ -export * from '../../../storybook/preview'; +/** + * Internal dependencies + */ + +import * as basePreviewConfig from '../../../storybook/preview'; +import { WithCustomControls } from './decorators/with-custom-controls'; + +export const globalTypes = { ...basePreviewConfig.globalTypes }; +export const decorators = [ + ...basePreviewConfig.decorators, + WithCustomControls, +]; +export const parameters = { ...basePreviewConfig.parameters }; From e3b03e0e7de64a4f8a2d883b49e9b6519912b03b Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Mon, 20 Feb 2023 14:53:52 +0100 Subject: [PATCH 02/13] Add playwright util for parsing e2e controls and testing all combinations via snapshots --- test/storybook-playwright/utils.ts | 84 ++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/test/storybook-playwright/utils.ts b/test/storybook-playwright/utils.ts index 80be7297c8843..da579cb393653 100644 --- a/test/storybook-playwright/utils.ts +++ b/test/storybook-playwright/utils.ts @@ -2,6 +2,7 @@ * External dependencies */ import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; const STORYBOOK_PORT = '50241'; @@ -38,3 +39,86 @@ export const gotoStoryId = ( { waitUntil: 'load' } ); }; + +/** + * Parses the Story, looking for e2e tests-specific controls, and generates + * snapshots for all possible combinations of these controls. + * + * @param page + */ +export const testAllSnapshotsCombinationsWithE2EControls = async ( + page: Page +) => { + type PropsObject = { name: string; values: string[] }; + + // Collect all available configurations. + const allProps: PropsObject[] = []; + + // Scan all `role=group` elements containing text "prop controls" + for ( const group of await page + .getByRole( 'group' ) + .filter( { hasText: 'prop controls' } ) + .all() ) { + // Get the text content + const title = await group.textContent(); + if ( title === null ) { + continue; + } + + // Use a RegExp to extract the prop name — + // it's expected to be the first word, before "prop controls" + const results = /(?^\w+) prop controls/g.exec( title ); + if ( results === null || ! results.groups?.propName ) { + continue; + } + + const propObject: PropsObject = { + name: results.groups?.propName, + values: [], + }; + + // Once the prop name is extracted, scan all buttons inside the group, + // and extract the label. + for ( const button of await group.getByRole( 'button' ).all() ) { + const buttonLabel = await button.textContent(); + if ( buttonLabel !== null ) { + propObject.values.push( buttonLabel ); + } + } + allProps.push( propObject ); + } + + // Test all possible configurations + const iterateOverNextPropValues = async ( + remainingProps: PropsObject[] + ) => { + const [ propObject, ...restProps ] = remainingProps; + + // Test all values for the given prop. + for ( const value of propObject.values ) { + // Find the button corresponding to the current value + const button = await page + .getByRole( 'group' ) + .filter( { + hasText: `${ propObject.name } prop controls`, + } ) + .getByRole( 'button', { name: value, exact: true } ); + + // Click the button. This will set the corresponding prop in the story. + await button.click(); + + if ( restProps.length === 0 ) { + // If we exhausted all of the props to set for this specific combination, + // it's time to take a screenshot of this specific combination of props. + expect( await page.screenshot() ).toMatchSnapshot(); + } else { + // IF there are more props to iterate through, let's do that through + // recursively calling this function. + await iterateOverNextPropValues( restProps ); + } + } + }; + + // Start! + await iterateOverNextPropValues( allProps ); +}; From eb41893ccba2ef6cc6729c3ffcf46d8c701141fa Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Mon, 20 Feb 2023 14:55:55 +0100 Subject: [PATCH 03/13] Add HStack e2e story + playwright test --- .../src/h-stack/stories/e2e/index.tsx | 112 ++++++++++++++++++ .../storybook-playwright/specs/hstack.spec.ts | 29 +++++ 2 files changed, 141 insertions(+) create mode 100644 packages/components/src/h-stack/stories/e2e/index.tsx create mode 100644 test/storybook-playwright/specs/hstack.spec.ts diff --git a/packages/components/src/h-stack/stories/e2e/index.tsx b/packages/components/src/h-stack/stories/e2e/index.tsx new file mode 100644 index 0000000000000..816910a4b3b93 --- /dev/null +++ b/packages/components/src/h-stack/stories/e2e/index.tsx @@ -0,0 +1,112 @@ +/** + * External dependencies + */ +import type { ComponentStory, ComponentMeta } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { View } from '../../../view'; +import { HStack } from '../..'; + +const E2E_CONTROLS_PROPS: { + name: keyof Omit< React.ComponentProps< typeof HStack >, 'children' >; + required: boolean; + values: Record< string, any >; +}[] = [ + { + name: 'alignment', + required: false, + values: { + top: 'top', + topLeft: 'topLeft', + topRight: 'topRight', + left: 'left', + center: 'center', + right: 'right', + bottom: 'bottom', + bottomLeft: 'bottomLeft', + bottomRight: 'bottomRight', + edge: 'edge', + stretch: 'stretch', + }, + }, + { + name: 'direction', + required: false, + values: { + row: 'row', + column: 'column', + // responsive: 'responsive', + }, + }, + // { + // name: 'expanded', + // required: false, + // values: { + // true: true, + // false: false, + // }, + // }, + // { + // name: 'isReversed', + // required: false, + // values: { + // true: true, + // false: false, + // }, + // }, + { + name: 'justify', + required: false, + values: { + spaceAround: 'space-around', + spaceBetween: 'space-between', + spaceEvenly: 'space-evenly', + stretch: 'stretch', + center: 'center', + end: 'end', + flexEnd: 'flex-end', + flexStart: 'flex-start', + start: 'start', + }, + }, + // { + // name: 'wrap', + // required: false, + // values: { + // true: true, + // false: false, + // }, + // }, +]; + +const meta: ComponentMeta< typeof HStack > = { + component: HStack, + title: 'Components (Experimental)/HStack', +}; +export default meta; + +const Template: ComponentStory< typeof HStack > = ( props ) => { + return ( + + { [ 'One', 'Two', 'Three', 'Four', 'Five' ].map( ( text ) => ( + + { text } + + ) ) } + + ); +}; + +export const Default: ComponentStory< typeof HStack > = Template.bind( {} ); +Default.args = { + spacing: 3, + // The `customE2EControlsProps` is used by custom decorator + // used for Storybook-powered e2e tests + // @ts-expect-error + customE2EControlsProps: E2E_CONTROLS_PROPS, +}; diff --git a/test/storybook-playwright/specs/hstack.spec.ts b/test/storybook-playwright/specs/hstack.spec.ts new file mode 100644 index 0000000000000..a0f7205560276 --- /dev/null +++ b/test/storybook-playwright/specs/hstack.spec.ts @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import { test } from '@playwright/test'; + +/** + * Internal dependencies + */ +import { + gotoStoryId, + testAllSnapshotsCombinationsWithE2EControls, +} from '../utils'; + +test.describe( 'HStack', () => { + test.beforeEach( async ( { page } ) => { + await gotoStoryId( page, 'components-experimental-hstack--default', { + decorators: { marginChecker: 'show' }, + } ); + } ); + + test( 'should render', async ( { page } ) => { + // This test is going to run slow. Tripe the default timeout. + test.slow(); + + await page.waitForSelector( '.components-h-stack' ); + + await testAllSnapshotsCombinationsWithE2EControls( page ); + } ); +} ); From 4a4a15c9c1171d42ba89342d624d3693d84e6491 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Mon, 20 Feb 2023 15:04:52 +0100 Subject: [PATCH 04/13] Add VStack e2e story + playwright test --- .../src/v-stack/stories/e2e/index.tsx | 112 ++++++++++++++++++ .../storybook-playwright/specs/vstack.spec.ts | 29 +++++ 2 files changed, 141 insertions(+) create mode 100644 packages/components/src/v-stack/stories/e2e/index.tsx create mode 100644 test/storybook-playwright/specs/vstack.spec.ts diff --git a/packages/components/src/v-stack/stories/e2e/index.tsx b/packages/components/src/v-stack/stories/e2e/index.tsx new file mode 100644 index 0000000000000..5c3c7c554986a --- /dev/null +++ b/packages/components/src/v-stack/stories/e2e/index.tsx @@ -0,0 +1,112 @@ +/** + * External dependencies + */ +import type { ComponentStory, ComponentMeta } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { View } from '../../../view'; +import { VStack } from '../..'; + +const E2E_CONTROLS_PROPS: { + name: keyof Omit< React.ComponentProps< typeof VStack >, 'children' >; + required: boolean; + values: Record< string, any >; +}[] = [ + { + name: 'alignment', + required: false, + values: { + top: 'top', + topLeft: 'topLeft', + topRight: 'topRight', + left: 'left', + center: 'center', + right: 'right', + bottom: 'bottom', + bottomLeft: 'bottomLeft', + bottomRight: 'bottomRight', + edge: 'edge', + stretch: 'stretch', + }, + }, + { + name: 'direction', + required: false, + values: { + row: 'row', + column: 'column', + // responsive: 'responsive', + }, + }, + // { + // name: 'expanded', + // required: false, + // values: { + // true: true, + // false: false, + // }, + // }, + // { + // name: 'isReversed', + // required: false, + // values: { + // true: true, + // false: false, + // }, + // }, + { + name: 'justify', + required: false, + values: { + spaceAround: 'space-around', + spaceBetween: 'space-between', + spaceEvenly: 'space-evenly', + stretch: 'stretch', + center: 'center', + end: 'end', + flexEnd: 'flex-end', + flexStart: 'flex-start', + start: 'start', + }, + }, + // { + // name: 'wrap', + // required: false, + // values: { + // true: true, + // false: false, + // }, + // }, +]; + +const meta: ComponentMeta< typeof VStack > = { + component: VStack, + title: 'Components (Experimental)/VStack', +}; +export default meta; + +const Template: ComponentStory< typeof VStack > = ( props ) => { + return ( + + { [ 'One', 'Two', 'Three', 'Four', 'Five' ].map( ( text ) => ( + + { text } + + ) ) } + + ); +}; + +export const Default: ComponentStory< typeof VStack > = Template.bind( {} ); +Default.args = { + spacing: 3, + // The `customE2EControlsProps` is used by custom decorator + // used for Storybook-powered e2e tests + // @ts-expect-error + customE2EControlsProps: E2E_CONTROLS_PROPS, +}; diff --git a/test/storybook-playwright/specs/vstack.spec.ts b/test/storybook-playwright/specs/vstack.spec.ts new file mode 100644 index 0000000000000..2a820a2a9b3fd --- /dev/null +++ b/test/storybook-playwright/specs/vstack.spec.ts @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import { test } from '@playwright/test'; + +/** + * Internal dependencies + */ +import { + gotoStoryId, + testAllSnapshotsCombinationsWithE2EControls, +} from '../utils'; + +test.describe( 'VStack', () => { + test.beforeEach( async ( { page } ) => { + await gotoStoryId( page, 'components-experimental-vstack--default', { + decorators: { marginChecker: 'show' }, + } ); + } ); + + test( 'should render', async ( { page } ) => { + // This test is going to run slow. Tripe the default timeout. + test.slow(); + + await page.waitForSelector( '.components-v-stack' ); + + await testAllSnapshotsCombinationsWithE2EControls( page ); + } ); +} ); From 2c4ba3f9658fae6a619df36aa92eae7a2afdf940 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Mon, 20 Feb 2023 17:05:48 +0100 Subject: [PATCH 05/13] Cleanup unused values --- .../src/h-stack/stories/e2e/index.tsx | 25 ------------------- .../src/v-stack/stories/e2e/index.tsx | 25 ------------------- 2 files changed, 50 deletions(-) diff --git a/packages/components/src/h-stack/stories/e2e/index.tsx b/packages/components/src/h-stack/stories/e2e/index.tsx index 816910a4b3b93..87ef52eb006a4 100644 --- a/packages/components/src/h-stack/stories/e2e/index.tsx +++ b/packages/components/src/h-stack/stories/e2e/index.tsx @@ -37,25 +37,8 @@ const E2E_CONTROLS_PROPS: { values: { row: 'row', column: 'column', - // responsive: 'responsive', }, }, - // { - // name: 'expanded', - // required: false, - // values: { - // true: true, - // false: false, - // }, - // }, - // { - // name: 'isReversed', - // required: false, - // values: { - // true: true, - // false: false, - // }, - // }, { name: 'justify', required: false, @@ -71,14 +54,6 @@ const E2E_CONTROLS_PROPS: { start: 'start', }, }, - // { - // name: 'wrap', - // required: false, - // values: { - // true: true, - // false: false, - // }, - // }, ]; const meta: ComponentMeta< typeof HStack > = { diff --git a/packages/components/src/v-stack/stories/e2e/index.tsx b/packages/components/src/v-stack/stories/e2e/index.tsx index 5c3c7c554986a..a346482ea1e5f 100644 --- a/packages/components/src/v-stack/stories/e2e/index.tsx +++ b/packages/components/src/v-stack/stories/e2e/index.tsx @@ -37,25 +37,8 @@ const E2E_CONTROLS_PROPS: { values: { row: 'row', column: 'column', - // responsive: 'responsive', }, }, - // { - // name: 'expanded', - // required: false, - // values: { - // true: true, - // false: false, - // }, - // }, - // { - // name: 'isReversed', - // required: false, - // values: { - // true: true, - // false: false, - // }, - // }, { name: 'justify', required: false, @@ -71,14 +54,6 @@ const E2E_CONTROLS_PROPS: { start: 'start', }, }, - // { - // name: 'wrap', - // required: false, - // values: { - // true: true, - // false: false, - // }, - // }, ]; const meta: ComponentMeta< typeof VStack > = { From f59a44759c65b01894239331f1517ae7e73360c8 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Mon, 20 Feb 2023 18:53:05 +0100 Subject: [PATCH 06/13] Typo --- test/storybook-playwright/specs/hstack.spec.ts | 2 +- test/storybook-playwright/specs/vstack.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/storybook-playwright/specs/hstack.spec.ts b/test/storybook-playwright/specs/hstack.spec.ts index a0f7205560276..c9c87c300c93d 100644 --- a/test/storybook-playwright/specs/hstack.spec.ts +++ b/test/storybook-playwright/specs/hstack.spec.ts @@ -19,7 +19,7 @@ test.describe( 'HStack', () => { } ); test( 'should render', async ( { page } ) => { - // This test is going to run slow. Tripe the default timeout. + // This test is going to run slow. Triple the default timeout. test.slow(); await page.waitForSelector( '.components-h-stack' ); diff --git a/test/storybook-playwright/specs/vstack.spec.ts b/test/storybook-playwright/specs/vstack.spec.ts index 2a820a2a9b3fd..9049502142808 100644 --- a/test/storybook-playwright/specs/vstack.spec.ts +++ b/test/storybook-playwright/specs/vstack.spec.ts @@ -19,7 +19,7 @@ test.describe( 'VStack', () => { } ); test( 'should render', async ( { page } ) => { - // This test is going to run slow. Tripe the default timeout. + // This test is going to run slow. Triple the default timeout. test.slow(); await page.waitForSelector( '.components-v-stack' ); From 999866b076daa560a9921f6059eec25f3bbbeb47 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Mon, 20 Feb 2023 21:18:07 +0100 Subject: [PATCH 07/13] Simplify decorator, use globalTypes to enable it --- .../decorators/with-custom-controls.js | 124 +++++------------- .../storybook-playwright/storybook/preview.js | 17 ++- test/storybook-playwright/utils.ts | 1 + 3 files changed, 50 insertions(+), 92 deletions(-) diff --git a/test/storybook-playwright/storybook/decorators/with-custom-controls.js b/test/storybook-playwright/storybook/decorators/with-custom-controls.js index 8232dd44caef3..0f8c8e059f0a0 100644 --- a/test/storybook-playwright/storybook/decorators/with-custom-controls.js +++ b/test/storybook-playwright/storybook/decorators/with-custom-controls.js @@ -1,76 +1,13 @@ -/** - * External dependencies - */ -import styled from '@emotion/styled'; - /** * WordPress dependencies */ import { useId, useState } from '@wordpress/element'; -const StyledButton = styled.button` - font-family: monospace; - &[aria-pressed='true'] { - outline: 1px solid red; - } -`; - -/** - * @template T - * @typedef {Object} PropSelectorProps - * @property {string} propName the name of the property - * @property {{label: string, value: T}[]} propValues the list of values to provide controls for - * @property {T | undefined} selectedValue the currently selected value for this prop - * @property {(value: T | undefined) => void} onValueSelected the callback fired when a value gets selected - * @property {boolean=} required Used to show (or hide) an "unset" control - */ - -/** - * @template TValue - * @param {PropSelectorProps} props - * @return {JSX.Element} The controls used in the e2e test - */ -const PropSelector = ( { - propName, - propValues, - selectedValue, - onValueSelected, - required = false, -} ) => { - const titleId = useId(); - - const selectValue = ( newValue ) => { - onValueSelected( newValue ); - }; - - return ( -
-

{ propName } prop controls

- { ! required && ( - selectValue( undefined ) } - aria-pressed={ selectedValue === undefined } - > - Unset - - ) } - { propValues.map( ( { label, value } ) => ( - selectValue( value ) } - aria-pressed={ selectedValue === value } - > - { label } - - ) ) } -
- ); -}; - export const WithCustomControls = ( Story, context ) => { + const textareaId = useId(); const [ partialProps, setPartialProps ] = useState( {} ); - if ( ! context.args.customE2EControlsProps ) { + if ( context.globals.customE2EControls === 'hide' ) { return ; } @@ -80,37 +17,42 @@ export const WithCustomControls = ( Story, context ) => { args: { ...context.args, ...partialProps }, }; - const { customE2EControlsProps, ...propsToShow } = - contextWithControlledProps.args; - return ( <>

Props:

-
{ JSON.stringify( propsToShow, undefined, 4 ) }
- - { context.args.customE2EControlsProps.map( - ( { name, required, values } ) => ( - ( { label, value } ) - ) } - onValueSelected={ ( newValue ) => - setPartialProps( ( oldProps ) => ( { - ...oldProps, - [ name ]: newValue, - } ) ) - } - selectedValue={ - contextWithControlledProps.args[ name ] - } - /> - ) - ) } +
+				{ JSON.stringify(
+					contextWithControlledProps.args,
+					undefined,
+					4
+				) }
+			
+ +
+ +
{ + event.preventDefault(); + + const propsRawText = event.target.elements.props.value; + + const propsParsed = JSON.parse( propsRawText ); + + setPartialProps( ( oldProps ) => ( { + ...oldProps, + ...propsParsed, + } ) ); + } } + > +

+ +