diff --git a/packages/odyssey-react-mui/package.json b/packages/odyssey-react-mui/package.json index 7591ad9f23..53433987cc 100644 --- a/packages/odyssey-react-mui/package.json +++ b/packages/odyssey-react-mui/package.json @@ -81,6 +81,7 @@ "@okta/odyssey-design-tokens": "workspace:^", "@types/luxon": "^3.4.2", "date-fns": "^2.30.0", + "dom-accessibility-api": "^0.7.0", "i18next": "^23.8.2", "luxon": "^3.4.4", "material-react-table": "^2.11.3", diff --git a/packages/odyssey-react-mui/scripts/generateTestSelectorsJson.ts b/packages/odyssey-react-mui/scripts/generateTestSelectorsJson.ts index 80191798af..3ab0185116 100644 --- a/packages/odyssey-react-mui/scripts/generateTestSelectorsJson.ts +++ b/packages/odyssey-react-mui/scripts/generateTestSelectorsJson.ts @@ -16,7 +16,7 @@ import { join } from "node:path"; const distDirectory = join(__dirname, "../dist/test-selectors"); import("../src/test-selectors/index").then( - ({ odysseyTestSelectors: testSelector }) => + ({ odysseyTestSelector: testSelector }) => mkdir(distDirectory) .catch(() => null) .then(() => diff --git a/packages/odyssey-react-mui/src/Autocomplete.tsx b/packages/odyssey-react-mui/src/Autocomplete.tsx index 7f42773c69..f740721b6c 100644 --- a/packages/odyssey-react-mui/src/Autocomplete.tsx +++ b/packages/odyssey-react-mui/src/Autocomplete.tsx @@ -43,6 +43,38 @@ import { useInputValues, getControlState, } from "./inputUtils"; +import { TestSelector } from "./test-selectors"; + +export const AutocompleteTestSelector = { + accessibleText: { + errorMessage: "errorMessage", + hint: "description", + label: "label", + }, + children: { + list: { + children: { + listItem: { + elementSelector: { + method: "ByRole", + options: { + label: "name", + }, + role: "option", + }, + }, + }, + isControlledElement: true, + }, + }, + elementSelector: { + method: "ByRole", + options: { + label: "name", + }, + role: "combobox", + }, +} as const satisfies TestSelector; type SetItemSize = (size: number) => void; diff --git a/packages/odyssey-react-mui/src/Callout.tsx b/packages/odyssey-react-mui/src/Callout.tsx index d904c43328..8b22564533 100644 --- a/packages/odyssey-react-mui/src/Callout.tsx +++ b/packages/odyssey-react-mui/src/Callout.tsx @@ -20,46 +20,34 @@ import { DesignTokens, useOdysseyDesignTokens, } from "./OdysseyDesignTokensContext"; -import { type FeatureTestSelector } from "./test-selectors"; +import { type TestSelector } from "./test-selectors"; import { Paragraph } from "./Typography"; import { useUniqueId } from "./useUniqueId"; -export const CalloutTestSelectors = { - feature: { +export const CalloutTestSelector = { + accessibleText: { + text: "description", + title: "label", + }, + children: { link: { - selector: { + elementSelector: { method: "ByRole", options: { - name: "${linkText}", + linkText: "name", }, role: "link", - templateVariableNames: ["linkText"], - }, - }, - text: { - selector: { - method: "ByText", - templateVariableNames: ["text"], - text: "${text}", - }, - }, - title: { - selector: { - method: "ByText", - templateVariableNames: ["title"], - text: "${title}", }, }, }, - selector: { + elementSelector: { method: "ByRole", options: { - name: "${title}", + title: "name", }, - role: "${role}", - templateVariableNames: ["role", "title"], + role: ["alert", "status"], }, -} as const satisfies FeatureTestSelector; +} as const satisfies TestSelector; export const calloutRoleValues = ["status", "alert"] as const; export const calloutSeverityValues = [ diff --git a/packages/odyssey-react-mui/src/Select.tsx b/packages/odyssey-react-mui/src/Select.tsx index 7e542e33d8..cf65654f61 100644 --- a/packages/odyssey-react-mui/src/Select.tsx +++ b/packages/odyssey-react-mui/src/Select.tsx @@ -49,6 +49,44 @@ import { useOdysseyDesignTokens, DesignTokens, } from "./OdysseyDesignTokensContext"; +import { TestSelector } from "./test-selectors"; + +export const SelectTestSelector = { + accessibleText: { + errorMessage: "errorMessage", + hint: "description", + label: "label", + }, + children: { + list: { + accessibleText: { + label: "label", + }, + children: { + listItem: { + accessibleText: { + label: "label", + }, + elementSelector: { + method: "ByRole", + options: { + label: "name", + }, + role: "option", + }, + }, + }, + isControlledElement: true, + }, + }, + elementSelector: { + method: "ByRole", + options: { + label: "name", + }, + role: "combobox", + }, +} as const satisfies TestSelector; export type SelectOption = { text: string; diff --git a/packages/odyssey-react-mui/src/Tabs.tsx b/packages/odyssey-react-mui/src/Tabs.tsx index aacd239ba8..76440f7f68 100644 --- a/packages/odyssey-react-mui/src/Tabs.tsx +++ b/packages/odyssey-react-mui/src/Tabs.tsx @@ -30,30 +30,28 @@ import { Badge, BadgeProps } from "./Badge"; import { Box } from "./Box"; import { HtmlProps } from "./HtmlProps"; import { useOdysseyDesignTokens } from "./OdysseyDesignTokensContext"; -import { type FeatureTestSelector } from "./test-selectors"; +import { type TestSelector } from "./test-selectors"; -export const TabsTestSelectors = { - feature: { +export const TabsTestSelector = { + children: { tabItem: { - selector: { + elementSelector: { method: "ByRole", options: { - name: "${label}", + label: "name", }, - templateVariableNames: ["label"], role: "tab", }, }, }, - selector: { + elementSelector: { method: "ByRole", options: { - name: "${ariaLabel}", + label: "name", }, role: "tablist", - templateVariableNames: ["ariaLabel"], }, -} as const satisfies FeatureTestSelector; +} as const satisfies TestSelector; export type TabItemProps = { /** diff --git a/packages/odyssey-react-mui/src/TextField.tsx b/packages/odyssey-react-mui/src/TextField.tsx index c3ff2f110d..122f34bcf2 100644 --- a/packages/odyssey-react-mui/src/TextField.tsx +++ b/packages/odyssey-react-mui/src/TextField.tsx @@ -30,56 +30,33 @@ import { import { Field } from "./Field"; import { HtmlProps } from "./HtmlProps"; import { FocusHandle, useInputValues, getControlState } from "./inputUtils"; -import { type FeatureTestSelector } from "./test-selectors"; +import { type TestSelector } from "./test-selectors"; -export const TextFieldTestSelectors = { - feature: { - description: { - selector: { - method: "ByText", - templateVariableNames: ["hint"], - text: "${hint}", - }, - }, - errorMessage: { - selector: { - method: "ByText", - templateVariableNames: ["errorMessage"], - text: "${errorMessage}", - }, - }, - input: { - selector: { - method: "ByRole", - options: { - name: "${label}", - }, - role: "textbox", - templateVariableNames: ["label"], - }, - }, - label: { - selector: { - method: "ByRole", - options: { - name: "${label}", - }, - role: "LabelText", - templateVariableNames: ["label"], - }, - }, +export const TextFieldTestSelector = { + accessibleText: { + errorMessage: "errorMessage", + hint: "description", + label: "label", + }, + children: { link: { - selector: { + elementSelector: { method: "ByRole", options: { - name: "${label}", + label: "name", }, - templateVariableNames: ["label"], role: "link", }, }, }, -} as const satisfies FeatureTestSelector; + elementSelector: { + method: "ByRole", + options: { + label: "name", + }, + role: "textbox", + }, +} as const satisfies TestSelector; export const textFieldTypeValues = [ "email", diff --git a/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts b/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts deleted file mode 100644 index 37fcbdfee1..0000000000 --- a/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts +++ /dev/null @@ -1,41 +0,0 @@ -/*! - * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved. - * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") - * - * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * - * See the License for the specific language governing permissions and limitations under the License. - */ - -import { ByRoleOptions } from "@testing-library/dom"; -import { AriaRole } from "react"; - -export type Selector = { - options?: ByRoleOptions; - templateVariableNames: string[]; -} & ( - | { - method: "ByRole"; - role: AriaRole; - } - | { - method: "ByLabelText" | "ByPlaceholderText" | "ByText"; - text: string; - } -); - -export type TestSelector = { - selector: Selector; -}; - -export type FeatureSelector = { - feature: Record; -}; - -export type FeatureTestSelector = - | TestSelector - | FeatureSelector - | (FeatureSelector & TestSelector); diff --git a/packages/odyssey-react-mui/src/test-selectors/getByQuerySelector.ts b/packages/odyssey-react-mui/src/test-selectors/getByQuerySelector.ts new file mode 100644 index 0000000000..a64bff21aa --- /dev/null +++ b/packages/odyssey-react-mui/src/test-selectors/getByQuerySelector.ts @@ -0,0 +1,176 @@ +/*! + * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { + queries, + within, + type BoundFunctions, + type ByRoleMatcher, + type ByRoleOptions, + type Matcher, + type SelectorMatcherOptions, +} from "@testing-library/dom"; + +export type RoleSelectorMethod = "ByRole"; + +export type TextSelectorMethod = + | "ByLabelText" + | "ByPlaceholderText" + | "ByText" + | "ByAltText" + | "ByTitle"; + +export type QueryMethod = "find" | "get" | "query"; + +export type ByRoleMethods = + | "getByRole" + // | "getAllByRole" + | "queryByRole"; +// | "queryAllByRole" +// | "findByRole" +// | "findAllByRole" + +export type ByTextMethods = + | "getByLabelText" + // | "getAllByLabelText" + | "queryByLabelText" + // | "queryAllByLabelText" + // | "findByLabelText" + // | "findAllByLabelText" + | "getByPlaceholderText" + // | "getAllByPlaceholderText" + | "queryByPlaceholderText" + // | "queryAllByPlaceholderText" + // | "findByPlaceholderText" + // | "findAllByPlaceholderText" + | "getByText" + // | "getAllByText" + | "queryByText" + // | "queryAllByText" + // | "findByText" + // | "findAllByText" + | "getByAltText" + // | "getAllByAltText" + | "queryByAltText" + // | "queryAllByAltText" + // | "findByAltText" + // | "findAllByAltText" + | "getByTitle" + // | "getAllByTitle" + | "queryByTitle"; +// | "queryAllByTitle" +// | "findByTitle" +// | "findAllByTitle" + +export const executeTestingLibraryMethod = < + CanvasMethods extends keyof BoundFunctions, +>({ + canvas, + queryMethod, + selectionMethod, +}: { + canvas: Pick, CanvasMethods>; + queryMethod: QueryMethod; + selectionMethod: RoleSelectorMethod | TextSelectorMethod; +}) => + canvas[ + queryMethod.concat(selectionMethod) as keyof Pick< + BoundFunctions, + CanvasMethods + > + ]; + +export const getByQuerySelector = < + LocalQueryMethod extends QueryMethod = "get", +>({ + element, + queryMethod, + queryOptions, + role, + selectionMethod, + text, +}: { + element: HTMLElement; + queryMethod: LocalQueryMethod; +} & ( + | { + queryOptions?: ByRoleOptions; + role: ByRoleMatcher; + selectionMethod: RoleSelectorMethod; + text?: never; + } + | { + queryOptions?: SelectorMatcherOptions; + role?: never; + selectionMethod: TextSelectorMethod; + text: Matcher; + } +)) => { + const canvas = within(element); + + const capturedElement = + selectionMethod === "ByRole" + ? executeTestingLibraryMethod({ + canvas, + queryMethod, + selectionMethod, + })(role, queryOptions) + : executeTestingLibraryMethod({ + canvas, + queryMethod, + selectionMethod, + })(text, queryOptions); + + return capturedElement as LocalQueryMethod extends "get" + ? HTMLElement + : HTMLElement | null; +}; + +export const getByRoleQuerySelector = ({ + element, + queryMethod, + queryOptions, + role, +}: { + element: HTMLElement; + queryMethod: LocalQueryMethod; + queryOptions?: ByRoleOptions; + role: ByRoleMatcher; +}) => + getByQuerySelector({ + element, + queryMethod, + queryOptions, + role, + selectionMethod: "ByRole", + }); + +export const getByTextQuerySelector = ({ + element, + queryMethod, + queryOptions, + selectionMethod, + text, +}: { + element: HTMLElement; + queryMethod: LocalQueryMethod; + queryOptions?: SelectorMatcherOptions; + selectionMethod: TextSelectorMethod; + text: Matcher; +}) => + getByQuerySelector({ + element, + queryMethod, + queryOptions, + selectionMethod, + text, + }); diff --git a/packages/odyssey-react-mui/src/test-selectors/getComputedAccessibleErrorMessageText.ts b/packages/odyssey-react-mui/src/test-selectors/getComputedAccessibleErrorMessageText.ts new file mode 100644 index 0000000000..49a9be01e4 --- /dev/null +++ b/packages/odyssey-react-mui/src/test-selectors/getComputedAccessibleErrorMessageText.ts @@ -0,0 +1,52 @@ +/*! + * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { normalizeText, validateHtmlElement } from "./sanityChecks"; + +// Code modified from: https://github.com/testing-library/jest-dom/blob/main/src/to-have-accessible-errormessage.js + +/** @see https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage */ +export const getComputedAccessibleErrorMessageText = ( + htmlElement: HTMLElement, +) => { + validateHtmlElement(htmlElement); + + const ariaErrorMessageId = htmlElement.getAttribute("aria-errormessage"); + + // `aria-errormessage` only supports a single `id`. + if (Boolean(ariaErrorMessageId) && /\s+/.test(ariaErrorMessageId || "")) { + throw new Error( + "`aria-errormessage` needs to have a single `id`.".concat( + "\n", + `Received: ${ariaErrorMessageId}`, + ), + ); + } + + /** @see https://www.w3.org/TR/wai-aria-1.2/#aria-invalid */ + const ariaInvalid = htmlElement.getAttribute("aria-invalid"); + + // `aria-invalid` only supports `true` when getting an error message. + if (!htmlElement.hasAttribute("aria-invalid") || ariaInvalid === "false") { + throw new Error( + "`aria-invalid` must be `true` when getting an accessible error message.".concat( + "\n", + `Received: ${ariaInvalid}`, + ), + ); + } + + return normalizeText( + htmlElement.ownerDocument.getElementById(ariaErrorMessageId || "") + ?.textContent ?? "", + ); +}; diff --git a/packages/odyssey-react-mui/src/test-selectors/getComputedAccessibleText.ts b/packages/odyssey-react-mui/src/test-selectors/getComputedAccessibleText.ts new file mode 100644 index 0000000000..0bbcd5efda --- /dev/null +++ b/packages/odyssey-react-mui/src/test-selectors/getComputedAccessibleText.ts @@ -0,0 +1,36 @@ +/*! + * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { + computeAccessibleName, + computeAccessibleDescription, +} from "dom-accessibility-api"; + +import { type AccessibleTextSelectorValue } from "./testSelector"; +import { getComputedAccessibleErrorMessageText } from "./getComputedAccessibleErrorMessageText"; + +export const accessibleTextSelector = { + description: computeAccessibleDescription, + errorMessage: getComputedAccessibleErrorMessageText, + label: computeAccessibleName, +} as const satisfies Record< + AccessibleTextSelectorValue, + (element: HTMLElement) => string +>; + +export const getComputedAccessibleText = ({ + element, + type, +}: { + element: HTMLElement; + type: AccessibleTextSelectorValue; +}) => accessibleTextSelector[type](element); diff --git a/packages/odyssey-react-mui/src/test-selectors/index.ts b/packages/odyssey-react-mui/src/test-selectors/index.ts index 3fb7fca27e..746808baf8 100644 --- a/packages/odyssey-react-mui/src/test-selectors/index.ts +++ b/packages/odyssey-react-mui/src/test-selectors/index.ts @@ -10,6 +10,6 @@ * See the License for the specific language governing permissions and limitations under the License. */ -export * from "./featureTestSelector"; +export * from "./queryOdysseySelector"; export * from "./querySelector"; -export * from "./odysseyTestSelectors"; +export * from "./testSelector"; diff --git a/packages/odyssey-react-mui/src/test-selectors/interpolateString.ts b/packages/odyssey-react-mui/src/test-selectors/interpolateString.ts new file mode 100644 index 0000000000..927a44f3aa --- /dev/null +++ b/packages/odyssey-react-mui/src/test-selectors/interpolateString.ts @@ -0,0 +1,41 @@ +/*! + * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +export const isRegExpString = (string: string) => /^\/*(.+)\/$/.test(string); + +/** @deprecated This function is only to be used to interpolate strings in unit tests. It uses `eval` internally which could be unsafe if it wasn't done explicitly in our testing environment. */ +export const interpolateString = ( + string: string, + replacements: Record, +) => { + // Writes out all replacement pairs as "const variableName = value" because we don't know which we want. Then at the end, we `return` the value we want by simply adding it as the last thing in eval'd code. + const interpolatedString = eval(` + ${Object.entries(replacements) + .map( + ([variableName, value]) => + `const ${variableName} = ${ + typeof value === "string" ? JSON.stringify(value) : value + };`, + ) + .join("")} + + \`${string}\` + `) as string; + + if (isRegExpString(interpolatedString)) { + // If this string matches the RegExp format, we know that it's a RegExp, and we'll evaluate it to one. TypeScript has no way of knowing the resulting type, so we have to set that ourselves. + return eval(interpolatedString) as RegExp; + } + + // This interpolated string is just a string. + return interpolatedString; +}; diff --git a/packages/odyssey-react-mui/src/test-selectors/linkedHtmlSelectors.ts b/packages/odyssey-react-mui/src/test-selectors/linkedHtmlSelectors.ts new file mode 100644 index 0000000000..9d739199fd --- /dev/null +++ b/packages/odyssey-react-mui/src/test-selectors/linkedHtmlSelectors.ts @@ -0,0 +1,73 @@ +/*! + * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { type AriaRole } from "react"; +import { ElementError } from "./sanityChecks"; +import { getRole } from "dom-accessibility-api"; + +/** + * For `aria-haspopup`: + * @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-haspopup + * For `datalist`: + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/datalist + */ +export const getControlledElement = ({ + element, + role, +}: { + element: HTMLElement; + /** If this element controls multiple items, it might be valuable to help narrow down the specific item's `role`. */ + role?: AriaRole; +}) => { + if (element instanceof HTMLInputElement && element.list) { + return element.list; + } + + if (element.getAttribute("aria-expanded") === "false") { + throw new ElementError( + "Popup isn't open in ARIA; therefore, it cannot be captured.", + element, + ); + } + + const linkedElementIds = + element.getAttribute("aria-controls") || element.getAttribute("aria-owns"); + + if (!linkedElementIds) { + throw new ElementError( + "Popup isn't linked; therefore, it cannot be captured.", + element, + ); + } + + const linkedElement = linkedElementIds + .split(" ") + .map((linkedElementId) => + element.ownerDocument.getElementById(linkedElementId), + ) + // This can be `.filter(Boolean)` when Inferred Type Predicates is in TypeScript (which should be part of the version we're using): https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-5.html#:~:text=Inferred%20Type%20Predicates,Thanks%20Dan! + .filter((linkedElement): linkedElement is HTMLElement => + Boolean(linkedElement), + ) + .find((linkedElement) => + role ? getRole(linkedElement) === role : Boolean(linkedElement), + ); + + if (!linkedElement) { + throw new ElementError( + "Controlled element isn't available; therefore, it cannot be captured.", + element, + ); + } + + return linkedElement; +}; diff --git a/packages/odyssey-react-mui/src/test-selectors/odysseyTestSelectors.ts b/packages/odyssey-react-mui/src/test-selectors/odysseyTestSelectors.ts deleted file mode 100644 index a096f84929..0000000000 --- a/packages/odyssey-react-mui/src/test-selectors/odysseyTestSelectors.ts +++ /dev/null @@ -1,22 +0,0 @@ -/*! - * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved. - * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") - * - * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * - * See the License for the specific language governing permissions and limitations under the License. - */ - -import { type FeatureTestSelector } from "./featureTestSelector"; -import { CalloutTestSelectors } from "../Callout"; -import { TabsTestSelectors } from "../Tabs"; -import { TextFieldTestSelectors } from "../TextField"; - -export const odysseyTestSelectors = { - Callout: CalloutTestSelectors, - Tabs: TabsTestSelectors, - TextField: TextFieldTestSelectors, -} as const satisfies Record; diff --git a/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts b/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts new file mode 100644 index 0000000000..36b297c954 --- /dev/null +++ b/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts @@ -0,0 +1,36 @@ +/*! + * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { querySelector } from "./querySelector"; +import { type TestSelector } from "./testSelector"; +import { AutocompleteTestSelector } from "../Autocomplete"; +import { CalloutTestSelector } from "../Callout"; +import { SelectTestSelector } from "../Select"; +import { TabsTestSelector } from "../Tabs"; +import { TextFieldTestSelector } from "../TextField"; + +export const odysseyTestSelector = { + Autocomplete: AutocompleteTestSelector, + Callout: CalloutTestSelector, + Select: SelectTestSelector, + Tabs: TabsTestSelector, + TextField: TextFieldTestSelector, +} as const satisfies Record; + +export const queryOdysseySelector = < + ComponentName extends keyof typeof odysseyTestSelector, +>( + /** + * Name of the component you want to select within. + */ + componentName: ComponentName, +) => querySelector(odysseyTestSelector[componentName]); diff --git a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts index 93955f515b..a45d3ccf75 100644 --- a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts @@ -11,188 +11,239 @@ */ import { - queries, - within, - type BoundFunctions, - type ByRoleOptions, - type GetByText, - GetByRole, -} from "@testing-library/dom"; - -import { - type FeatureTestSelector, + type AccessibleTextSelector, + type AriaRole, + type ElementChildSelector, type TestSelector, -} from "./featureTestSelector"; -import { odysseyTestSelectors } from "./odysseyTestSelectors"; - -export const interpolateString = ( - string: string, - values: Record, -) => { - const interpolatedString = eval(` - ${Object.entries(values) - .map( - ([key, value]) => - `const ${key} = ${ - typeof value === "string" ? JSON.stringify(value) : value - };`, - ) - .join("")} - - \`${string}\` - `) as string; - - if (/^\/*(.+)\/$/.test(interpolatedString)) { - return eval(interpolatedString) as RegExp; - } + type ElementSelector, +} from "./testSelector"; +import { getComputedAccessibleText } from "./getComputedAccessibleText"; +import { + getByRoleQuerySelector, + getByTextQuerySelector, + type QueryMethod, +} from "./getByQuerySelector"; +import { getControlledElement } from "./linkedHtmlSelectors"; +import { ElementError } from "./sanityChecks"; - return interpolatedString; -}; +export type InnerQuerySelectorProps< + LocalTestSelector extends TestSelector, + LocalQueryMethod extends QueryMethod, +> = { + /** + * Testing Library method used to query elements. + */ + queryMethod?: LocalQueryMethod; +} & (LocalTestSelector extends ElementSelector + ? LocalTestSelector["elementSelector"] extends { + role: infer Role; + } + ? Role extends AriaRole[] + ? { + /** + * Role is used when you have an optional `role`; otherwise, it'd baked into the metadata. + */ + role: Role[number]; + } + : object + : object + : object) & + (LocalTestSelector extends ElementSelector + ? { + /** + * Helps narrow down HTML selection to the correct element. + */ + options: Record< + keyof LocalTestSelector["elementSelector"]["options"], + string | RegExp + >; + } + : object); -const getByQuerySelector = ({ - canvas, - method, - options, +export const captureElement = < + LocalTestSelector extends TestSelector, + QuerySelectorOptions extends Record, + LocalQueryMethod extends QueryMethod = "get", +>({ + containerElement, + queryMethod, + querySelectorOptions, role, - text, + testSelector, }: { - canvas: BoundFunctions; - method: "ByRole" | "ByLabelText" | "ByPlaceholderText" | "ByText"; - options?: ByRoleOptions; - role?: Parameters[1]; - text?: Parameters[1]; + containerElement: HTMLElement; + queryMethod: LocalQueryMethod; + querySelectorOptions?: QuerySelectorOptions; + role?: AriaRole; + testSelector: LocalTestSelector; }) => { - if (method === "ByRole") { - return canvas.getByRole( - // TODO: These should eventually reference `query` as the function identifier. - role!, - options, - ); - } else if (method === "ByLabelText") { - return canvas.getByLabelText( - // These should eventually reference `query` as the function identifier. - text!, // TODO: Use TypeScript `Infer` to ensure `label` is required when it's `ByLabelText`. - options, - ); - } else if (method === "ByPlaceholderText") { - return canvas.getByPlaceholderText( - // These should eventually reference `query` as the function identifier. - text!, // TODO: Use TypeScript `Infer` to ensure `label` is required when it's `ByLabelText`. - options, - ); - } else if (method === "ByText") { - return canvas.getByText( - // These should eventually reference `query` as the function identifier. - text!, // TODO: Use TypeScript `Infer` to ensure `description` is required when it's `ByLabelText`. - options, - ); + if ("elementSelector" in testSelector && querySelectorOptions) { + const sharedProps = { + element: containerElement, + queryMethod, + queryOptions: Object.fromEntries( + Object.entries(testSelector.elementSelector.options).map( + ([testSelectorOptionKey, testingLibraryOptionKey]) => [ + testingLibraryOptionKey, + querySelectorOptions[testSelectorOptionKey], + ], + ), + ), + }; + + if (testSelector.elementSelector.method === "ByRole") { + return getByRoleQuerySelector({ + ...sharedProps, + role: + Array.isArray(testSelector.elementSelector.role) || role + ? role || "" + : testSelector.elementSelector.role, + }); + } + + return getByTextQuerySelector({ + ...sharedProps, + selectionMethod: testSelector.elementSelector.method, + text: testSelector.elementSelector.text, + }); + } else if ( + "isControlledElement" in testSelector && + testSelector.isControlledElement + ) { + try { + return getControlledElement({ element: containerElement }); + } catch (error) { + if (queryMethod === "query") { + return null; + } + + throw error; + } } return null; }; -export const querySelector = ({ - canvas, - templateArgs: templateArgsProp, - testSelectors, -}: { - /** - * Testing Library canvas. This is usually `screen`, but Storybook uses `within(canvas)`. - */ - canvas: BoundFunctions; - templateArgs?: TestSelectors extends TestSelector - ? Record< - TestSelectors["selector"]["templateVariableNames"][number], +export const querySelector = + ( + /** + * Selectors object including children and accessible text selections. + */ + testSelector: LocalTestSelector, + ) => + ( + props: { + /** + * Refers to Testing Library's canvas. This is usually `screen`, but Storybook uses `within(canvas)`. + */ + element: HTMLElement; + } & InnerQuerySelectorProps, + ) => { + const { element: containerElement, queryMethod } = props; + + const capturedElement = captureElement({ + containerElement, + queryMethod: queryMethod || ("get" as const), + querySelectorOptions: "options" in props ? props.options : undefined, + role: "role" in props ? (props.role as AriaRole) : undefined, + testSelector, + }); + + const getAccessibleText = < + LabelName extends LocalTestSelector extends AccessibleTextSelector + ? keyof LocalTestSelector["accessibleText"] + : never, + >( + labelName: LabelName, + ) => { + if (!capturedElement) { + throw new ElementError( + "No child HTML element available", + containerElement, + ); + } + + if (!("accessibleText" in testSelector)) { + throw new Error("Missing `accessibleText` in `TestSelector`"); + } + + return getComputedAccessibleText({ + element: capturedElement, + type: testSelector.accessibleText[labelName], + }); + }; + + const selectChild = < + ChildName extends LocalTestSelector extends ElementChildSelector + ? keyof LocalTestSelector["children"] + : keyof ElementChildSelector, + ChildQueryMethod extends QueryMethod = "get", + >( + childProps: { + name: ChildName; + } & InnerQuerySelectorProps< + LocalTestSelector extends ElementChildSelector + ? LocalTestSelector["children"][ChildName] + : TestSelector, + ChildQueryMethod + >, + ) => { + if (!capturedElement) { + throw new ElementError( + "No child HTML element available", + containerElement, + ); + } + + if (!("children" in testSelector)) { + throw new Error("Missing `children` in `TestSelector`"); + } + + type Options = Record< + LocalTestSelector extends ElementChildSelector + ? LocalTestSelector["children"][ChildName] extends ElementSelector + ? keyof LocalTestSelector["children"][ChildName]["elementSelector"]["options"] + : never + : never, string | RegExp - > - : never; - testSelectors: TestSelectors; -}) => { - const element = - "selector" in testSelectors - ? getByQuerySelector({ - canvas, - method: testSelectors.selector.method, - options: - templateArgsProp && testSelectors.selector.options - ? Object.fromEntries( - Object.entries(testSelectors.selector.options).map( - ([key, value]) => [ - key, - interpolateString(value, templateArgsProp), - ], - ), - ) - : testSelectors.selector.options, - ...(testSelectors.selector.method === "ByRole" + >; + + return querySelector( + testSelector.children[ + childProps.name + ] as LocalTestSelector extends ElementChildSelector + ? LocalTestSelector["children"][ChildName] + : TestSelector, + )( + // @ts-expect-error: Type '{ role?: AriaRole | undefined; options?: Record | undefined; element: HTMLElement...' is not assignable to type '(LocalTestSelector extends ElementChildSelector ? LocalTestSelector["children"][ChildName] : TestSelector) extends { ...; } ? Role extends AriaRole[] ? { ...; } : object : object'.ts(2345) + // `as testSelector.children[ChildName]` narrows the props down enough that TypeScript errors here. We're passing the correct information, but it doesn't know that, and it's difficult to fix this. -Kevin Ghadyani + { + element: capturedElement, + queryMethod: childProps.queryMethod, + ...("options" in childProps && childProps.options ? { - role: templateArgsProp - ? (interpolateString( - testSelectors.selector?.role, - templateArgsProp, - ) as string) - : testSelectors.selector?.role, + options: childProps.options as Options, } - : { - text: templateArgsProp - ? interpolateString( - testSelectors.selector?.text, - templateArgsProp, - ) - : testSelectors.selector?.text, - }), - }) - : null; - - const select = - "feature" in testSelectors - ? ( - featureName: FeatureName, - templateArgs?: (typeof testSelectors)["feature"][FeatureName] extends TestSelector - ? Record< - (typeof testSelectors)["feature"][FeatureName]["selector"]["templateVariableNames"][number], - string | RegExp - > - : never, - ) => - querySelector({ - canvas: element ? within(element) : canvas, - templateArgs, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error: Type 'FeatureName' cannot be used to index type 'Record'.ts(2536) - testSelectors: testSelectors.feature[featureName], - }) - : null; - - return { - element, - select, - }; -}; + : {}), + ...("role" in childProps && childProps.role + ? { + role: childProps.role as AriaRole, + } + : {}), + }, + ); + }; -export const queryOdysseySelector = < - ComponentName extends keyof typeof odysseyTestSelectors, ->({ - canvas, - componentName, - templateArgs, -}: { - canvas: Parameters< - typeof querySelector<(typeof odysseyTestSelectors)[ComponentName]> - >[0]["canvas"]; - /** - * Name of the component you want to select within. - */ - componentName: ComponentName; - /** - * String or RegExp values required for this selector. - */ - templateArgs?: Parameters< - typeof querySelector<(typeof odysseyTestSelectors)[ComponentName]> - >[0]["templateArgs"]; -}) => - querySelector({ - canvas, - templateArgs, - testSelectors: odysseyTestSelectors[componentName], - }); + return { + element: capturedElement as LocalQueryMethod extends "get" + ? HTMLElement + : HTMLElement | null, + getAccessibleText: + getAccessibleText as LocalTestSelector extends AccessibleTextSelector + ? typeof getAccessibleText + : never, + selectChild: selectChild as LocalTestSelector extends ElementChildSelector + ? typeof selectChild + : never, + }; + }; diff --git a/packages/odyssey-react-mui/src/test-selectors/sanityChecks.ts b/packages/odyssey-react-mui/src/test-selectors/sanityChecks.ts new file mode 100644 index 0000000000..283ce27173 --- /dev/null +++ b/packages/odyssey-react-mui/src/test-selectors/sanityChecks.ts @@ -0,0 +1,53 @@ +/*! + * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +// Code modified from: https://github.com/testing-library/jest-dom/blob/main/src/utils.js + +export class ElementError extends Error { + constructor(message: string, element: HTMLElement) { + super(message); + + this.name = "ElementError"; + + console.error("ElementError", element); + } +} + +export const normalizeText = (text: string) => { + return text.replace(/\s+/g, " ").trim(); +}; + +export const getWindow = (htmlElement: HTMLElement) => { + if ( + !htmlElement || + !htmlElement.ownerDocument || + !htmlElement.ownerDocument.defaultView + ) { + throw new ElementError("Expected element to have a `window`", htmlElement); + } + + return htmlElement.ownerDocument.defaultView!; +}; + +export const validateHtmlElement = (htmlElement: HTMLElement) => { + const window = getWindow(htmlElement); + + if ( + !(htmlElement instanceof window.SVGElement) && + !(htmlElement instanceof window.HTMLElement) + ) { + throw new ElementError( + "Expected element to be an HTMLElement or an SVGElement", + htmlElement, + ); + } +}; diff --git a/packages/odyssey-react-mui/src/test-selectors/testSelector.ts b/packages/odyssey-react-mui/src/test-selectors/testSelector.ts new file mode 100644 index 0000000000..1e486af023 --- /dev/null +++ b/packages/odyssey-react-mui/src/test-selectors/testSelector.ts @@ -0,0 +1,143 @@ +/*! + * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { + type ByRoleOptions, + type SelectorMatcherOptions, +} from "@testing-library/dom"; + +import { + type RoleSelectorMethod, + type TextSelectorMethod, +} from "./getByQuerySelector"; + +/** + * We can't use React's `AriaRole` because it allows any string value. We want to be very specific. This is otherwise copied straight from React's code. + * @see https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/react/index.d.ts#L2815 + */ +export type AriaRole = + | "alert" + | "alertdialog" + | "application" + | "article" + | "banner" + | "button" + | "cell" + | "checkbox" + | "columnheader" + | "combobox" + | "complementary" + | "contentinfo" + | "definition" + | "dialog" + | "directory" + | "document" + | "feed" + | "figure" + | "form" + | "grid" + | "gridcell" + | "group" + | "heading" + | "img" + | "link" + | "list" + | "listbox" + | "listitem" + | "log" + | "main" + | "marquee" + | "math" + | "menu" + | "menubar" + | "menuitem" + | "menuitemcheckbox" + | "menuitemradio" + | "navigation" + | "none" + | "note" + | "option" + | "presentation" + | "progressbar" + | "radio" + | "radiogroup" + | "region" + | "row" + | "rowgroup" + | "rowheader" + | "scrollbar" + | "search" + | "searchbox" + | "separator" + | "slider" + | "spinbutton" + | "status" + | "switch" + | "tab" + | "table" + | "tablist" + | "tabpanel" + | "term" + | "textbox" + | "timer" + | "toolbar" + | "tooltip" + | "tree" + | "treegrid" + | "treeitem"; + +export type ControlledElementSelector = { isControlledElement?: true }; + +export type RoleSelectorOptions = { + method: RoleSelectorMethod; + options: Record; + role: AriaRole | AriaRole[]; + // | "UNKNOWN" // This should be a `Symbol`, but it can't because this is ultimately going to be JSON stringified. This type will allow passing a custom role if the component allows it: `Box`. +}; + +export type TextSelectorOptions = { + method: TextSelectorMethod; + options: Record; + text: string; +}; + +export type ElementSelectorValue = RoleSelectorOptions | TextSelectorOptions; + +export type ElementSelector = { + elementSelector: ElementSelectorValue; +}; + +export type ElementChildSelectorValue = Record< + string, + TestSelector & ControlledElementSelector +>; + +export type ElementChildSelector = { + children: ElementChildSelectorValue; +}; + +export type AccessibleTextSelectorValue = + | "description" + | "errorMessage" + | "label"; + +export type AccessibleTextSelector = { + /** An "accessible -> semantic" name mapping such as "`description` -> `hint`" where "description" equates to `"aria-description"`. */ + accessibleText: Record; +}; + +export type TestSelector = + | ElementChildSelector + | ElementSelector + | (ElementChildSelector & ElementSelector) + | (AccessibleTextSelector & ElementSelector) + | (ElementChildSelector & AccessibleTextSelector & ElementSelector); diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Callout/Callout.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Callout/Callout.stories.tsx index d144ffd2c2..02678bc596 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Callout/Callout.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Callout/Callout.stories.tsx @@ -19,7 +19,6 @@ import { import { queryOdysseySelector } from "@okta/odyssey-react-mui/test-selectors"; import { expect } from "@storybook/jest"; import { Meta, StoryObj } from "@storybook/react"; -import { within } from "@storybook/testing-library"; import { MuiThemeDecorator } from "../../../../.storybook/components"; import { PlaywrightProps } from "../storybookTypes"; @@ -212,15 +211,19 @@ export const TitleWithLink: StoryObj = { step: PlaywrightProps["step"]; }) => { await step("has visible link", async () => { - const element = queryOdysseySelector({ - canvas: within(canvasElement), - componentName: "Callout", - templateArgs: { - role: "alert", + const querySelect = queryOdysseySelector("Callout"); + + const element = querySelect({ + element: canvasElement, + role: "alert", + options: { title: /Safety checks failed/, }, - }).select?.("link", { - linkText: "Visit fueling console", + }).selectChild?.({ + name: "link", + options: { + linkText: "Visit fueling console", + }, }).element; expect(element).toBeVisible(); diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/Select/Select.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Select/Select.stories.tsx index 6c1963e82a..3de29d8b8c 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/Select/Select.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/Select/Select.stories.tsx @@ -12,7 +12,8 @@ import { Meta, StoryObj } from "@storybook/react"; import { Select, SelectProps, Link } from "@okta/odyssey-react-mui"; -import { userEvent, waitFor, screen } from "@storybook/testing-library"; +import { queryOdysseySelector } from "@okta/odyssey-react-mui/test-selectors"; +import { screen, userEvent, waitFor } from "@storybook/testing-library"; import { expect } from "@storybook/jest"; import { useCallback, useState } from "react"; @@ -223,6 +224,7 @@ const storybookMeta: Meta> = { export default storybookMeta; export const Default: StoryObj = { + args: { defaultValue: "" }, play: async ({ canvasElement, step }) => { await step("Select Roles and permissions from the listbox", async () => { const comboBoxElement = canvasElement.querySelector( @@ -243,12 +245,52 @@ export const Default: StoryObj = { }); }, }; -Default.args = { defaultValue: "" }; export const DefaultValue: StoryObj = { args: { defaultValue: "Roles and permissions", }, + play: async ({ canvasElement, step }) => { + await step("can click dropdown option", async () => { + const querySelect = queryOdysseySelector("Select"); + + const selector = querySelect({ + element: canvasElement, + options: { + label: /Okta documentation/, + }, + }); + + await userEvent.click(selector.element); + + const list = selector.selectChild({ + name: "list", + }); + + await waitFor(() => { + expect(list.element).toBeVisible(); + }); + + const listItemElement = list.selectChild({ + name: "listItem", + options: { + label: "Roles and permissions", + }, + }).element; + + await waitFor(() => { + expect(listItemElement).toBeVisible(); + }); + + if (listItemElement) { + await userEvent.click(listItemElement); + } + + waitFor(() => { + expect(list?.element).not.toBeVisible(); + }); + }); + }, }; export const Disabled: StoryObj = { diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/TextField/TextField.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/TextField/TextField.stories.tsx index e5a87fd9e1..6fdbe2cea8 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/TextField/TextField.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/TextField/TextField.stories.tsx @@ -10,19 +10,19 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { Meta, StoryObj } from "@storybook/react"; import { InputAdornment, Link, TextField, textFieldTypeValues, } from "@okta/odyssey-react-mui"; - +import { Meta, StoryObj } from "@storybook/react"; import { userEvent, within } from "@storybook/testing-library"; import { expect } from "@storybook/jest"; +import { ChangeEvent, useCallback, useState } from "react"; + import { fieldComponentPropsMetaData } from "../../../fieldComponentPropsMetaData"; import { MuiThemeDecorator } from "../../../../.storybook/components"; -import { ChangeEvent, useCallback, useState } from "react"; const storybookMeta: Meta = { title: "MUI Components/Forms/TextField", diff --git a/yarn.lock b/yarn.lock index 82376bd487..9e7495fbf2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5282,6 +5282,7 @@ __metadata: babel-plugin-import: "npm:^1.13.5" concurrently: "npm:^8.2.2" date-fns: "npm:^2.30.0" + dom-accessibility-api: "npm:^0.7.0" eslint: "npm:^8.56.0" i18next: "npm:^23.8.2" jest: "npm:^29.7.0" @@ -12783,6 +12784,13 @@ __metadata: languageName: node linkType: hard +"dom-accessibility-api@npm:^0.7.0": + version: 0.7.0 + resolution: "dom-accessibility-api@npm:0.7.0" + checksum: 10/ac69d27099bc0650633545c461347a355c2794b330f69b6b67fd98df91be9a48547d85176758747841e10ee041905143b39b6094e72c704c265b0f4f9bb7ab28 + languageName: node + linkType: hard + "dom-helpers@npm:^5.0.1": version: 5.2.1 resolution: "dom-helpers@npm:5.2.1"