diff --git a/scripts/pluralize.js b/scripts/pluralize.js new file mode 100644 index 0000000..1dc4100 --- /dev/null +++ b/scripts/pluralize.js @@ -0,0 +1,17 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +const pluralizationMap = { + Avatar: "Avatars", + ChatBubble: "ChatBubbles", + LoadingBar: "LoadingBars", +}; + +function pluralizeComponentName(componentName) { + if (!(componentName in pluralizationMap)) { + throw new Error(`Could not find the plural case for ${componentName}.`); + } + + return pluralizationMap[componentName]; +} + +export { pluralizeComponentName }; diff --git a/scripts/test-utils.js b/scripts/test-utils.js index ddf7fda..f41a015 100644 --- a/scripts/test-utils.js +++ b/scripts/test-utils.js @@ -7,12 +7,120 @@ import path from "node:path"; import { default as convertToSelectorUtil } from "@cloudscape-design/test-utils-converter"; +import { pluralizeComponentName } from "./pluralize.js"; import { pascalCase, writeSourceFile } from "./utils.js"; const components = globbySync(["src/test-utils/dom/**/index.ts", "!src/test-utils/dom/index.ts"]).map((fileName) => fileName.replace("src/test-utils/dom/", "").replace("/index.ts", ""), ); +function toWrapper(componentClass) { + return `${componentClass}Wrapper`; +} + +const configs = { + common: { + buildFinder: ({ componentName, componentNamePlural }) => ` + ElementWrapper.prototype.find${componentName} = function(selector) { + const rootSelector = \`.$\{${toWrapper(componentName)}.rootSelector}\`; + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, ${toWrapper(componentName)}); + }; + + ElementWrapper.prototype.findAll${componentNamePlural} = function(selector) { + return this.findAllComponents(${toWrapper(componentName)}, selector); + };`, + }, + dom: { + defaultExport: `export default function wrapper(root: Element = document.body) { if (document && document.body && !document.body.contains(root)) { console.warn('[AwsUi] [test-utils] provided element is not part of the document body, interactions may work incorrectly')}; return new ElementWrapper(root); }`, + buildFinderInterface: ({ componentName, componentNamePlural }) => ` + /** + * Returns the wrapper of the first ${componentName} that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first ${componentName}. + * If no matching ${componentName} is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {${toWrapper(componentName)} | null} + */ + find${componentName}(selector?: string): ${toWrapper(componentName)} | null; + + /** + * Returns an array of ${componentName} wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the ${componentNamePlural} inside the current wrapper. + * If no matching ${componentName} is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array<${toWrapper(componentName)}>} + */ + findAll${componentNamePlural}(selector?: string): Array<${toWrapper(componentName)}>;`, + }, + selectors: { + defaultExport: `export default function wrapper(root: string = 'body') { return new ElementWrapper(root); }`, + buildFinderInterface: ({ componentName, componentNamePlural }) => ` + /** + * Returns a wrapper that matches the ${componentNamePlural} with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches ${componentNamePlural}. + * + * @param {string} [selector] CSS Selector + * @returns {${toWrapper(componentName)}} + */ + find${componentName}(selector?: string): ${toWrapper(componentName)}; + + /** + * Returns a multi-element wrapper that matches ${componentNamePlural} with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches ${componentNamePlural}. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper<${toWrapper(componentName)}>} + */ + findAll${componentNamePlural}(selector?: string): MultiElementWrapper<${toWrapper(componentName)}>;`, + }, +}; + +function generateTestUtilMetaData() { + const testUtilsSrcDir = path.resolve("src/test-utils"); + const metaData = components.reduce((allMetaData, componentFolderName) => { + const absPathComponentFolder = path.resolve(testUtilsSrcDir, componentFolderName); + const relPathTestUtilFile = `./${path.relative(testUtilsSrcDir, absPathComponentFolder)}`; + + const componentNameKebab = componentFolderName; + const componentName = pascalCase(componentNameKebab); + const componentNamePlural = pluralizeComponentName(componentName); + + const componentMetaData = { + componentName, + componentNamePlural, + relPathTestUtilFile, + }; + + return allMetaData.concat(componentMetaData); + }, []); + + return metaData; +} + +function generateFindersInterfaces({ testUtilMetaData, testUtilType, configs }) { + const { buildFinderInterface } = configs[testUtilType]; + const findersInterfaces = testUtilMetaData.map(buildFinderInterface); + + // we need to redeclare the interface in its original definition, extending a re-export will not work + // https://github.com/microsoft/TypeScript/issues/12607 + const interfaces = `declare module '@cloudscape-design/test-utils-core/dist/${testUtilType}' { + interface ElementWrapper { + ${findersInterfaces.join("\n")} + } + }`; + + return interfaces; +} + +function generateFindersImplementations({ testUtilMetaData, configs }) { + const { buildFinder } = configs.common; + const findersImplementations = testUtilMetaData.map(buildFinder); + return findersImplementations.join("\n"); +} + generateSelectorUtils(); generateDomIndexFile(); generateSelectorsIndexFile(); @@ -31,8 +139,7 @@ function generateSelectorUtils() { function generateDomIndexFile() { const content = generateIndexFileContent({ testUtilType: "dom", - buildFinderInterface: (componentName) => - `find${pascalCase(componentName)}(selector?: string): ${pascalCase(componentName)}Wrapper | null;`, + testUtilMetaData: generateTestUtilMetaData(), }); writeSourceFile("./src/test-utils/dom/index.ts", content); } @@ -40,49 +147,38 @@ function generateDomIndexFile() { function generateSelectorsIndexFile() { const content = generateIndexFileContent({ testUtilType: "selectors", - buildFinderInterface: (componentName) => - `find${pascalCase(componentName)}(selector?: string): ${pascalCase(componentName)}Wrapper;`, + testUtilMetaData: generateTestUtilMetaData(), }); writeSourceFile("./src/test-utils/selectors/index.ts", content); } -function generateIndexFileContent({ testUtilType, buildFinderInterface }) { +function generateIndexFileContent({ testUtilType, testUtilMetaData }) { + const config = configs[testUtilType]; + if (config === undefined) { + throw new Error("Unknown test util type"); + } + return [ // language=TypeScript `import { ElementWrapper } from '@cloudscape-design/test-utils-core/${testUtilType}';`, `import '@cloudscape-design/components/test-utils/${testUtilType}';`, `import { appendSelector } from '@cloudscape-design/test-utils-core/utils';`, `export { ElementWrapper };`, - ...components.map((componentName) => { - const componentImport = `./${componentName}/index`; + ...testUtilMetaData.map((metaData) => { + const { componentName, relPathTestUtilFile } = metaData; + return ` - import ${pascalCase(componentName)}Wrapper from '${componentImport}'; - export { ${pascalCase(componentName)}Wrapper }; + import ${toWrapper(componentName)} from '${relPathTestUtilFile}'; + export { ${componentName}Wrapper }; `; }), - // we need to redeclare the interface in its original definition, extending a re-export will not work - // https://github.com/microsoft/TypeScript/issues/12607 - `declare module '@cloudscape-design/test-utils-core/dist/${testUtilType}' { - interface ElementWrapper { - ${components.map((componentName) => buildFinderInterface(componentName)).join("\n")} - } - }`, - ...components.map((componentName) => { - // language=TypeScript - return `ElementWrapper.prototype.find${pascalCase(componentName)} = function(selector) { - const rootSelector = \`.$\{${pascalCase(componentName)}Wrapper.rootSelector}\`; - // casting to 'any' is needed to avoid this issue with generics - // https://github.com/microsoft/TypeScript/issues/29132 - return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, ${pascalCase( - componentName, - )}Wrapper); - };`; - }), - `export { createWrapper as default } from '@cloudscape-design/test-utils-core/${testUtilType}';`, + generateFindersInterfaces({ testUtilMetaData, testUtilType, configs }), + generateFindersImplementations({ testUtilMetaData, configs }), + config.defaultExport, ].join("\n"); } function compileTypescript() { const config = path.resolve("src/test-utils/tsconfig.json"); - execaSync("tsc", ["-p", config, "--sourceMap"], { stdio: "inherit" }); + execaSync("tsc", ["-p", config, "--sourceMap", "--inlineSources"], { stdio: "inherit" }); } diff --git a/src/__tests__/__snapshots__/test-utils-wrappers.test.tsx.snap b/src/__tests__/__snapshots__/test-utils-wrappers.test.tsx.snap new file mode 100644 index 0000000..f7ff663 --- /dev/null +++ b/src/__tests__/__snapshots__/test-utils-wrappers.test.tsx.snap @@ -0,0 +1,229 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Generate test utils ElementWrapper > 'dom' ElementWrapper matches the snapshot 1`] = ` +"import { ElementWrapper } from '@cloudscape-design/test-utils-core/dom'; +import '@cloudscape-design/components/test-utils/dom'; +import { appendSelector } from '@cloudscape-design/test-utils-core/utils'; +export { ElementWrapper }; + + import AvatarWrapper from './avatar'; + export { AvatarWrapper }; + + + import ChatBubbleWrapper from './chat-bubble'; + export { ChatBubbleWrapper }; + + + import LoadingBarWrapper from './loading-bar'; + export { LoadingBarWrapper }; + +declare module '@cloudscape-design/test-utils-core/dist/dom' { + interface ElementWrapper { + + /** + * Returns the wrapper of the first Avatar that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first Avatar. + * If no matching Avatar is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {AvatarWrapper | null} + */ + findAvatar(selector?: string): AvatarWrapper | null; + + /** + * Returns an array of Avatar wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the Avatars inside the current wrapper. + * If no matching Avatar is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ + findAllAvatars(selector?: string): Array; + + /** + * Returns the wrapper of the first ChatBubble that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first ChatBubble. + * If no matching ChatBubble is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {ChatBubbleWrapper | null} + */ + findChatBubble(selector?: string): ChatBubbleWrapper | null; + + /** + * Returns an array of ChatBubble wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the ChatBubbles inside the current wrapper. + * If no matching ChatBubble is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ + findAllChatBubbles(selector?: string): Array; + + /** + * Returns the wrapper of the first LoadingBar that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first LoadingBar. + * If no matching LoadingBar is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {LoadingBarWrapper | null} + */ + findLoadingBar(selector?: string): LoadingBarWrapper | null; + + /** + * Returns an array of LoadingBar wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the LoadingBars inside the current wrapper. + * If no matching LoadingBar is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ + findAllLoadingBars(selector?: string): Array; + } + } + + ElementWrapper.prototype.findAvatar = function(selector) { + const rootSelector = \`.\${AvatarWrapper.rootSelector}\`; + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, AvatarWrapper); + }; + + ElementWrapper.prototype.findAllAvatars = function(selector) { + return this.findAllComponents(AvatarWrapper, selector); + }; + + ElementWrapper.prototype.findChatBubble = function(selector) { + const rootSelector = \`.\${ChatBubbleWrapper.rootSelector}\`; + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, ChatBubbleWrapper); + }; + + ElementWrapper.prototype.findAllChatBubbles = function(selector) { + return this.findAllComponents(ChatBubbleWrapper, selector); + }; + + ElementWrapper.prototype.findLoadingBar = function(selector) { + const rootSelector = \`.\${LoadingBarWrapper.rootSelector}\`; + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, LoadingBarWrapper); + }; + + ElementWrapper.prototype.findAllLoadingBars = function(selector) { + return this.findAllComponents(LoadingBarWrapper, selector); + }; +export default function wrapper(root: Element = document.body) { if (document && document.body && !document.body.contains(root)) { console.warn('[AwsUi] [test-utils] provided element is not part of the document body, interactions may work incorrectly')}; return new ElementWrapper(root); }" +`; + +exports[`Generate test utils ElementWrapper > 'selectors' ElementWrapper matches the snapshot 1`] = ` +"import { ElementWrapper } from '@cloudscape-design/test-utils-core/selectors'; +import '@cloudscape-design/components/test-utils/selectors'; +import { appendSelector } from '@cloudscape-design/test-utils-core/utils'; +export { ElementWrapper }; + + import AvatarWrapper from './avatar'; + export { AvatarWrapper }; + + + import ChatBubbleWrapper from './chat-bubble'; + export { ChatBubbleWrapper }; + + + import LoadingBarWrapper from './loading-bar'; + export { LoadingBarWrapper }; + +declare module '@cloudscape-design/test-utils-core/dist/selectors' { + interface ElementWrapper { + + /** + * Returns a wrapper that matches the Avatars with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches Avatars. + * + * @param {string} [selector] CSS Selector + * @returns {AvatarWrapper} + */ + findAvatar(selector?: string): AvatarWrapper; + + /** + * Returns a multi-element wrapper that matches Avatars with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches Avatars. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ + findAllAvatars(selector?: string): MultiElementWrapper; + + /** + * Returns a wrapper that matches the ChatBubbles with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches ChatBubbles. + * + * @param {string} [selector] CSS Selector + * @returns {ChatBubbleWrapper} + */ + findChatBubble(selector?: string): ChatBubbleWrapper; + + /** + * Returns a multi-element wrapper that matches ChatBubbles with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches ChatBubbles. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ + findAllChatBubbles(selector?: string): MultiElementWrapper; + + /** + * Returns a wrapper that matches the LoadingBars with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches LoadingBars. + * + * @param {string} [selector] CSS Selector + * @returns {LoadingBarWrapper} + */ + findLoadingBar(selector?: string): LoadingBarWrapper; + + /** + * Returns a multi-element wrapper that matches LoadingBars with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches LoadingBars. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ + findAllLoadingBars(selector?: string): MultiElementWrapper; + } + } + + ElementWrapper.prototype.findAvatar = function(selector) { + const rootSelector = \`.\${AvatarWrapper.rootSelector}\`; + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, AvatarWrapper); + }; + + ElementWrapper.prototype.findAllAvatars = function(selector) { + return this.findAllComponents(AvatarWrapper, selector); + }; + + ElementWrapper.prototype.findChatBubble = function(selector) { + const rootSelector = \`.\${ChatBubbleWrapper.rootSelector}\`; + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, ChatBubbleWrapper); + }; + + ElementWrapper.prototype.findAllChatBubbles = function(selector) { + return this.findAllComponents(ChatBubbleWrapper, selector); + }; + + ElementWrapper.prototype.findLoadingBar = function(selector) { + const rootSelector = \`.\${LoadingBarWrapper.rootSelector}\`; + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, LoadingBarWrapper); + }; + + ElementWrapper.prototype.findAllLoadingBars = function(selector) { + return this.findAllComponents(LoadingBarWrapper, selector); + }; +export default function wrapper(root: string = 'body') { return new ElementWrapper(root); }" +`; diff --git a/src/__tests__/test-utils-wrappers.test.tsx b/src/__tests__/test-utils-wrappers.test.tsx new file mode 100644 index 0000000..c0e3141 --- /dev/null +++ b/src/__tests__/test-utils-wrappers.test.tsx @@ -0,0 +1,24 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import fs from "fs"; +import path from "path"; +import { describe, expect, test } from "vitest"; + +describe("Generate test utils ElementWrapper", () => { + const importPaths = [ + { + type: "dom", + relativePath: "../test-utils/dom/index.ts", + }, + { + type: "selectors", + relativePath: "../test-utils/selectors/index.ts", + }, + ] as const; + + test.each(importPaths)("$type ElementWrapper matches the snapshot", ({ relativePath }) => { + const testUtilsPath = path.join(__dirname, relativePath); + const domWrapper = fs.readFileSync(testUtilsPath, "utf8"); + expect(domWrapper).toMatchSnapshot(); + }); +}); diff --git a/src/__tests__/test-utils.test.tsx b/src/__tests__/test-utils.test.tsx new file mode 100644 index 0000000..821a6b4 --- /dev/null +++ b/src/__tests__/test-utils.test.tsx @@ -0,0 +1,123 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ComponentType } from "react"; +import { render } from "@testing-library/react"; +import { paramCase, pascalCase } from "change-case"; +import { describe, expect, test } from "vitest"; + +import * as components from "../../lib/components"; +import createWrapperDom, { ElementWrapper as DomElementWrapper } from "../../lib/components/test-utils/dom"; +import createWrapperSelectors from "../../lib/components/test-utils/selectors"; +import { defaultProps } from "./default-props"; + +const RENDER_COMPONENTS_DEFAULT_PROPS: Record[] = [ + { + "data-testid": "first-item", + "data-name": "first item", + }, + { + "data-testid": "second-item", + "data-name": "second item", + }, +]; + +function renderComponents(componentName: string, props = RENDER_COMPONENTS_DEFAULT_PROPS) { + const Component = components[componentName as keyof typeof components] as ComponentType; + const componentDefaultProps = defaultProps[paramCase(componentName) as keyof typeof defaultProps]; + return render( +
+ {props.map(({ ...customProps }, index) => ( + + ))} +
, + ); +} + +function getComponentSelectors(componentName: string) { + const componentNamePascalCase = pascalCase(componentName); + const findAllRegex = new RegExp(`findAll${componentNamePascalCase}.*`); + + // The same set of selector functions are present in both dom and selectors. + // For this reason, looking into DOM is representative of both groups. + const wrapperPropsList = Object.keys(DomElementWrapper.prototype); + + // Every component has the same set of selector functions. + // For this reason, casting the function names into the Avatar component. + const findName = `find${componentNamePascalCase}` as "findAvatar"; + const findAllName = wrapperPropsList.find((selector) => findAllRegex.test(selector)) as "findAllAvatars"; + + return { findName, findAllName }; +} + +describe.each(Object.keys(components))("ElementWrapper selectors for %s component", (componentName) => { + const { findName, findAllName } = getComponentSelectors(componentName); + + describe("dom wrapper", () => { + test(`${findName} returns the first ${componentName}`, () => { + const { container } = renderComponents(componentName); + const wrapper = createWrapperDom(container); + const element = wrapper[findName]()!.getElement(); + + expect(element).toHaveAttribute("data-name", "first item"); + }); + + test(`${findAllName} returns all of the ${componentName} components`, () => { + const { container } = renderComponents(componentName); + const wrapper = createWrapperDom(container); + const elementNameAttributes = wrapper[findAllName]().map((component) => + component!.getElement().getAttribute("data-name"), + ); + + expect(elementNameAttributes).toEqual(["first item", "second item"]); + }); + + test(`${findAllName} returns only the matching ${componentName} components, when a selector is specified`, () => { + const { container } = renderComponents(componentName, [ + { "data-type": "first-type", "data-name": "first item" }, + { "data-type": "second-type", "data-name": "second item" }, + { "data-type": "second-type", "data-name": "third item" }, + ]); + const wrapper = createWrapperDom(container); + const elementNameAttributes = wrapper[findAllName]("[data-type=second-type]").map((component) => + component!.getElement().getAttribute("data-name"), + ); + + expect(elementNameAttributes).toEqual(["second item", "third item"]); + }); + }); + + describe("selectors wrapper", () => { + test(`${findName} returns a selector that matches the ${componentName}`, () => { + const { container } = renderComponents(componentName); + const wrapper = createWrapperSelectors(); + const selector = wrapper[findName]().toSelector(); + const element = container.querySelector(selector); + + expect(element).toHaveAttribute("data-name", "first item"); + }); + + test(`${findAllName} returns a selector that matches the ${componentName} with nth-child index`, () => { + const { container } = renderComponents(componentName); + const wrapper = createWrapperSelectors(); + const selector = wrapper[findAllName]().get(2).toSelector(); + const element = container.querySelector(selector); + + expect(element).toHaveAttribute("data-name", "second item"); + }); + + test(`${findAllName} appends the specified selector to the default ${componentName} selectors`, () => { + const { container } = renderComponents(componentName, [ + { "data-type": "first-type", "data-name": "first item" }, + { "data-type": "second-type", "data-name": "second item" }, + ]); + const wrapper = createWrapperSelectors(); + const firstItemSelector = wrapper[findAllName]("[data-type=second-type]").get(1).toSelector(); + const secondItemSelector = wrapper[findAllName]("[data-type=second-type]").get(2).toSelector(); + const firstElement = container.querySelector(firstItemSelector); + const secondElement = container.querySelector(secondItemSelector); + + expect(firstElement).toBeFalsy(); + expect(secondElement).toBeTruthy(); + }); + }); +});