From 544771c0d7f978b32f0bb12f18fa54e95fb6695a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20S=C3=A1ros?= Date: Fri, 6 Oct 2023 12:15:34 +0200 Subject: [PATCH] chore(ui-number-input,ui-scripts): move generateA11yTests to ui-scripts --- package-lock.json | 8 +- packages/ui-number-input/package.json | 5 +- .../__new-tests__/NumberInput.test.tsx | 45 +++ packages/ui-number-input/tsconfig.build.json | 2 + .../ui-scripts/lib/test/generateA11yTests.tsx | 61 ++++ .../lib/test/generateComponentExamples.tsx | 325 ++++++++++++++++++ .../lib/test/generatePropCombinations.ts | 86 +++++ 7 files changed, 530 insertions(+), 2 deletions(-) create mode 100644 packages/ui-number-input/src/NumberInput/__new-tests__/NumberInput.test.tsx create mode 100644 packages/ui-scripts/lib/test/generateA11yTests.tsx create mode 100644 packages/ui-scripts/lib/test/generateComponentExamples.tsx create mode 100644 packages/ui-scripts/lib/test/generatePropCombinations.ts diff --git a/package-lock.json b/package-lock.json index 2500db4a6d..b0a314c96f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54853,10 +54853,13 @@ "prop-types": "^15.8.1" }, "devDependencies": { + "@instructure/ui-axe-check": "^8.45.0", "@instructure/ui-babel-preset": "8.45.0", + "@instructure/ui-scripts": "^8.45.0", "@instructure/ui-test-locator": "8.45.0", "@instructure/ui-test-utils": "8.45.0", - "@instructure/ui-themes": "8.45.0" + "@instructure/ui-themes": "8.45.0", + "@testing-library/react": "^14.0.0" }, "peerDependencies": { "react": ">=16.8 <=18" @@ -60323,16 +60326,19 @@ "@babel/runtime": "^7.22.15", "@instructure/emotion": "8.45.0", "@instructure/shared-types": "8.45.0", + "@instructure/ui-axe-check": "^8.45.0", "@instructure/ui-babel-preset": "8.45.0", "@instructure/ui-form-field": "8.45.0", "@instructure/ui-icons": "8.45.0", "@instructure/ui-react-utils": "8.45.0", + "@instructure/ui-scripts": "*", "@instructure/ui-test-locator": "8.45.0", "@instructure/ui-test-utils": "8.45.0", "@instructure/ui-testable": "8.45.0", "@instructure/ui-themes": "8.45.0", "@instructure/ui-utils": "8.45.0", "@instructure/uid": "8.45.0", + "@testing-library/react": "^14.0.0", "keycode": "^2.2.1", "prop-types": "^15.8.1" } diff --git a/packages/ui-number-input/package.json b/packages/ui-number-input/package.json index 538d19bed1..c433e040c3 100644 --- a/packages/ui-number-input/package.json +++ b/packages/ui-number-input/package.json @@ -23,10 +23,13 @@ "ts:check": "tsc -p tsconfig.build.json --noEmit --emitDeclarationOnly false" }, "devDependencies": { + "@instructure/ui-axe-check": "^8.45.0", "@instructure/ui-babel-preset": "8.45.0", + "@instructure/ui-scripts": "^8.45.0", "@instructure/ui-test-locator": "8.45.0", "@instructure/ui-test-utils": "8.45.0", - "@instructure/ui-themes": "8.45.0" + "@instructure/ui-themes": "8.45.0", + "@testing-library/react": "^14.0.0" }, "dependencies": { "@babel/runtime": "^7.22.15", diff --git a/packages/ui-number-input/src/NumberInput/__new-tests__/NumberInput.test.tsx b/packages/ui-number-input/src/NumberInput/__new-tests__/NumberInput.test.tsx new file mode 100644 index 0000000000..fd066d07a6 --- /dev/null +++ b/packages/ui-number-input/src/NumberInput/__new-tests__/NumberInput.test.tsx @@ -0,0 +1,45 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { render } from '@testing-library/react' + +import { runAxeCheck } from '@instructure/ui-axe-check' +import { NumberInput } from '../index' +import NumberInputExamples from '../__examples__/NumberInput.examples' +// eslint-disable-next-line no-restricted-imports +import { generateA11yTests } from '@instructure/ui-scripts/lib/test/generateA11yTests' + +describe('', () => { + const generatedComponents = generateA11yTests( + NumberInput, + NumberInputExamples + ) + for (const component of generatedComponents) { + it(component.description, async () => { + const { container } = render(component.content) + const axeCheck = await runAxeCheck(container) + expect(axeCheck).toBe(true) + }) + } +}) diff --git a/packages/ui-number-input/tsconfig.build.json b/packages/ui-number-input/tsconfig.build.json index b2806f107e..945a4f3be8 100644 --- a/packages/ui-number-input/tsconfig.build.json +++ b/packages/ui-number-input/tsconfig.build.json @@ -10,6 +10,8 @@ { "path": "../ui-babel-preset/tsconfig.build.json" }, { "path": "../ui-test-locator/tsconfig.build.json" }, { "path": "../ui-test-utils/tsconfig.build.json" }, + { "path": "../ui-scripts/tsconfig.build.json" }, + { "path": "../ui-axe-check/tsconfig.build.json" }, { "path": "../ui-themes/tsconfig.build.json" }, { "path": "../emotion/tsconfig.build.json" }, { "path": "../shared-types/tsconfig.build.json" }, diff --git a/packages/ui-scripts/lib/test/generateA11yTests.tsx b/packages/ui-scripts/lib/test/generateA11yTests.tsx new file mode 100644 index 0000000000..966a9094ed --- /dev/null +++ b/packages/ui-scripts/lib/test/generateA11yTests.tsx @@ -0,0 +1,61 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import React from 'react' +import { + Example, + StoryConfig, + generateComponentExamples +} from './generateComponentExamples' + +type ReturnComponentType = { + content: React.JSX.Element + description: string +} + +const renderExample = ({ Component, componentProps, key }: Example) => ( + +) + +export function generateA11yTests>( + Component: React.ComponentType, + componentExample: StoryConfig +): ReturnComponentType[] { + const sections = generateComponentExamples(Component, componentExample) + const returnComponents: ReturnComponentType[] = [] + sections.forEach(({ pages }, sectionIndex) => { + pages.forEach(({ examples }, pageIndex) => { + examples.forEach((example, exampleIndex) => { + const Example = renderExample.bind(null, example) + const description = `${Component.displayName} example ${ + sectionIndex * sections.length + pageIndex + 1 + }/${exampleIndex + 1}` + returnComponents.push({ + content: , + description + }) + }) + }) + }) + return returnComponents +} diff --git a/packages/ui-scripts/lib/test/generateComponentExamples.tsx b/packages/ui-scripts/lib/test/generateComponentExamples.tsx new file mode 100644 index 0000000000..35f0b5ef9e --- /dev/null +++ b/packages/ui-scripts/lib/test/generateComponentExamples.tsx @@ -0,0 +1,325 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { generatePropCombinations } from './generatePropCombinations' +import React, { ComponentType, ReactNode } from 'react' + +export type StoryConfig = { + /** + * Used to divide the resulting examples into sections. It should correspond + * to an enumerated prop in the Component + */ + sectionProp?: keyof Props + /** + * Specifies the max number of examples that can exist in a single page + * within a section + */ + maxExamplesPerPage?: number | ((sectionName: string) => number) + /** + * Specifies the total max number of examples. Default: 500 + */ + maxExamples?: number + /** + * An object with keys that correspond to the component props. Each key has a + * corresponding value array. This array contains possible values for that prop. + */ + propValues?: Partial> + /** + * Prop keys to exclude from propValues. Useful when generating propValues with code. + */ + excludeProps?: (keyof Props)[] + /** + * The values returned by this function are passed to the component. + * A function called with the prop combination for the current example. It + * returns an object of props that will be passed into the `renderExample` + * function as componentProps. + */ + getComponentProps?: (props: Props & Record) => Partial + /** + * The values returned by this function are passed to a `View` that wraps the + * example. + * A function called with the prop combination for the current example. It + * returns an object of props that will be passed into the `renderExample` + * function as exampleProps. + */ + getExampleProps?: (props: Props & Record) => Record + /** + * A function called with the examples and index for the current page of + * examples. It returns an object of parameters/metadata for that page of + * examples (e.g. to be passed in to a visual regression tool like chromatic). + */ + getParameters?: (params: ExamplesPage) => { + [key: string]: any + delay?: number + disable?: boolean + } + filter?: (props: Props) => boolean +} + +type ExampleSection = { + sectionName: string + propName: keyof Props + propValue: string + pages: ExamplesPage[] +} + +export type ExamplesPage = { + examples: Example[] + index: number + renderExample?: (exampleProps: Example) => ReactNode + parameters?: Record +} + +export type Example = { + Component: ComponentType + componentProps: Partial + exampleProps: Record // actually Partial + key: string +} + +/** + * Generates examples for the given component based on the given configuration. + * @param Component A React component + * @param config A configuration object (stored in xy.examples.jsx files in InstUI) + * @returns Array of examples broken into sections and pages if configured to do so. + * @module generateComponentExamples + * @private + * + */ +export function generateComponentExamples>( + Component: ComponentType, + config: StoryConfig +) { + const { sectionProp, excludeProps, filter } = config + + const PROPS_CACHE: string[] = [] + const sections: ExampleSection[] = [] + const maxExamples = config.maxExamples ? config.maxExamples : 500 + let exampleCount = 0 + let propValues: Partial> = {} + + const getParameters = (page: ExamplesPage) => { + const examples = page.examples + const index = page.index + let parameters = {} + if (typeof config.getParameters === 'function') { + parameters = { + ...config.getParameters({ examples, index }) + } + } + return parameters + } + + /** + * Merges the auto-generated props with ones in the examples files specified + * by the `getComponentProps()` method; props from the example files have + * priority + */ + const mergeComponentPropsFromConfig = (props: Props) => { + let componentProps = props + // TODO this code is so complicated because getComponentProps(props) can return + // different values based on its props parameter. + // If it would always return the same thing then we could reduce the + // number of combinations generated by generatePropCombinations() by + // getComponentProps() reducing some to 1 value, it would also remove the + // need of PROPS_CACHE and duplicate checks. + // InstUI is not using the 'props' param of getComponentProps(), but others are + if (typeof config.getComponentProps === 'function') { + componentProps = { + ...componentProps, + ...config.getComponentProps(props) + } + } + return componentProps + } + + const getExampleProps = (props: Props) => { + let exampleProps: Record = {} + if (typeof config.getExampleProps === 'function') { + exampleProps = { + ...config.getExampleProps(props) + } + } + return exampleProps + } + + const addPage = (section: ExampleSection) => { + const page: ExamplesPage = { + examples: [], + index: section.pages.length + } + section.pages.push(page) + return page + } + + const addExample = (sectionName: string, example: Example) => { + let section = sections.find( + (section) => section.sectionName === sectionName + ) + if (!section) { + section = { + sectionName: sectionName, + propName: sectionProp!, + propValue: sectionName, + pages: [] + } + sections.push(section) + } + + let page = section.pages[section.pages.length - 1] + + let { maxExamplesPerPage } = config + + if (typeof maxExamplesPerPage === 'function') { + maxExamplesPerPage = maxExamplesPerPage(sectionName) + } + + if (!page) { + page = addPage(section) + } else if ( + maxExamplesPerPage && + page.examples.length % maxExamplesPerPage === 0 && + page.examples.length > 0 + ) { + page = addPage(section) + } + + page.examples.push(example) + } + + // Serializes the given recursively, faster than JSON.stringify() + const fastSerialize = (props: Props) => { + const strArr: string[] = [] + objToString(props, strArr) + return strArr.join('') + } + + const objToString = (currObject: any, currString: string[]) => { + if (!currObject) { + return + } + if (React.isValidElement(currObject)) { + currString.push(JSON.stringify(currObject)) + } else if (typeof currObject === 'object') { + for (const [key, value] of Object.entries(currObject)) { + currString.push(key) + objToString(value, currString) + } + } else { + currString.push(currObject) + } + } + + const maybeAddExample = (props: Props): void => { + const componentProps = mergeComponentPropsFromConfig(props) + const ignore = typeof filter === 'function' ? filter(componentProps) : false + if (ignore) { + return + } + const propsString = fastSerialize(componentProps) + if (!PROPS_CACHE.includes(propsString)) { + const key = `${Date.now()}${Math.round(Math.random() * 10000)}` + const exampleProps = getExampleProps(props) + exampleCount++ + if (exampleCount < maxExamples) { + PROPS_CACHE.push(propsString) + let sectionName = 'Examples' + if (sectionProp && componentProps[sectionProp]) { + sectionName = componentProps[sectionProp] as unknown as string + } + addExample(sectionName, { + Component, + componentProps, + exampleProps, + key + }) + } + } + } + + if (isEmpty(config.propValues)) { + maybeAddExample({} as Props) + } else { + if (Array.isArray(excludeProps)) { + ;(Object.keys(config.propValues) as (keyof Props)[]).forEach( + (propName) => { + if (!excludeProps.includes(propName)) { + propValues[propName] = config.propValues![propName] + } + } + ) + } else { + propValues = config.propValues + } + // eslint-disable-next-line no-console + console.info( + `Generating examples for ${Component.displayName} (${ + Object.keys(propValues).length + } props):`, + propValues + ) + // TODO reconcile the differences between these files + // generatePropCombinations should call getComponentProps and not do anything? + const combos = generatePropCombinations(propValues as any).filter(Boolean) + let index = 0 + while (index < combos.length && exampleCount < maxExamples) { + const combo = combos[index] + if (combo) { + maybeAddExample(combo as Props) + index++ + } + } + } + + if (exampleCount >= maxExamples) { + console.error( + `Too many examples for ${Component.displayName}! Add a filter to the config.` + ) + } + + // eslint-disable-next-line no-console + console.info( + `Generated ${exampleCount} examples for ${Component.displayName}` + ) + + sections.forEach(({ pages }) => { + pages.forEach((page) => { + // eslint-disable-next-line no-param-reassign + page.parameters = getParameters(page) + }) + }) + return sections +} + +function isEmpty( + obj: unknown +): obj is null | undefined | Record { + if (typeof obj !== 'object') return true + for (const key in obj) { + if (Object.hasOwnProperty.call(obj, key)) return false + } + return true +} + +export default generateComponentExamples diff --git a/packages/ui-scripts/lib/test/generatePropCombinations.ts b/packages/ui-scripts/lib/test/generatePropCombinations.ts new file mode 100644 index 0000000000..ace5062368 --- /dev/null +++ b/packages/ui-scripts/lib/test/generatePropCombinations.ts @@ -0,0 +1,86 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +type Props = T extends Record ? Record : T +type ArrayElement = A extends readonly (infer T)[] ? T : never + +/** + * Given possible values for each prop, returns all combinations of those prop values. + * To generate the prop names and values from the component source see the `parsePropValues` utility + * + * @param {Object} propValues an object with the shape {propName: arrayOfPossibleValues} + * @returns {Array} an array of all prop combinations [{propAName: propAValue, propBName: propBValue}] + * + * @module generatePropCombinations + * @private + */ +export function generatePropCombinations>( + propValues: Props +) { + type PropValueType = ArrayElement[keyof Props]> + const propNames = Object.keys(propValues) + const combos: Array, PropValueType>> = [] + + if (!propNames.length) return combos + + const numProps = propNames.length + for (let i = 0; i < numProps; i++) { + const propName = propNames[i] + const valuesForProp = propValues[propName as keyof Props] + + if (!Array.isArray(valuesForProp) || !valuesForProp.length) { + throw new Error( + `[ui-examples-loader] Please provide a non-empty array of possible values for + prop ${propName}. in "propValues"` + ) + } + + const numValues = valuesForProp.length + const numCombos = combos.length + + for (let j = 0; j < numValues; j++) { + const propValue = valuesForProp[j] + if (numCombos > 0) { + for (let k = 0; k < numCombos; k++) { + const combo = combos[k] + + // Check against the keys of the object here. `combo[propName]` could + // evaluate to a boolean value which will mess up this logic. + if (!Object.keys(combo).includes(propName)) { + // eslint-disable-next-line no-param-reassign + combo[propName as keyof Props] = propValue + } else { + combos.push({ ...combo, [propName]: propValue }) + } + } + } else { + //@ts-expect-error TODO: fix this + combos.push({ [propName]: propValue as PropValueType }) + } + } + } + return combos +} + +export default generatePropCombinations