From 58f7e36e5077c214dc1d127f2fcfcc6ca09e737c Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Mon, 19 Aug 2024 12:37:30 -0500 Subject: [PATCH 01/28] fix: selecting controlled elements --- packages/odyssey-react-mui/package.json | 1 + packages/odyssey-react-mui/src/Select.tsx | 34 ++++ packages/odyssey-react-mui/src/TextField.tsx | 47 ++--- .../src/test-selectors/elementSelector.ts | 90 +++++++++ .../src/test-selectors/featureTestSelector.ts | 15 +- .../src/test-selectors/getAccessibleText.ts | 36 ++++ .../getComputedAccessibleErrorMessageText.ts | 52 ++++++ .../src/test-selectors/index.ts | 3 +- .../src/test-selectors/linkedHtmlSelectors.ts | 73 ++++++++ .../test-selectors/odysseyTestSelectors.ts | 2 + .../test-selectors/queryOdysseySelector.ts | 41 ++++ .../src/test-selectors/querySelector.ts | 175 +++++------------- .../src/test-selectors/sanity-checks.ts | 53 ++++++ .../odyssey-mui/Callout/Callout.stories.tsx | 7 +- .../odyssey-mui/Select/Select.stories.tsx | 42 ++++- .../TextField/TextField.stories.tsx | 6 +- yarn.lock | 8 + 17 files changed, 513 insertions(+), 172 deletions(-) create mode 100644 packages/odyssey-react-mui/src/test-selectors/elementSelector.ts create mode 100644 packages/odyssey-react-mui/src/test-selectors/getAccessibleText.ts create mode 100644 packages/odyssey-react-mui/src/test-selectors/getComputedAccessibleErrorMessageText.ts create mode 100644 packages/odyssey-react-mui/src/test-selectors/linkedHtmlSelectors.ts create mode 100644 packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts create mode 100644 packages/odyssey-react-mui/src/test-selectors/sanity-checks.ts diff --git a/packages/odyssey-react-mui/package.json b/packages/odyssey-react-mui/package.json index 7fd984a82d..b90c5e7792 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/src/Select.tsx b/packages/odyssey-react-mui/src/Select.tsx index 1a49423030..cc8a01f6a9 100644 --- a/packages/odyssey-react-mui/src/Select.tsx +++ b/packages/odyssey-react-mui/src/Select.tsx @@ -52,6 +52,40 @@ import { useOdysseyDesignTokens, DesignTokens, } from "./OdysseyDesignTokensContext"; +import { FeatureTestSelector } from "./test-selectors"; + +export const SelectTestSelectors = { + feature: { + list: { + feature: { + listItem: { + selector: { + method: "ByRole", + options: { + name: "${label}", + }, + role: "option", + templateVariableNames: ["label"], + }, + }, + }, + isControlledElement: true, + }, + }, + label: { + errorMessage: "errorMessage", + hint: "description", + label: "label", + }, + selector: { + method: "ByRole", + options: { + name: "${label}", + }, + role: "combobox", + templateVariableNames: ["label"], + }, +} as const satisfies FeatureTestSelector; export type SelectOption = { text: string; diff --git a/packages/odyssey-react-mui/src/TextField.tsx b/packages/odyssey-react-mui/src/TextField.tsx index c3ff2f110d..742a226eeb 100644 --- a/packages/odyssey-react-mui/src/TextField.tsx +++ b/packages/odyssey-react-mui/src/TextField.tsx @@ -34,40 +34,6 @@ import { type FeatureTestSelector } 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"], - }, - }, link: { selector: { method: "ByRole", @@ -79,6 +45,19 @@ export const TextFieldTestSelectors = { }, }, }, + label: { + errorMessage: "errorMessage", + hint: "description", + label: "label", + }, + selector: { + method: "ByRole", + options: { + name: "${label}", + }, + role: "textbox", + templateVariableNames: ["label"], + }, } as const satisfies FeatureTestSelector; export const textFieldTypeValues = [ diff --git a/packages/odyssey-react-mui/src/test-selectors/elementSelector.ts b/packages/odyssey-react-mui/src/test-selectors/elementSelector.ts new file mode 100644 index 0000000000..d0be65eb58 --- /dev/null +++ b/packages/odyssey-react-mui/src/test-selectors/elementSelector.ts @@ -0,0 +1,90 @@ +/*! + * 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, + type BoundFunctions, + type ByRoleOptions, + type GetByRole, + type GetByText, +} from "@testing-library/dom"; + +export const isRegExpString = (string: string) => /^\/*(.+)\/$/.test(string); + +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; +}; + +export const getByQuerySelector = ({ + canvas, + method, + options, + role, + text, +}: { + canvas: BoundFunctions; + method: "ByRole" | "ByLabelText" | "ByPlaceholderText" | "ByText"; + options?: ByRoleOptions; + role?: Parameters[1]; + text?: Parameters[1]; +}) => { + 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, + ); + } + + return null; +}; diff --git a/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts b/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts index 37fcbdfee1..224e18797b 100644 --- a/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts @@ -32,10 +32,19 @@ export type TestSelector = { }; export type FeatureSelector = { - feature: Record; + feature: Record; +}; + +export type LabelSelectorType = "description" | "errorMessage" | "label"; + +export type LabelSelector = { + /** An "accessible -> semantic" name mapping such as "`description` -> `hint`". */ + label: Record; }; export type FeatureTestSelector = - | TestSelector | FeatureSelector - | (FeatureSelector & TestSelector); + | TestSelector + | (FeatureSelector & TestSelector) + | (LabelSelector & TestSelector) + | (FeatureSelector & LabelSelector & TestSelector); diff --git a/packages/odyssey-react-mui/src/test-selectors/getAccessibleText.ts b/packages/odyssey-react-mui/src/test-selectors/getAccessibleText.ts new file mode 100644 index 0000000000..52ae075f0f --- /dev/null +++ b/packages/odyssey-react-mui/src/test-selectors/getAccessibleText.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 LabelSelectorType } from "./featureTestSelector"; +import { getComputedAccessibleErrorMessageText } from "./getComputedAccessibleErrorMessageText"; + +export const accessibleTextSelector = { + description: computeAccessibleDescription, + errorMessage: getComputedAccessibleErrorMessageText, + label: computeAccessibleName, +} as const satisfies Record< + LabelSelectorType, + (element: HTMLElement) => string +>; + +export const getComputedAccessibleText = ({ + element, + type, +}: { + element: HTMLElement; + type: LabelSelectorType; +}) => accessibleTextSelector[type](element); 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..79b6513aa3 --- /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 "./sanity-checks"; + +// 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/index.ts b/packages/odyssey-react-mui/src/test-selectors/index.ts index 3fb7fca27e..6bb17f4821 100644 --- a/packages/odyssey-react-mui/src/test-selectors/index.ts +++ b/packages/odyssey-react-mui/src/test-selectors/index.ts @@ -11,5 +11,6 @@ */ export * from "./featureTestSelector"; -export * from "./querySelector"; export * from "./odysseyTestSelectors"; +export * from "./queryOdysseySelector"; +export * from "./querySelector"; 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..35e7e8b332 --- /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 "./sanity-checks"; +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 index a096f84929..aa961c7981 100644 --- a/packages/odyssey-react-mui/src/test-selectors/odysseyTestSelectors.ts +++ b/packages/odyssey-react-mui/src/test-selectors/odysseyTestSelectors.ts @@ -12,11 +12,13 @@ import { type FeatureTestSelector } from "./featureTestSelector"; import { CalloutTestSelectors } from "../Callout"; +import { SelectTestSelectors } from "../Select"; import { TabsTestSelectors } from "../Tabs"; import { TextFieldTestSelectors } from "../TextField"; export const odysseyTestSelectors = { Callout: CalloutTestSelectors, + Select: SelectTestSelectors, 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..e6084c3546 --- /dev/null +++ b/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.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. + */ + +import { odysseyTestSelectors } from "./odysseyTestSelectors"; +import { querySelector } from "./querySelector"; + +export const queryOdysseySelector = < + ComponentName extends keyof typeof odysseyTestSelectors, +>({ + element, + componentName, + options, +}: { + element: Parameters< + typeof querySelector<(typeof odysseyTestSelectors)[ComponentName]> + >[0]["element"]; + /** + * Name of the component you want to select within. + */ + componentName: ComponentName; + /** + * String or RegExp values required for this selector. + */ + options?: Parameters< + typeof querySelector<(typeof odysseyTestSelectors)[ComponentName]> + >[0]["options"]; +}) => + querySelector({ + element, + options, + testSelectors: odysseyTestSelectors[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..393d1e85a3 100644 --- a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts @@ -10,145 +10,81 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { - queries, - within, - type BoundFunctions, - type ByRoleOptions, - type GetByText, - GetByRole, -} from "@testing-library/dom"; +import { within } from "@testing-library/dom"; +import { getByQuerySelector, interpolateString } from "./elementSelector"; import { type FeatureTestSelector, 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; - } - - return interpolatedString; -}; - -const getByQuerySelector = ({ - canvas, - method, - options, - role, - text, -}: { - canvas: BoundFunctions; - method: "ByRole" | "ByLabelText" | "ByPlaceholderText" | "ByText"; - options?: ByRoleOptions; - role?: Parameters[1]; - text?: Parameters[1]; -}) => { - 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, - ); - } - - return null; -}; +import { getComputedAccessibleText } from "./getAccessibleText"; +import { getControlledElement } from "./linkedHtmlSelectors"; export const querySelector = ({ - canvas, - templateArgs: templateArgsProp, + element: parentElement, + options: querySelectorOptions, testSelectors, }: { /** - * Testing Library canvas. This is usually `screen`, but Storybook uses `within(canvas)`. + * Refers to Testing Library's canvas. This is usually `screen`, but Storybook uses `within(canvas)`. + */ + element: HTMLElement; + /** + * Required values help narrow down selection. */ - canvas: BoundFunctions; - templateArgs?: TestSelectors extends TestSelector + options?: TestSelectors extends TestSelector ? Record< TestSelectors["selector"]["templateVariableNames"][number], string | RegExp > : never; + /** + * Selectors object. + */ testSelectors: TestSelectors; }) => { - const element = - "selector" in testSelectors + const capturedElement = + "selector" in testSelectors && testSelectors.selector ? getByQuerySelector({ - canvas, + canvas: within(parentElement), method: testSelectors.selector.method, options: - templateArgsProp && testSelectors.selector.options + querySelectorOptions && testSelectors.selector.options ? Object.fromEntries( Object.entries(testSelectors.selector.options).map( ([key, value]) => [ key, - interpolateString(value, templateArgsProp), + interpolateString(value, querySelectorOptions), ], ), ) : testSelectors.selector.options, ...(testSelectors.selector.method === "ByRole" ? { - role: templateArgsProp - ? (interpolateString( + role: querySelectorOptions + ? // Even though the interpolation function could return a RegExp, our `role` type ensures they can only pass a string. TypeScript has no way of knowing which template will return a RegExp or string, so that's why we have to force it ourselves with `as`. + (interpolateString( testSelectors.selector?.role, - templateArgsProp, + querySelectorOptions, ) as string) : testSelectors.selector?.role, } : { - text: templateArgsProp + text: querySelectorOptions ? interpolateString( testSelectors.selector?.text, - templateArgsProp, + querySelectorOptions, ) : testSelectors.selector?.text, }), }) - : null; + : parentElement; - const select = - "feature" in testSelectors + const selectChild = + "feature" in testSelectors && testSelectors.feature ? ( featureName: FeatureName, - templateArgs?: (typeof testSelectors)["feature"][FeatureName] extends TestSelector + options?: (typeof testSelectors)["feature"][FeatureName] extends TestSelector ? Record< (typeof testSelectors)["feature"][FeatureName]["selector"]["templateVariableNames"][number], string | RegExp @@ -156,43 +92,32 @@ export const querySelector = ({ : never, ) => querySelector({ - canvas: element ? within(element) : canvas, - templateArgs, + element: capturedElement + ? testSelectors.feature[featureName].isControlledElement + ? getControlledElement({ element: capturedElement }) + : capturedElement + : parentElement, + options, // 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; + const selectLabel = + "label" in testSelectors && testSelectors.label && capturedElement + ? ( + labelName: LabelName, + ) => + getComputedAccessibleText({ + element: capturedElement, + type: testSelectors.label[labelName], + }) + : null; + return { - element, - select, + element: capturedElement, + selectChild, + selectLabel, }; }; - -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], - }); diff --git a/packages/odyssey-react-mui/src/test-selectors/sanity-checks.ts b/packages/odyssey-react-mui/src/test-selectors/sanity-checks.ts new file mode 100644 index 0000000000..3fd0f56f09 --- /dev/null +++ b/packages/odyssey-react-mui/src/test-selectors/sanity-checks.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(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-storybook/src/components/odyssey-mui/Callout/Callout.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/Callout/Callout.stories.tsx index d144ffd2c2..d5fe565086 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"; @@ -213,13 +212,13 @@ export const TitleWithLink: StoryObj = { }) => { await step("has visible link", async () => { const element = queryOdysseySelector({ - canvas: within(canvasElement), + element: canvasElement, componentName: "Callout", - templateArgs: { + options: { role: "alert", title: /Safety checks failed/, }, - }).select?.("link", { + }).selectChild?.("link", { linkText: "Visit fueling console", }).element; 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 6eeb7cec63..259f877d3e 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"; @@ -222,6 +223,7 @@ const storybookMeta: Meta> = { export default storybookMeta; export const Default: StoryObj = { + args: { defaultValue: "" }, play: async ({ canvasElement, step }) => { await step("Select Earth from the listbox", async () => { const comboBoxElement = canvasElement.querySelector( @@ -242,12 +244,48 @@ export const Default: StoryObj = { }); }, }; -Default.args = { defaultValue: "" }; export const DefaultValue: StoryObj = { args: { defaultValue: "Mars", }, + play: async ({ canvasElement, step }) => { + await step("can click dropdown option", async () => { + const selector = queryOdysseySelector({ + element: canvasElement, + componentName: "Select", + options: { + label: /Destination/, + }, + }); + + if (selector.element) { + await userEvent.click(selector.element); + } + + const list = selector.selectChild?.("list"); + + await waitFor(() => { + expect(list?.element).toBeVisible(); + }); + + const listItemElement = list?.selectChild?.("listItem", { + label: "Mars", + }).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 2ff2c31d6b..f22ac88402 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" @@ -12784,6 +12785,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" From eebaa9fe1e9e593cc9ab627d8c11df9dbd8144d1 Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Mon, 19 Aug 2024 18:04:23 -0500 Subject: [PATCH 02/28] feat: adds ability to have type-safe roles in queries --- packages/odyssey-react-mui/src/Callout.tsx | 4 +- .../src/test-selectors/featureTestSelector.ts | 96 ++++++++++- .../src/test-selectors/getByQuerySelector.ts | 157 ++++++++++++++++++ ...leText.ts => getComputedAccessibleText.ts} | 0 ...lementSelector.ts => interpolateString.ts} | 50 ------ .../src/test-selectors/querySelector.ts | 111 ++++++++++--- 6 files changed, 335 insertions(+), 83 deletions(-) create mode 100644 packages/odyssey-react-mui/src/test-selectors/getByQuerySelector.ts rename packages/odyssey-react-mui/src/test-selectors/{getAccessibleText.ts => getComputedAccessibleText.ts} (100%) rename packages/odyssey-react-mui/src/test-selectors/{elementSelector.ts => interpolateString.ts} (53%) diff --git a/packages/odyssey-react-mui/src/Callout.tsx b/packages/odyssey-react-mui/src/Callout.tsx index d904c43328..de88b5141b 100644 --- a/packages/odyssey-react-mui/src/Callout.tsx +++ b/packages/odyssey-react-mui/src/Callout.tsx @@ -56,8 +56,8 @@ export const CalloutTestSelectors = { options: { name: "${title}", }, - role: "${role}", - templateVariableNames: ["role", "title"], + role: ["alert", "status"], + templateVariableNames: ["title"], }, } as const satisfies FeatureTestSelector; diff --git a/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts b/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts index 224e18797b..13dfd1b966 100644 --- a/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts @@ -10,19 +10,103 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { ByRoleOptions } from "@testing-library/dom"; -import { AriaRole } from "react"; +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 Selector = { - options?: ByRoleOptions; templateVariableNames: string[]; } & ( | { - method: "ByRole"; - role: AriaRole; + method: RoleSelectorMethod; + options?: ByRoleOptions; + role: AriaRole | AriaRole[]; + // | "UNKNOWN" // This should be a `Symbol`, but it can't because this is ultimately going to be JSON stringified. } | { - method: "ByLabelText" | "ByPlaceholderText" | "ByText"; + method: TextSelectorMethod; + options?: SelectorMatcherOptions; text: string; } ); 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..a555683cd1 --- /dev/null +++ b/packages/odyssey-react-mui/src/test-selectors/getByQuerySelector.ts @@ -0,0 +1,157 @@ +/*! + * 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 = ({ + element, + queryMethod, + queryOptions, + role, + selectionMethod, + text, +}: { + element: HTMLElement; + queryMethod: QueryMethod; +} & ( + | { + queryOptions?: ByRoleOptions; + role: ByRoleMatcher; + selectionMethod: RoleSelectorMethod; + text?: never; + } + | { + queryOptions?: SelectorMatcherOptions; + role?: never; + selectionMethod: TextSelectorMethod; + text: Matcher; + } +)) => { + const canvas = within(element); + + if (selectionMethod === "ByRole") { + return executeTestingLibraryMethod({ + canvas, + queryMethod, + selectionMethod, + })(role, queryOptions); + } else if ( + selectionMethod === "ByLabelText" || + selectionMethod === "ByPlaceholderText" || + selectionMethod === "ByText" + ) { + return executeTestingLibraryMethod({ + canvas, + queryMethod, + selectionMethod, + })(text, queryOptions); + } + + return null; +}; + +// getByQuerySelector({ +// element: document.createElement('div'), +// selectionMethod: "ByRole", +// queryOptions: { +// name: "fun" +// }, +// queryMethod: "get", +// role: "yo" +// }) + +// getByQuerySelector({ +// element: document.createElement('div'), +// selectionMethod: "ByLabelText", +// queryOptions: { +// exact: true, +// }, +// queryMethod: "get", +// text: "yo", +// }) diff --git a/packages/odyssey-react-mui/src/test-selectors/getAccessibleText.ts b/packages/odyssey-react-mui/src/test-selectors/getComputedAccessibleText.ts similarity index 100% rename from packages/odyssey-react-mui/src/test-selectors/getAccessibleText.ts rename to packages/odyssey-react-mui/src/test-selectors/getComputedAccessibleText.ts diff --git a/packages/odyssey-react-mui/src/test-selectors/elementSelector.ts b/packages/odyssey-react-mui/src/test-selectors/interpolateString.ts similarity index 53% rename from packages/odyssey-react-mui/src/test-selectors/elementSelector.ts rename to packages/odyssey-react-mui/src/test-selectors/interpolateString.ts index d0be65eb58..6bb70af664 100644 --- a/packages/odyssey-react-mui/src/test-selectors/elementSelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/interpolateString.ts @@ -10,14 +10,6 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { - queries, - type BoundFunctions, - type ByRoleOptions, - type GetByRole, - type GetByText, -} from "@testing-library/dom"; - export const isRegExpString = (string: string) => /^\/*(.+)\/$/.test(string); export const interpolateString = ( @@ -46,45 +38,3 @@ export const interpolateString = ( // This interpolated string is just a string. return interpolatedString; }; - -export const getByQuerySelector = ({ - canvas, - method, - options, - role, - text, -}: { - canvas: BoundFunctions; - method: "ByRole" | "ByLabelText" | "ByPlaceholderText" | "ByText"; - options?: ByRoleOptions; - role?: Parameters[1]; - text?: Parameters[1]; -}) => { - 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, - ); - } - - return null; -}; diff --git a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts index 393d1e85a3..ab33845bba 100644 --- a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts @@ -10,14 +10,14 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { within } from "@testing-library/dom"; - -import { getByQuerySelector, interpolateString } from "./elementSelector"; import { + type AriaRole, type FeatureTestSelector, type TestSelector, } from "./featureTestSelector"; -import { getComputedAccessibleText } from "./getAccessibleText"; +import { getComputedAccessibleText } from "./getComputedAccessibleText"; +import { getByQuerySelector } from "./getByQuerySelector"; +import { interpolateString } from "./interpolateString"; import { getControlledElement } from "./linkedHtmlSelectors"; export const querySelector = ({ @@ -32,12 +32,34 @@ export const querySelector = ({ /** * Required values help narrow down selection. */ - options?: TestSelectors extends TestSelector + options?: (TestSelectors extends TestSelector ? Record< TestSelectors["selector"]["templateVariableNames"][number], string | RegExp - > - : never; + > & + (TestSelectors extends TestSelector & { + method: "ByRole"; + role: infer Role; + } + ? Role extends AriaRole[] + ? { + role: Role[number]; + } + : object + : object) + : object) & + (TestSelectors extends { + selector: { + method: "ByRole"; + role: AriaRole[]; + }; + } + ? { + role: TestSelectors["selector"]["role"][number]; + } + : object) & { + queryMethod?: Parameters[0]["queryMethod"]; + }; /** * Selectors object. */ @@ -46,9 +68,9 @@ export const querySelector = ({ const capturedElement = "selector" in testSelectors && testSelectors.selector ? getByQuerySelector({ - canvas: within(parentElement), - method: testSelectors.selector.method, - options: + element: parentElement, + queryMethod: querySelectorOptions?.queryMethod || "get", + queryOptions: querySelectorOptions && testSelectors.selector.options ? Object.fromEntries( Object.entries(testSelectors.selector.options).map( @@ -59,37 +81,75 @@ export const querySelector = ({ ), ) : testSelectors.selector.options, + // ...( // TEMP + // testSelectors.selector.method === "ByRole" + // && Array.isArray(testSelectors.selector.role) + // && querySelectorOptions + // ? { + // role: (querySelectorOptions.role) as TestSelectors["selector"]["role"][number] + // } + // : {} + // ), ...(testSelectors.selector.method === "ByRole" ? { - role: querySelectorOptions - ? // Even though the interpolation function could return a RegExp, our `role` type ensures they can only pass a string. TypeScript has no way of knowing which template will return a RegExp or string, so that's why we have to force it ourselves with `as`. - (interpolateString( - testSelectors.selector?.role, - querySelectorOptions, - ) as string) - : testSelectors.selector?.role, + selectionMethod: testSelectors.selector.method, + role: Array.isArray(testSelectors.selector.role) + ? // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error: Property 'role' does not exist on type 'TestSelectors extends { selector: { method: "ByRole"; role: AriaRole[]; }; } ? { role: TestSelectors["selector"]["role"][number]; } : {}'.ts(2339) + // This is erroring, but the type and code work. They does narrow the type properly. It's just here where TypeScript doesn't understand that both `querySelectorOptions` exists as does the `role` property on it. It _has_ to passed in based on the way the types were written, so it's safe to ignore this error for now. -Kevin Ghadyani + querySelectorOptions.role + : testSelectors.selector.role, } : { + selectionMethod: testSelectors.selector.method, text: querySelectorOptions ? interpolateString( - testSelectors.selector?.text, + testSelectors.selector.text, querySelectorOptions, ) - : testSelectors.selector?.text, + : testSelectors.selector.text, }), }) : parentElement; const selectChild = - "feature" in testSelectors && testSelectors.feature - ? ( + "feature" in testSelectors && testSelectors.feature && capturedElement + ? < + FeatureName extends keyof (typeof testSelectors)["feature"], + FeatureTestSelectors extends + (typeof testSelectors)["feature"][FeatureName], + >( featureName: FeatureName, - options?: (typeof testSelectors)["feature"][FeatureName] extends TestSelector + options?: (FeatureTestSelectors extends TestSelector ? Record< - (typeof testSelectors)["feature"][FeatureName]["selector"]["templateVariableNames"][number], + FeatureTestSelectors["selector"]["templateVariableNames"][number], string | RegExp - > - : never, + > & + (FeatureTestSelectors extends TestSelector & { + method: "ByRole"; + role: infer Role; + } + ? Role extends AriaRole[] + ? { + role: Role[number]; + } + : object + : object) + : object) & + (FeatureTestSelectors extends { + selector: { + method: "ByRole"; + role: AriaRole[]; + }; + } + ? { + role: FeatureTestSelectors["selector"]["role"][number]; + } + : object) & { + queryMethod?: Parameters< + typeof getByQuerySelector + >[0]["queryMethod"]; + }, ) => querySelector({ element: capturedElement @@ -100,6 +160,7 @@ export const querySelector = ({ options, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error: Type 'FeatureName' cannot be used to index type 'Record'.ts(2536) + // It's my opinion this is a TypeScript bug because the type works even if it says it doesn't. -Kevin Ghadyani testSelectors: testSelectors.feature[featureName], }) : null; From df88130802b15c51517540910220c58b36bdb5a2 Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Wed, 21 Aug 2024 11:07:09 -0500 Subject: [PATCH 03/28] fix: updates Callout test selectors --- packages/odyssey-react-mui/package.json | 1 + .../odyssey-react-mui/src/Autocomplete.tsx | 42 +++++++++++++++++-- packages/odyssey-react-mui/src/Callout.tsx | 18 ++------ .../test-selectors/odysseyTestSelectors.ts | 2 + .../src/test-selectors/querySelector.ts | 9 ---- yarn.lock | 1 + 6 files changed, 46 insertions(+), 27 deletions(-) diff --git a/packages/odyssey-react-mui/package.json b/packages/odyssey-react-mui/package.json index b90c5e7792..002287c2d6 100644 --- a/packages/odyssey-react-mui/package.json +++ b/packages/odyssey-react-mui/package.json @@ -88,6 +88,7 @@ "react-i18next": "^14.0.5", "react-virtualized-auto-sizer": "^1.0.22", "react-window": "^1.8.10", + "rxjs": "^7.8.1", "word-wrap": "^1.2.5" }, "devDependencies": { diff --git a/packages/odyssey-react-mui/src/Autocomplete.tsx b/packages/odyssey-react-mui/src/Autocomplete.tsx index 4dbffdb492..fc60d19d96 100644 --- a/packages/odyssey-react-mui/src/Autocomplete.tsx +++ b/packages/odyssey-react-mui/src/Autocomplete.tsx @@ -39,10 +39,6 @@ import _AutoSizer, { } from "react-virtualized-auto-sizer"; import { useTranslation } from "react-i18next"; -// This is required to get around a react-types issue for "AutoSizer is not a valid JSX element." -// @see https://github.com/bvaughn/react-virtualized/issues/1739#issuecomment-1291444246 -const AutoSizer = _AutoSizer as unknown as FC; - import { Field } from "./Field"; import { FieldComponentProps } from "./FieldComponentProps"; import type { HtmlProps } from "./HtmlProps"; @@ -51,6 +47,44 @@ import { useInputValues, getControlState, } from "./inputUtils"; +import { FeatureTestSelector } from "./test-selectors"; + +// This is required to get around a react-types issue for "AutoSizer is not a valid JSX element." +// @see https://github.com/bvaughn/react-virtualized/issues/1739#issuecomment-1291444246 +const AutoSizer = _AutoSizer as unknown as FC; + +export const AutocompleteTestSelectors = { + feature: { + list: { + feature: { + listItem: { + selector: { + method: "ByRole", + options: { + name: "${label}", + }, + role: "option", + templateVariableNames: ["label"], + }, + }, + }, + isControlledElement: true, + }, + }, + label: { + errorMessage: "errorMessage", + hint: "description", + label: "label", + }, + selector: { + method: "ByRole", + options: { + name: "${label}", + }, + role: "combobox", + templateVariableNames: ["label"], + }, +} as const satisfies FeatureTestSelector; export type AutocompleteProps< OptionType, diff --git a/packages/odyssey-react-mui/src/Callout.tsx b/packages/odyssey-react-mui/src/Callout.tsx index de88b5141b..aef5d08ea9 100644 --- a/packages/odyssey-react-mui/src/Callout.tsx +++ b/packages/odyssey-react-mui/src/Callout.tsx @@ -36,20 +36,10 @@ export const CalloutTestSelectors = { templateVariableNames: ["linkText"], }, }, - text: { - selector: { - method: "ByText", - templateVariableNames: ["text"], - text: "${text}", - }, - }, - title: { - selector: { - method: "ByText", - templateVariableNames: ["title"], - text: "${title}", - }, - }, + }, + label: { + text: "description", + title: "label", }, selector: { method: "ByRole", diff --git a/packages/odyssey-react-mui/src/test-selectors/odysseyTestSelectors.ts b/packages/odyssey-react-mui/src/test-selectors/odysseyTestSelectors.ts index aa961c7981..ff621f5f40 100644 --- a/packages/odyssey-react-mui/src/test-selectors/odysseyTestSelectors.ts +++ b/packages/odyssey-react-mui/src/test-selectors/odysseyTestSelectors.ts @@ -11,12 +11,14 @@ */ import { type FeatureTestSelector } from "./featureTestSelector"; +import { AutocompleteTestSelectors } from "../Autocomplete"; import { CalloutTestSelectors } from "../Callout"; import { SelectTestSelectors } from "../Select"; import { TabsTestSelectors } from "../Tabs"; import { TextFieldTestSelectors } from "../TextField"; export const odysseyTestSelectors = { + Autocomplete: AutocompleteTestSelectors, Callout: CalloutTestSelectors, Select: SelectTestSelectors, Tabs: TabsTestSelectors, diff --git a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts index ab33845bba..4e77112fdd 100644 --- a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts @@ -81,15 +81,6 @@ export const querySelector = ({ ), ) : testSelectors.selector.options, - // ...( // TEMP - // testSelectors.selector.method === "ByRole" - // && Array.isArray(testSelectors.selector.role) - // && querySelectorOptions - // ? { - // role: (querySelectorOptions.role) as TestSelectors["selector"]["role"][number] - // } - // : {} - // ), ...(testSelectors.selector.method === "ByRole" ? { selectionMethod: testSelectors.selector.method, diff --git a/yarn.lock b/yarn.lock index f22ac88402..78e2b7aff6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5298,6 +5298,7 @@ __metadata: react-window: "npm:^1.8.10" regenerator-runtime: "npm:^0.14.1" rimraf: "npm:^5.0.1" + rxjs: "npm:^7.8.1" stylelint: "npm:^14.13.0" tsx: "npm:^4.7.3" typescript: "npm:^5.5.4" From 47d0244bdee1ce3180c60ecba229560dbf3b6904 Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Wed, 21 Aug 2024 11:59:39 -0500 Subject: [PATCH 04/28] test --- packages/odyssey-react-mui/src/Callout.tsx | 6 +- .../src/test-selectors/featureTestSelector.ts | 40 ++++++---- .../src/test-selectors/querySelector.ts | 68 +++++++++-------- .../src/test-selectors/transducers.tsx | 73 +++++++++++++++++++ 4 files changed, 137 insertions(+), 50 deletions(-) create mode 100644 packages/odyssey-react-mui/src/test-selectors/transducers.tsx diff --git a/packages/odyssey-react-mui/src/Callout.tsx b/packages/odyssey-react-mui/src/Callout.tsx index aef5d08ea9..0871adaf28 100644 --- a/packages/odyssey-react-mui/src/Callout.tsx +++ b/packages/odyssey-react-mui/src/Callout.tsx @@ -30,10 +30,9 @@ export const CalloutTestSelectors = { selector: { method: "ByRole", options: { - name: "${linkText}", + linkText: "name", }, role: "link", - templateVariableNames: ["linkText"], }, }, }, @@ -44,10 +43,9 @@ export const CalloutTestSelectors = { selector: { method: "ByRole", options: { - name: "${title}", + title: "name", }, role: ["alert", "status"], - templateVariableNames: ["title"], }, } as const satisfies FeatureTestSelector; diff --git a/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts b/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts index 13dfd1b966..1232f06e38 100644 --- a/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts @@ -95,20 +95,32 @@ export type AriaRole = | "treegrid" | "treeitem"; -export type Selector = { - templateVariableNames: string[]; -} & ( - | { - method: RoleSelectorMethod; - options?: ByRoleOptions; - role: AriaRole | AriaRole[]; - // | "UNKNOWN" // This should be a `Symbol`, but it can't because this is ultimately going to be JSON stringified. - } - | { - method: TextSelectorMethod; - options?: SelectorMatcherOptions; - text: string; - } +export type RoleSelector = { + method: RoleSelectorMethod; + options: ( + Record< + string, + keyof ByRoleOptions + > + ); + role: AriaRole | AriaRole[]; + // | "UNKNOWN" // This should be a `Symbol`, but it can't because this is ultimately going to be JSON stringified. +} + +export type TextSelector = { + method: TextSelectorMethod; + options: ( + Record< + string, + keyof SelectorMatcherOptions + > + ); + text: string; +} + +export type Selector = ( + | RoleSelector + | TextSelector ); export type TestSelector = { diff --git a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts index 4e77112fdd..1e0324fe94 100644 --- a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts @@ -20,6 +20,37 @@ import { getByQuerySelector } from "./getByQuerySelector"; import { interpolateString } from "./interpolateString"; import { getControlledElement } from "./linkedHtmlSelectors"; +export type TestSelectorOptions = ( + TestSelectors extends TestSelector + ? Record< + keyof TestSelectors["selector"]["options"], + string | RegExp + > + : {} +) + +export type TestSelectorRole = ( + TestSelectors extends TestSelector + & { + method: "ByRole"; + role: infer Role; + } + ? Role extends AriaRole[] + ? { + role: Role[number]; + } + : {} + : {} +) + +export type QuerySelectorOptions = ( + TestSelectorOptions + & TestSelectorRole + & { + queryMethod?: Parameters[0]["queryMethod"]; + } +) + export const querySelector = ({ element: parentElement, options: querySelectorOptions, @@ -32,34 +63,7 @@ export const querySelector = ({ /** * Required values help narrow down selection. */ - options?: (TestSelectors extends TestSelector - ? Record< - TestSelectors["selector"]["templateVariableNames"][number], - string | RegExp - > & - (TestSelectors extends TestSelector & { - method: "ByRole"; - role: infer Role; - } - ? Role extends AriaRole[] - ? { - role: Role[number]; - } - : object - : object) - : object) & - (TestSelectors extends { - selector: { - method: "ByRole"; - role: AriaRole[]; - }; - } - ? { - role: TestSelectors["selector"]["role"][number]; - } - : object) & { - queryMethod?: Parameters[0]["queryMethod"]; - }; + options?: QuerySelectorOptions; /** * Selectors object. */ @@ -74,9 +78,9 @@ export const querySelector = ({ querySelectorOptions && testSelectors.selector.options ? Object.fromEntries( Object.entries(testSelectors.selector.options).map( - ([key, value]) => [ - key, - interpolateString(value, querySelectorOptions), + ([testSelectorsKey, testingLibraryKey]) => [ + testingLibraryKey, + querySelectorOptions[testSelectorsKey], ], ), ) @@ -113,7 +117,7 @@ export const querySelector = ({ featureName: FeatureName, options?: (FeatureTestSelectors extends TestSelector ? Record< - FeatureTestSelectors["selector"]["templateVariableNames"][number], + keyof FeatureTestSelectors["selector"]["options"], string | RegExp > & (FeatureTestSelectors extends TestSelector & { diff --git a/packages/odyssey-react-mui/src/test-selectors/transducers.tsx b/packages/odyssey-react-mui/src/test-selectors/transducers.tsx new file mode 100644 index 0000000000..d630e6e507 --- /dev/null +++ b/packages/odyssey-react-mui/src/test-selectors/transducers.tsx @@ -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 { map } from "rxjs"; + +import { + type AriaRole, + type FeatureTestSelector, + type TestSelector, +} from "./featureTestSelector"; +import { getComputedAccessibleText } from "./getComputedAccessibleText"; +import { getByQuerySelector } from "./getByQuerySelector"; +import { interpolateString } from "./interpolateString"; +import { getControlledElement } from "./linkedHtmlSelectors"; + +const selectChild = () => { + map(({ + element: parentElement, + options: querySelectorOptions, + testSelectors, + }: { + /** + * Refers to Testing Library's canvas. This is usually `screen`, but Storybook uses `within(canvas)`. + */ + element: HTMLElement; + /** + * Required values help narrow down selection. + */ + options?: (TestSelectors extends TestSelector + ? Record< + TestSelectors["selector"]["templateVariableNames"][number], + string | RegExp + > & + (TestSelectors extends TestSelector & { + method: "ByRole"; + role: infer Role; + } + ? Role extends AriaRole[] + ? { + role: Role[number]; + } + : object + : object) + : object) & + (TestSelectors extends { + selector: { + method: "ByRole"; + role: AriaRole[]; + }; + } + ? { + role: TestSelectors["selector"]["role"][number]; + } + : object) & { + queryMethod?: Parameters[0]["queryMethod"]; + }; + /** + * Selectors object. + */ + testSelectors: TestSelectors; + }) => ( + // + )), +} From 00804e2e875bce2671b7dc2540d4889aa54e0b8d Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Wed, 21 Aug 2024 12:45:25 -0500 Subject: [PATCH 05/28] test 2 --- .../odyssey-react-mui/src/Autocomplete.tsx | 6 +-- packages/odyssey-react-mui/src/Select.tsx | 6 +-- packages/odyssey-react-mui/src/Tabs.tsx | 6 +-- packages/odyssey-react-mui/src/TextField.tsx | 6 +-- .../test-selectors/odysseyTestSelectors.ts | 26 ------------- .../test-selectors/queryOdysseySelector.ts | 17 ++++++++- .../src/test-selectors/querySelector.ts | 38 +++++++++++-------- 7 files changed, 45 insertions(+), 60 deletions(-) delete mode 100644 packages/odyssey-react-mui/src/test-selectors/odysseyTestSelectors.ts diff --git a/packages/odyssey-react-mui/src/Autocomplete.tsx b/packages/odyssey-react-mui/src/Autocomplete.tsx index fc60d19d96..9a37a94d37 100644 --- a/packages/odyssey-react-mui/src/Autocomplete.tsx +++ b/packages/odyssey-react-mui/src/Autocomplete.tsx @@ -61,10 +61,9 @@ export const AutocompleteTestSelectors = { selector: { method: "ByRole", options: { - name: "${label}", + label: "name", }, role: "option", - templateVariableNames: ["label"], }, }, }, @@ -79,10 +78,9 @@ export const AutocompleteTestSelectors = { selector: { method: "ByRole", options: { - name: "${label}", + label: "name", }, role: "combobox", - templateVariableNames: ["label"], }, } as const satisfies FeatureTestSelector; diff --git a/packages/odyssey-react-mui/src/Select.tsx b/packages/odyssey-react-mui/src/Select.tsx index cc8a01f6a9..f6cb0d490b 100644 --- a/packages/odyssey-react-mui/src/Select.tsx +++ b/packages/odyssey-react-mui/src/Select.tsx @@ -62,10 +62,9 @@ export const SelectTestSelectors = { selector: { method: "ByRole", options: { - name: "${label}", + label: "name", }, role: "option", - templateVariableNames: ["label"], }, }, }, @@ -80,10 +79,9 @@ export const SelectTestSelectors = { selector: { method: "ByRole", options: { - name: "${label}", + label: "name", }, role: "combobox", - templateVariableNames: ["label"], }, } as const satisfies FeatureTestSelector; diff --git a/packages/odyssey-react-mui/src/Tabs.tsx b/packages/odyssey-react-mui/src/Tabs.tsx index aacd239ba8..534d11ec9d 100644 --- a/packages/odyssey-react-mui/src/Tabs.tsx +++ b/packages/odyssey-react-mui/src/Tabs.tsx @@ -38,9 +38,8 @@ export const TabsTestSelectors = { selector: { method: "ByRole", options: { - name: "${label}", + label: "name", }, - templateVariableNames: ["label"], role: "tab", }, }, @@ -48,10 +47,9 @@ export const TabsTestSelectors = { selector: { method: "ByRole", options: { - name: "${ariaLabel}", + label: "name", }, role: "tablist", - templateVariableNames: ["ariaLabel"], }, } as const satisfies FeatureTestSelector; diff --git a/packages/odyssey-react-mui/src/TextField.tsx b/packages/odyssey-react-mui/src/TextField.tsx index 742a226eeb..df77516bf1 100644 --- a/packages/odyssey-react-mui/src/TextField.tsx +++ b/packages/odyssey-react-mui/src/TextField.tsx @@ -38,9 +38,8 @@ export const TextFieldTestSelectors = { selector: { method: "ByRole", options: { - name: "${label}", + label: "name", }, - templateVariableNames: ["label"], role: "link", }, }, @@ -53,10 +52,9 @@ export const TextFieldTestSelectors = { selector: { method: "ByRole", options: { - name: "${label}", + label: "name", }, role: "textbox", - templateVariableNames: ["label"], }, } as const satisfies FeatureTestSelector; 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 ff621f5f40..0000000000 --- a/packages/odyssey-react-mui/src/test-selectors/odysseyTestSelectors.ts +++ /dev/null @@ -1,26 +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 { AutocompleteTestSelectors } from "../Autocomplete"; -import { CalloutTestSelectors } from "../Callout"; -import { SelectTestSelectors } from "../Select"; -import { TabsTestSelectors } from "../Tabs"; -import { TextFieldTestSelectors } from "../TextField"; - -export const odysseyTestSelectors = { - Autocomplete: AutocompleteTestSelectors, - Callout: CalloutTestSelectors, - Select: SelectTestSelectors, - 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 index e6084c3546..3d3ddaf0c8 100644 --- a/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts @@ -10,8 +10,21 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { odysseyTestSelectors } from "./odysseyTestSelectors"; import { querySelector } from "./querySelector"; +import { type FeatureTestSelector } from "./featureTestSelector"; +import { AutocompleteTestSelectors } from "../Autocomplete"; +import { CalloutTestSelectors } from "../Callout"; +import { SelectTestSelectors } from "../Select"; +import { TabsTestSelectors } from "../Tabs"; +import { TextFieldTestSelectors } from "../TextField"; + +export const odysseyTestSelectors = { + Autocomplete: AutocompleteTestSelectors, + Callout: CalloutTestSelectors, + Select: SelectTestSelectors, + Tabs: TabsTestSelectors, + TextField: TextFieldTestSelectors, +} as const satisfies Record; export const queryOdysseySelector = < ComponentName extends keyof typeof odysseyTestSelectors, @@ -30,7 +43,7 @@ export const queryOdysseySelector = < /** * String or RegExp values required for this selector. */ - options?: Parameters< + options: Parameters< typeof querySelector<(typeof odysseyTestSelectors)[ComponentName]> >[0]["options"]; }) => diff --git a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts index 1e0324fe94..09f59571f0 100644 --- a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts @@ -20,20 +20,26 @@ import { getByQuerySelector } from "./getByQuerySelector"; import { interpolateString } from "./interpolateString"; import { getControlledElement } from "./linkedHtmlSelectors"; -export type TestSelectorOptions = ( +// export type TestSelectorOptions = ( +// TestSelectors extends { +// selector: { +// options: Record; +// }; +// } ? Record : {} +// ) + +export type TestSelectorOptions = ( TestSelectors extends TestSelector - ? Record< - keyof TestSelectors["selector"]["options"], - string | RegExp - > + ? Record : {} ) -export type TestSelectorRole = ( - TestSelectors extends TestSelector - & { - method: "ByRole"; - role: infer Role; +export type TestSelectorRole = ( + TestSelectors extends { + selector: { + method: "ByRole"; + role: infer Role; + } } ? Role extends AriaRole[] ? { @@ -43,7 +49,7 @@ export type TestSelectorRole = ( : {} ) -export type QuerySelectorOptions = ( +export type QuerySelectorOptions = ( TestSelectorOptions & TestSelectorRole & { @@ -63,19 +69,19 @@ export const querySelector = ({ /** * Required values help narrow down selection. */ - options?: QuerySelectorOptions; + options: QuerySelectorOptions; /** * Selectors object. */ testSelectors: TestSelectors; }) => { const capturedElement = - "selector" in testSelectors && testSelectors.selector + "selector" in testSelectors ? getByQuerySelector({ element: parentElement, queryMethod: querySelectorOptions?.queryMethod || "get", queryOptions: - querySelectorOptions && testSelectors.selector.options + querySelectorOptions ? Object.fromEntries( Object.entries(testSelectors.selector.options).map( ([testSelectorsKey, testingLibraryKey]) => [ @@ -108,7 +114,7 @@ export const querySelector = ({ : parentElement; const selectChild = - "feature" in testSelectors && testSelectors.feature && capturedElement + "feature" in testSelectors && capturedElement ? < FeatureName extends keyof (typeof testSelectors)["feature"], FeatureTestSelectors extends @@ -161,7 +167,7 @@ export const querySelector = ({ : null; const selectLabel = - "label" in testSelectors && testSelectors.label && capturedElement + "label" in testSelectors && capturedElement ? ( labelName: LabelName, ) => From 724f013a395ea96567ac5bb0a8d2330ea5a08e91 Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Wed, 21 Aug 2024 17:31:25 -0500 Subject: [PATCH 06/28] test 3 --- .../scripts/generateTestSelectorsJson.ts | 2 +- .../odyssey-react-mui/src/Autocomplete.tsx | 10 +- packages/odyssey-react-mui/src/Callout.tsx | 8 +- packages/odyssey-react-mui/src/Select.tsx | 10 +- packages/odyssey-react-mui/src/TextField.tsx | 10 +- .../src/test-selectors/featureTestSelector.ts | 2 +- .../src/test-selectors/getByQuerySelector.ts | 58 +++- .../getComputedAccessibleErrorMessageText.ts | 2 +- .../src/test-selectors/linkedHtmlSelectors.ts | 2 +- .../test-selectors/queryOdysseySelector.ts | 31 +-- .../src/test-selectors/querySelector.ts | 258 +++++++++--------- .../{sanity-checks.ts => sanityChecks.ts} | 0 .../odyssey-mui/Callout/Callout.stories.tsx | 18 +- .../odyssey-mui/Select/Select.stories.tsx | 23 +- 14 files changed, 236 insertions(+), 198 deletions(-) rename packages/odyssey-react-mui/src/test-selectors/{sanity-checks.ts => sanityChecks.ts} (100%) 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 9a37a94d37..68e5ff68cd 100644 --- a/packages/odyssey-react-mui/src/Autocomplete.tsx +++ b/packages/odyssey-react-mui/src/Autocomplete.tsx @@ -54,6 +54,11 @@ import { FeatureTestSelector } from "./test-selectors"; const AutoSizer = _AutoSizer as unknown as FC; export const AutocompleteTestSelectors = { + accessibleText: { + errorMessage: "errorMessage", + hint: "description", + label: "label", + }, feature: { list: { feature: { @@ -70,11 +75,6 @@ export const AutocompleteTestSelectors = { isControlledElement: true, }, }, - label: { - errorMessage: "errorMessage", - hint: "description", - label: "label", - }, selector: { method: "ByRole", options: { diff --git a/packages/odyssey-react-mui/src/Callout.tsx b/packages/odyssey-react-mui/src/Callout.tsx index 0871adaf28..e2877c74ce 100644 --- a/packages/odyssey-react-mui/src/Callout.tsx +++ b/packages/odyssey-react-mui/src/Callout.tsx @@ -25,6 +25,10 @@ import { Paragraph } from "./Typography"; import { useUniqueId } from "./useUniqueId"; export const CalloutTestSelectors = { + accessibleText: { + text: "description", + title: "label", + }, feature: { link: { selector: { @@ -36,10 +40,6 @@ export const CalloutTestSelectors = { }, }, }, - label: { - text: "description", - title: "label", - }, selector: { method: "ByRole", options: { diff --git a/packages/odyssey-react-mui/src/Select.tsx b/packages/odyssey-react-mui/src/Select.tsx index f6cb0d490b..3b0e5f9a24 100644 --- a/packages/odyssey-react-mui/src/Select.tsx +++ b/packages/odyssey-react-mui/src/Select.tsx @@ -55,6 +55,11 @@ import { import { FeatureTestSelector } from "./test-selectors"; export const SelectTestSelectors = { + accessibleText: { + errorMessage: "errorMessage", + hint: "description", + label: "label", + }, feature: { list: { feature: { @@ -71,11 +76,6 @@ export const SelectTestSelectors = { isControlledElement: true, }, }, - label: { - errorMessage: "errorMessage", - hint: "description", - label: "label", - }, selector: { method: "ByRole", options: { diff --git a/packages/odyssey-react-mui/src/TextField.tsx b/packages/odyssey-react-mui/src/TextField.tsx index df77516bf1..2adc8c78fb 100644 --- a/packages/odyssey-react-mui/src/TextField.tsx +++ b/packages/odyssey-react-mui/src/TextField.tsx @@ -33,6 +33,11 @@ import { FocusHandle, useInputValues, getControlState } from "./inputUtils"; import { type FeatureTestSelector } from "./test-selectors"; export const TextFieldTestSelectors = { + accessibleText: { + errorMessage: "errorMessage", + hint: "description", + label: "label", + }, feature: { link: { selector: { @@ -44,11 +49,6 @@ export const TextFieldTestSelectors = { }, }, }, - label: { - errorMessage: "errorMessage", - hint: "description", - label: "label", - }, selector: { method: "ByRole", options: { diff --git a/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts b/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts index 1232f06e38..780658f1bd 100644 --- a/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts @@ -135,7 +135,7 @@ export type LabelSelectorType = "description" | "errorMessage" | "label"; export type LabelSelector = { /** An "accessible -> semantic" name mapping such as "`description` -> `hint`". */ - label: Record; + accessibleText: Record; }; export type FeatureTestSelector = diff --git a/packages/odyssey-react-mui/src/test-selectors/getByQuerySelector.ts b/packages/odyssey-react-mui/src/test-selectors/getByQuerySelector.ts index a555683cd1..17b8f1de1e 100644 --- a/packages/odyssey-react-mui/src/test-selectors/getByQuerySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/getByQuerySelector.ts @@ -121,21 +121,57 @@ export const getByQuerySelector = ({ queryMethod, selectionMethod, })(role, queryOptions); - } else if ( - selectionMethod === "ByLabelText" || - selectionMethod === "ByPlaceholderText" || - selectionMethod === "ByText" - ) { - return executeTestingLibraryMethod({ - canvas, - queryMethod, - selectionMethod, - })(text, queryOptions); } - return null; + return executeTestingLibraryMethod({ + canvas, + queryMethod, + selectionMethod, + })(text, queryOptions); }; +export const getByRoleQuerySelector = ({ + element, + queryMethod, + queryOptions, + role, +}: { + element: HTMLElement; + queryMethod: QueryMethod; + queryOptions?: ByRoleOptions; + role: ByRoleMatcher; +}) => ( + getByQuerySelector({ + element, + queryMethod, + queryOptions, + role, + selectionMethod: "ByRole", + }) +) + +export const getByTextQuerySelector = ({ + element, + queryMethod, + queryOptions, + selectionMethod, + text, +}: { + element: HTMLElement; + queryMethod: QueryMethod; + queryOptions?: SelectorMatcherOptions; + selectionMethod: TextSelectorMethod; + text: Matcher; +}) => ( + getByQuerySelector({ + element, + queryMethod, + queryOptions, + selectionMethod, + text, + }) +) + // getByQuerySelector({ // element: document.createElement('div'), // selectionMethod: "ByRole", diff --git a/packages/odyssey-react-mui/src/test-selectors/getComputedAccessibleErrorMessageText.ts b/packages/odyssey-react-mui/src/test-selectors/getComputedAccessibleErrorMessageText.ts index 79b6513aa3..49a9be01e4 100644 --- a/packages/odyssey-react-mui/src/test-selectors/getComputedAccessibleErrorMessageText.ts +++ b/packages/odyssey-react-mui/src/test-selectors/getComputedAccessibleErrorMessageText.ts @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { normalizeText, validateHtmlElement } from "./sanity-checks"; +import { normalizeText, validateHtmlElement } from "./sanityChecks"; // Code modified from: https://github.com/testing-library/jest-dom/blob/main/src/to-have-accessible-errormessage.js diff --git a/packages/odyssey-react-mui/src/test-selectors/linkedHtmlSelectors.ts b/packages/odyssey-react-mui/src/test-selectors/linkedHtmlSelectors.ts index 35e7e8b332..9d739199fd 100644 --- a/packages/odyssey-react-mui/src/test-selectors/linkedHtmlSelectors.ts +++ b/packages/odyssey-react-mui/src/test-selectors/linkedHtmlSelectors.ts @@ -11,7 +11,7 @@ */ import { type AriaRole } from "react"; -import { ElementError } from "./sanity-checks"; +import { ElementError } from "./sanityChecks"; import { getRole } from "dom-accessibility-api"; /** diff --git a/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts b/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts index 3d3ddaf0c8..167b49e628 100644 --- a/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts @@ -18,7 +18,7 @@ import { SelectTestSelectors } from "../Select"; import { TabsTestSelectors } from "../Tabs"; import { TextFieldTestSelectors } from "../TextField"; -export const odysseyTestSelectors = { +export const odysseyTestSelector = { Autocomplete: AutocompleteTestSelectors, Callout: CalloutTestSelectors, Select: SelectTestSelectors, @@ -27,28 +27,13 @@ export const odysseyTestSelectors = { } as const satisfies Record; export const queryOdysseySelector = < - ComponentName extends keyof typeof odysseyTestSelectors, ->({ - element, - componentName, - options, -}: { - element: Parameters< - typeof querySelector<(typeof odysseyTestSelectors)[ComponentName]> - >[0]["element"]; + ComponentName extends keyof typeof odysseyTestSelector, +>( /** * Name of the component you want to select within. */ - componentName: ComponentName; - /** - * String or RegExp values required for this selector. - */ - options: Parameters< - typeof querySelector<(typeof odysseyTestSelectors)[ComponentName]> - >[0]["options"]; -}) => - querySelector({ - element, - options, - testSelectors: odysseyTestSelectors[componentName], - }); + 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 09f59571f0..f6384915ed 100644 --- a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts @@ -16,9 +16,9 @@ import { type TestSelector, } from "./featureTestSelector"; import { getComputedAccessibleText } from "./getComputedAccessibleText"; -import { getByQuerySelector } from "./getByQuerySelector"; -import { interpolateString } from "./interpolateString"; +import { getByRoleQuerySelector, getByTextQuerySelector, type QueryMethod } from "./getByQuerySelector"; import { getControlledElement } from "./linkedHtmlSelectors"; +import { ElementError } from "./sanityChecks"; // export type TestSelectorOptions = ( // TestSelectors extends { @@ -28,158 +28,160 @@ import { getControlledElement } from "./linkedHtmlSelectors"; // } ? Record : {} // ) -export type TestSelectorOptions = ( - TestSelectors extends TestSelector - ? Record - : {} -) - -export type TestSelectorRole = ( - TestSelectors extends { +export type TestSelectorRole = ( + LocalFeatureTestSelector extends { selector: { - method: "ByRole"; role: infer Role; } } ? Role extends AriaRole[] - ? { - role: Role[number]; - } - : {} - : {} + ? Role[number] + : never + : never ) -export type QuerySelectorOptions = ( - TestSelectorOptions - & TestSelectorRole - & { - queryMethod?: Parameters[0]["queryMethod"]; - } +export type QuerySelectorOptions = ( + LocalFeatureTestSelector extends TestSelector + ? Record + : {} ) -export const querySelector = ({ +export const querySelector = ( + /** + * Selectors object including features and accessible text selections. + */ + testSelector: LocalFeatureTestSelector, +) => ({ element: parentElement, options: querySelectorOptions, - testSelectors, + queryMethod, + role, }: { /** * Refers to Testing Library's canvas. This is usually `screen`, but Storybook uses `within(canvas)`. */ element: HTMLElement; /** - * Required values help narrow down selection. + * Helps narrow down HTML selection to the correct element. + */ + options?: QuerySelectorOptions + /** + * Testing Library method used to query elements. */ - options: QuerySelectorOptions; + queryMethod?: QueryMethod /** - * Selectors object. + * Role is used when you have an optional `role`; otherwise, it'd baked into the metadata. */ - testSelectors: TestSelectors; + role?: TestSelectorRole }) => { - const capturedElement = - "selector" in testSelectors - ? getByQuerySelector({ - element: parentElement, - queryMethod: querySelectorOptions?.queryMethod || "get", - queryOptions: - querySelectorOptions - ? Object.fromEntries( - Object.entries(testSelectors.selector.options).map( - ([testSelectorsKey, testingLibraryKey]) => [ - testingLibraryKey, - querySelectorOptions[testSelectorsKey], - ], - ), - ) - : testSelectors.selector.options, - ...(testSelectors.selector.method === "ByRole" - ? { - selectionMethod: testSelectors.selector.method, - role: Array.isArray(testSelectors.selector.role) - ? // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error: Property 'role' does not exist on type 'TestSelectors extends { selector: { method: "ByRole"; role: AriaRole[]; }; } ? { role: TestSelectors["selector"]["role"][number]; } : {}'.ts(2339) - // This is erroring, but the type and code work. They does narrow the type properly. It's just here where TypeScript doesn't understand that both `querySelectorOptions` exists as does the `role` property on it. It _has_ to passed in based on the way the types were written, so it's safe to ignore this error for now. -Kevin Ghadyani - querySelectorOptions.role - : testSelectors.selector.role, - } - : { - selectionMethod: testSelectors.selector.method, - text: querySelectorOptions - ? interpolateString( - testSelectors.selector.text, - querySelectorOptions, - ) - : testSelectors.selector.text, - }), + if ("selector" in testSelector && querySelectorOptions) { + const sharedProps = { + element: parentElement, + queryMethod: queryMethod || "get", + queryOptions: + Object.fromEntries( + (Object.entries(testSelector.selector.options) as ( + Array<[keyof QuerySelectorOptions, QuerySelectorOptions[keyof QuerySelectorOptions]]> + )) + .map( + ([testSelectorsKey, testingLibraryKey]) => [ + testingLibraryKey, + querySelectorOptions[testSelectorsKey], + ], + ), + ), + } + + const capturedElement = ( + testSelector.selector.method === "ByRole" + ? ( + getByRoleQuerySelector({ + ...sharedProps, + role: Array.isArray(testSelector.selector.role) || role + ? role || "" + : testSelector.selector.role, }) - : parentElement; + ) + : ( + getByTextQuerySelector({ + ...sharedProps, + selectionMethod: testSelector.selector.method, + text: testSelector.selector.text, + }) + ) + ) - const selectChild = - "feature" in testSelectors && capturedElement - ? < - FeatureName extends keyof (typeof testSelectors)["feature"], - FeatureTestSelectors extends - (typeof testSelectors)["feature"][FeatureName], - >( - featureName: FeatureName, - options?: (FeatureTestSelectors extends TestSelector - ? Record< - keyof FeatureTestSelectors["selector"]["options"], - string | RegExp - > & - (FeatureTestSelectors extends TestSelector & { - method: "ByRole"; - role: infer Role; - } - ? Role extends AriaRole[] - ? { - role: Role[number]; - } - : object - : object) - : object) & - (FeatureTestSelectors extends { - selector: { - method: "ByRole"; - role: AriaRole[]; - }; - } - ? { - role: FeatureTestSelectors["selector"]["role"][number]; - } - : object) & { - queryMethod?: Parameters< - typeof getByQuerySelector - >[0]["queryMethod"]; - }, - ) => - querySelector({ - element: capturedElement - ? testSelectors.feature[featureName].isControlledElement - ? getControlledElement({ element: capturedElement }) - : capturedElement - : parentElement, + // TODO: This forces all functions to `get` rather than allowing `query` to return `null`. We should probably figure out a better way to tell TypeScript `null` is fine sometimes, but then error if there's no element when calling `selectChild` rather than having to use `?.`. + if (!capturedElement) { + throw new ElementError("No element exists for thsi query.", parentElement) + } + + const selectChild = + "feature" in testSelector + ? ( + < + FeatureName extends keyof (typeof testSelector)["feature"] + >({ + featureName, options, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error: Type 'FeatureName' cannot be used to index type 'Record'.ts(2536) - // It's my opinion this is a TypeScript bug because the type works even if it says it doesn't. -Kevin Ghadyani - testSelectors: testSelectors.feature[featureName], - }) - : null; + // queryMethod, + // role, + }: ( + { + featureName: FeatureName, + options?: (typeof testSelector)["feature"][FeatureName] extends TestSelector + ? Record< + keyof (typeof testSelector)["feature"][FeatureName]["selector"]["options"], + string | RegExp + > + : never, + } + // & ( + // Pick>>[0], "queryMethod" | "role"> + // ) + )) => { + return querySelector( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error: Type 'FeatureName' cannot be used to index type 'Record'.ts(2536) + // It's my opinion this is a TypeScript bug because the type works even if it says it doesn't. -Kevin Ghadyani + testSelector.feature[featureName], + )({ + element: ( + testSelector.feature[featureName].isControlledElement + ? getControlledElement({ element: capturedElement }) + : capturedElement + ), + options, + // queryMethod, + // role, + }) + } + ) + : null; - const selectLabel = - "label" in testSelectors && capturedElement - ? ( - labelName: LabelName, - ) => - getComputedAccessibleText({ - element: capturedElement, - type: testSelectors.label[labelName], - }) - : null; + const selectAccessibleLabel = + "accessibleLabel" in testSelector + ? ( + labelName: LabelName, + ) => + getComputedAccessibleText({ + element: capturedElement, + type: testSelector.accessibleLabel[labelName], + }) + : null; + + return { + element: capturedElement, + selectChild, + selectAccessibleLabel, + }; + } return { - element: capturedElement, - selectChild, - selectLabel, + element: null, }; }; diff --git a/packages/odyssey-react-mui/src/test-selectors/sanity-checks.ts b/packages/odyssey-react-mui/src/test-selectors/sanityChecks.ts similarity index 100% rename from packages/odyssey-react-mui/src/test-selectors/sanity-checks.ts rename to packages/odyssey-react-mui/src/test-selectors/sanityChecks.ts 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 d5fe565086..ddcb442db2 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 @@ -211,16 +211,22 @@ export const TitleWithLink: StoryObj = { step: PlaywrightProps["step"]; }) => { await step("has visible link", async () => { - const element = queryOdysseySelector({ + const querySelector = queryOdysseySelector("Callout") + + const element = querySelector({ element: canvasElement, - componentName: "Callout", + role: "alert", options: { - role: "alert", title: /Safety checks failed/, }, - }).selectChild?.("link", { - linkText: "Visit fueling console", - }).element; + }) + .selectChild?.({ + featureName: "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 259f877d3e..8616ac885c 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 @@ -251,27 +251,36 @@ export const DefaultValue: StoryObj = { }, play: async ({ canvasElement, step }) => { await step("can click dropdown option", async () => { - const selector = queryOdysseySelector({ + const querySelector = queryOdysseySelector("Select") + + const selector = querySelector({ element: canvasElement, - componentName: "Select", options: { label: /Destination/, }, - }); + }) if (selector.element) { await userEvent.click(selector.element); } - const list = selector.selectChild?.("list"); + const list = selector.selectChild?.({ + featureName: "list", + }); await waitFor(() => { expect(list?.element).toBeVisible(); }); - const listItemElement = list?.selectChild?.("listItem", { - label: "Mars", - }).element; + const listItemElement = list?.selectChild?.({ + // featureName: "listItem", + featureName: "", + // ^? + options: { + label: "Mars", + }, + }) + .element; await waitFor(() => { expect(listItemElement).toBeVisible(); From ae71a24a79fd255015df0917a6994b2cc779080b Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Thu, 22 Aug 2024 14:45:54 -0500 Subject: [PATCH 07/28] test 4 --- .../src/test-selectors/featureTestSelector.ts | 10 +- .../src/test-selectors/getByQuerySelector.ts | 32 ++-- .../getComputedAccessibleText.ts | 6 +- .../src/test-selectors/querySelector.ts | 151 +++++++++--------- 4 files changed, 107 insertions(+), 92 deletions(-) diff --git a/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts b/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts index 780658f1bd..2eb1691ad5 100644 --- a/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts @@ -131,16 +131,16 @@ export type FeatureSelector = { feature: Record; }; -export type LabelSelectorType = "description" | "errorMessage" | "label"; +export type AccessibleLabelSelectorType = "description" | "errorMessage" | "label"; -export type LabelSelector = { +export type AccessibleLabelSelector = { /** An "accessible -> semantic" name mapping such as "`description` -> `hint`". */ - accessibleText: Record; + accessibleText: Record; }; export type FeatureTestSelector = | FeatureSelector | TestSelector | (FeatureSelector & TestSelector) - | (LabelSelector & TestSelector) - | (FeatureSelector & LabelSelector & TestSelector); + | (AccessibleLabelSelector & TestSelector) + | (FeatureSelector & AccessibleLabelSelector & TestSelector); diff --git a/packages/odyssey-react-mui/src/test-selectors/getByQuerySelector.ts b/packages/odyssey-react-mui/src/test-selectors/getByQuerySelector.ts index 17b8f1de1e..8ff7ce2f78 100644 --- a/packages/odyssey-react-mui/src/test-selectors/getByQuerySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/getByQuerySelector.ts @@ -115,19 +115,29 @@ export const getByQuerySelector = ({ )) => { const canvas = within(element); - if (selectionMethod === "ByRole") { - return executeTestingLibraryMethod({ - canvas, - queryMethod, - selectionMethod, - })(role, queryOptions); + const capturedElement = ( + selectionMethod === "ByRole" + ? ( + executeTestingLibraryMethod({ + canvas, + queryMethod, + selectionMethod, + })(role, queryOptions) + ) + : ( + executeTestingLibraryMethod({ + canvas, + queryMethod, + selectionMethod, + })(text, queryOptions) + ) + ) + + if (queryMethod === "get") { + return capturedElement as HTMLElement } - return executeTestingLibraryMethod({ - canvas, - queryMethod, - selectionMethod, - })(text, queryOptions); + return capturedElement }; export const getByRoleQuerySelector = ({ diff --git a/packages/odyssey-react-mui/src/test-selectors/getComputedAccessibleText.ts b/packages/odyssey-react-mui/src/test-selectors/getComputedAccessibleText.ts index 52ae075f0f..a63811cbea 100644 --- a/packages/odyssey-react-mui/src/test-selectors/getComputedAccessibleText.ts +++ b/packages/odyssey-react-mui/src/test-selectors/getComputedAccessibleText.ts @@ -15,7 +15,7 @@ import { computeAccessibleDescription, } from "dom-accessibility-api"; -import { type LabelSelectorType } from "./featureTestSelector"; +import { type AccessibleLabelSelectorType } from "./featureTestSelector"; import { getComputedAccessibleErrorMessageText } from "./getComputedAccessibleErrorMessageText"; export const accessibleTextSelector = { @@ -23,7 +23,7 @@ export const accessibleTextSelector = { errorMessage: getComputedAccessibleErrorMessageText, label: computeAccessibleName, } as const satisfies Record< - LabelSelectorType, + AccessibleLabelSelectorType, (element: HTMLElement) => string >; @@ -32,5 +32,5 @@ export const getComputedAccessibleText = ({ type, }: { element: HTMLElement; - type: LabelSelectorType; + type: AccessibleLabelSelectorType; }) => accessibleTextSelector[type](element); diff --git a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts index f6384915ed..4ceb7ce7b4 100644 --- a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts @@ -50,7 +50,7 @@ export const querySelector = ({ element: parentElement, options: querySelectorOptions, @@ -74,13 +74,15 @@ export const querySelector = }) => { - if ("selector" in testSelector && querySelectorOptions) { + let capturedElement: HTMLElement | null = null + + if ("selector" in featureTestSelector && querySelectorOptions) { const sharedProps = { element: parentElement, queryMethod: queryMethod || "get", queryOptions: Object.fromEntries( - (Object.entries(testSelector.selector.options) as ( + (Object.entries(featureTestSelector.selector.options) as ( Array<[keyof QuerySelectorOptions, QuerySelectorOptions[keyof QuerySelectorOptions]]> )) .map( @@ -92,96 +94,99 @@ export const querySelector = ({ - featureName, - options, - // queryMethod, - // role, - }: ( - { - featureName: FeatureName, - options?: (typeof testSelector)["feature"][FeatureName] extends TestSelector - ? Record< - keyof (typeof testSelector)["feature"][FeatureName]["selector"]["options"], - string | RegExp - > - : never, - } - // & ( - // Pick>>[0], "queryMethod" | "role"> - // ) - )) => { - return querySelector( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error: Type 'FeatureName' cannot be used to index type 'Record'.ts(2536) - // It's my opinion this is a TypeScript bug because the type works even if it says it doesn't. -Kevin Ghadyani - testSelector.feature[featureName], - )({ - element: ( - testSelector.feature[featureName].isControlledElement - ? getControlledElement({ element: capturedElement }) - : capturedElement - ), - options, - // queryMethod, - // role, - }) + const getAccessibleText = + "accessibleText" in featureTestSelector + ? ( + ( + labelName: LabelName, + ) => { + if (!capturedElement) { + throw new ElementError("No child HTML element available", parentElement) } - ) - : null; - const selectAccessibleLabel = - "accessibleLabel" in testSelector - ? ( - labelName: LabelName, - ) => + return ( getComputedAccessibleText({ element: capturedElement, - type: testSelector.accessibleLabel[labelName], + type: featureTestSelector.accessibleText[labelName], }) - : null; + ) + } + ) + : null; - return { - element: capturedElement, - selectChild, - selectAccessibleLabel, - }; - } + const selectChild = + "feature" in featureTestSelector + ? ( + < + FeatureName extends keyof (typeof featureTestSelector)["feature"] + >({ + featureName, + options, + queryMethod, + role, + }: ( + { + featureName: FeatureName, + // options?: (typeof featureTestSelector)["feature"][FeatureName] extends TestSelector + // ? Record< + // keyof (typeof featureTestSelector)["feature"][FeatureName]["selector"]["options"], + // string | RegExp + // > + // : never, + } + & ( + Pick>>[0], "options" | "queryMethod" | "role"> + ) + )) => { + if (!capturedElement) { + throw new ElementError("No child HTML element available", parentElement) + } + + return querySelector( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error: Type 'FeatureName' cannot be used to index type 'Record'.ts(2536) + // It's my opinion this is a TypeScript bug because the type works even if it says it doesn't. -Kevin Ghadyani + featureTestSelector.feature[featureName], + )({ + element: capturedElement, + options, + queryMethod, + role, + }) + } + ) + : null return { - element: null, + element: capturedElement, + getAccessibleText, + selectChild, }; }; From aa71cc1aa3f7949c505318d50d3e18e49f3adeb3 Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Thu, 22 Aug 2024 19:17:03 -0500 Subject: [PATCH 08/28] test 5 --- .../src/test-selectors/featureTestSelector.ts | 2 + .../src/test-selectors/getByQuerySelector.ts | 42 +-- .../src/test-selectors/querySelector.ts | 298 ++++++++++-------- .../odyssey-mui/Select/Select.stories.tsx | 22 +- 4 files changed, 180 insertions(+), 184 deletions(-) diff --git a/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts b/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts index 2eb1691ad5..da57421550 100644 --- a/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts @@ -127,6 +127,8 @@ export type TestSelector = { selector: Selector; }; +expect type Feature = Record + export type FeatureSelector = { feature: Record; }; diff --git a/packages/odyssey-react-mui/src/test-selectors/getByQuerySelector.ts b/packages/odyssey-react-mui/src/test-selectors/getByQuerySelector.ts index 8ff7ce2f78..1cb474649c 100644 --- a/packages/odyssey-react-mui/src/test-selectors/getByQuerySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/getByQuerySelector.ts @@ -89,7 +89,7 @@ export const executeTestingLibraryMethod = < > ]; -export const getByQuerySelector = ({ +export const getByQuerySelector = ({ element, queryMethod, queryOptions, @@ -98,7 +98,7 @@ export const getByQuerySelector = ({ text, }: { element: HTMLElement; - queryMethod: QueryMethod; + queryMethod: LocalQueryMethod; } & ( | { queryOptions?: ByRoleOptions; @@ -133,21 +133,21 @@ export const getByQuerySelector = ({ ) ) - if (queryMethod === "get") { - return capturedElement as HTMLElement - } - - return capturedElement + return capturedElement as ( + LocalQueryMethod extends "get" + ? HTMLElement + : HTMLElement | null + ) }; -export const getByRoleQuerySelector = ({ +export const getByRoleQuerySelector = ({ element, queryMethod, queryOptions, role, }: { element: HTMLElement; - queryMethod: QueryMethod; + queryMethod: LocalQueryMethod; queryOptions?: ByRoleOptions; role: ByRoleMatcher; }) => ( @@ -160,7 +160,7 @@ export const getByRoleQuerySelector = ({ }) ) -export const getByTextQuerySelector = ({ +export const getByTextQuerySelector = ({ element, queryMethod, queryOptions, @@ -168,7 +168,7 @@ export const getByTextQuerySelector = ({ text, }: { element: HTMLElement; - queryMethod: QueryMethod; + queryMethod: LocalQueryMethod; queryOptions?: SelectorMatcherOptions; selectionMethod: TextSelectorMethod; text: Matcher; @@ -181,23 +181,3 @@ export const getByTextQuerySelector = ({ text, }) ) - -// getByQuerySelector({ -// element: document.createElement('div'), -// selectionMethod: "ByRole", -// queryOptions: { -// name: "fun" -// }, -// queryMethod: "get", -// role: "yo" -// }) - -// getByQuerySelector({ -// element: document.createElement('div'), -// selectionMethod: "ByLabelText", -// queryOptions: { -// exact: true, -// }, -// queryMethod: "get", -// text: "yo", -// }) diff --git a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts index 4ceb7ce7b4..d43f85e18d 100644 --- a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts @@ -11,6 +11,8 @@ */ import { + Feature, + FeatureSelector, type AriaRole, type FeatureTestSelector, type TestSelector, @@ -20,26 +22,14 @@ import { getByRoleQuerySelector, getByTextQuerySelector, type QueryMethod } from import { getControlledElement } from "./linkedHtmlSelectors"; import { ElementError } from "./sanityChecks"; -// export type TestSelectorOptions = ( -// TestSelectors extends { +// export type TestSelectorOptions = ( +// LocalFeatureTestSelector extends { // selector: { // options: Record; // }; // } ? Record : {} // ) -export type TestSelectorRole = ( - LocalFeatureTestSelector extends { - selector: { - role: infer Role; - } - } - ? Role extends AriaRole[] - ? Role[number] - : never - : never -) - export type QuerySelectorOptions = ( LocalFeatureTestSelector extends TestSelector ? Record @@ -51,142 +41,172 @@ export const querySelector = ({ - element: parentElement, - options: querySelectorOptions, - queryMethod, - role, -}: { +) => ( /** * Refers to Testing Library's canvas. This is usually `screen`, but Storybook uses `within(canvas)`. */ - element: HTMLElement; - /** - * Helps narrow down HTML selection to the correct element. - */ - options?: QuerySelectorOptions - /** - * Testing Library method used to query elements. - */ - queryMethod?: QueryMethod - /** - * Role is used when you have an optional `role`; otherwise, it'd baked into the metadata. - */ - role?: TestSelectorRole -}) => { - let capturedElement: HTMLElement | null = null + containerElement: HTMLElement +) => ( + ( + props?: ( + { + /** + * Testing Library method used to query elements. + */ + queryMethod?: LocalQueryMethod + } + & ( + LocalFeatureTestSelector extends { + selector: { + 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] + } + : { + role?: never + } + : { + role?: never + } + ) + & ( + LocalFeatureTestSelector extends TestSelector + ? { + /** + * Helps narrow down HTML selection to the correct element. + */ + options: QuerySelectorOptions + } + : { + options?: never + } + ) + ) +) => { + const { + options: querySelectorOptions, + queryMethod, + role, + } = props || {} + + const localQueryMethod = queryMethod || ("get" as const) + + let capturedElement: HTMLElement | null = null - if ("selector" in featureTestSelector && querySelectorOptions) { - const sharedProps = { - element: parentElement, - queryMethod: queryMethod || "get", - queryOptions: - Object.fromEntries( - (Object.entries(featureTestSelector.selector.options) as ( - Array<[keyof QuerySelectorOptions, QuerySelectorOptions[keyof QuerySelectorOptions]]> - )) - .map( - ([testSelectorsKey, testingLibraryKey]) => [ - testingLibraryKey, - querySelectorOptions[testSelectorsKey], - ], + if ("selector" in featureTestSelector && querySelectorOptions) { + const sharedProps = { + element: containerElement, + queryMethod: localQueryMethod, + queryOptions: + Object.fromEntries( + (Object.entries(featureTestSelector.selector.options) as ( + Array<[keyof QuerySelectorOptions, QuerySelectorOptions[keyof QuerySelectorOptions]]> + )) + .map( + ([testSelectorsKey, testingLibraryKey]) => [ + testingLibraryKey, + querySelectorOptions[testSelectorsKey], + ], + ), ), - ), - } + } - capturedElement = ( - featureTestSelector.selector.method === "ByRole" - ? ( - getByRoleQuerySelector({ - ...sharedProps, - role: Array.isArray(featureTestSelector.selector.role) || role - ? role || "" - : featureTestSelector.selector.role, - }) - ) - : ( - getByTextQuerySelector({ - ...sharedProps, - selectionMethod: featureTestSelector.selector.method, - text: featureTestSelector.selector.text, - }) + capturedElement = ( + featureTestSelector.selector.method === "ByRole" + ? ( + getByRoleQuerySelector({ + ...sharedProps, + role: Array.isArray(featureTestSelector.selector.role) || role + ? role || "" + : featureTestSelector.selector.role, + }) + ) + : ( + getByTextQuerySelector({ + ...sharedProps, + selectionMethod: featureTestSelector.selector.method, + text: featureTestSelector.selector.text, + }) + ) ) - ) - } - else if ("isControlledElement" in featureTestSelector && featureTestSelector.isControlledElement) { - capturedElement = getControlledElement({ element: parentElement }) - } + } + else if ("isControlledElement" in featureTestSelector && featureTestSelector.isControlledElement) { + try { + capturedElement = getControlledElement({ element: containerElement }) + } catch (error) { + if (queryMethod === "query") { + capturedElement = null + } - const getAccessibleText = - "accessibleText" in featureTestSelector - ? ( - ( - labelName: LabelName, - ) => { - if (!capturedElement) { - throw new ElementError("No child HTML element available", parentElement) - } + throw error + } + } - return ( - getComputedAccessibleText({ - element: capturedElement, - type: featureTestSelector.accessibleText[labelName], - }) - ) - } - ) - : null; + const getAccessibleText = + "accessibleText" in featureTestSelector + ? ( + ( + labelName: LabelName, + ) => { + if (!capturedElement) { + throw new ElementError("No child HTML element available", containerElement) + } - const selectChild = - "feature" in featureTestSelector - ? ( - < - FeatureName extends keyof (typeof featureTestSelector)["feature"] - >({ - featureName, - options, - queryMethod, - role, - }: ( - { - featureName: FeatureName, - // options?: (typeof featureTestSelector)["feature"][FeatureName] extends TestSelector - // ? Record< - // keyof (typeof featureTestSelector)["feature"][FeatureName]["selector"]["options"], - // string | RegExp - // > - // : never, - } - & ( - Pick>>[0], "options" | "queryMethod" | "role"> - ) - )) => { - if (!capturedElement) { - throw new ElementError("No child HTML element available", parentElement) + return ( + getComputedAccessibleText({ + element: capturedElement, + type: featureTestSelector.accessibleText[labelName], + }) + ) } + ) + : null; - return querySelector( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error: Type 'FeatureName' cannot be used to index type 'Record'.ts(2536) - // It's my opinion this is a TypeScript bug because the type works even if it says it doesn't. -Kevin Ghadyani - featureTestSelector.feature[featureName], - )({ - element: capturedElement, - options, - queryMethod, - role, - }) + const selectChild = + < + FeatureName extends LocalFeatureTestSelector extends FeatureSelector + ? keyof LocalFeatureTestSelector["feature"] + : keyof FeatureSelector + >( + featureName: FeatureName, + ) => { + if (!capturedElement) { + throw new ElementError("No child HTML element available", containerElement) } - ) - : null - return { - element: capturedElement, - getAccessibleText, - selectChild, - }; -}; + if (!("feature" in featureTestSelector)) { + throw new Error("Missing feature in featureTestSelector") + } + + return querySelector( + featureTestSelector.feature[featureName] as ( + LocalFeatureTestSelector extends FeatureSelector + ? LocalFeatureTestSelector["feature"][FeatureName] + : FeatureTestSelector + ), + )( + capturedElement + ) + } + + return { + element: capturedElement as ( + LocalQueryMethod extends "get" + ? HTMLElement + : HTMLElement | null + ), + getAccessibleText, + selectChild: selectChild as ( + LocalFeatureTestSelector extends FeatureSelector + ? typeof selectChild + : never + ), + }; + } +); 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 8616ac885c..bdfbed078b 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 @@ -251,31 +251,25 @@ export const DefaultValue: StoryObj = { }, play: async ({ canvasElement, step }) => { await step("can click dropdown option", async () => { - const querySelector = queryOdysseySelector("Select") + const querySelect = queryOdysseySelector("Select")( + canvasElement + ) - const selector = querySelector({ - element: canvasElement, + const selector = querySelect({ options: { label: /Destination/, }, }) - if (selector.element) { - await userEvent.click(selector.element); - } + await userEvent.click(selector.element); - const list = selector.selectChild?.({ - featureName: "list", - }); + const list = selector.selectChild("list")(); await waitFor(() => { - expect(list?.element).toBeVisible(); + expect(list.element).toBeVisible(); }); - const listItemElement = list?.selectChild?.({ - // featureName: "listItem", - featureName: "", - // ^? + const listItemElement = list.selectChild("listItem")({ options: { label: "Mars", }, From b53ae5a3cb95cf7938fbfe1450e586f08bbc1ff7 Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Thu, 22 Aug 2024 20:05:40 -0500 Subject: [PATCH 09/28] fix: reconfigured querySelector with correct types --- .../src/test-selectors/featureTestSelector.ts | 35 +-- .../src/test-selectors/querySelector.ts | 273 +++++++++--------- 2 files changed, 149 insertions(+), 159 deletions(-) diff --git a/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts b/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts index da57421550..f5590a23d0 100644 --- a/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts @@ -97,43 +97,36 @@ export type AriaRole = export type RoleSelector = { method: RoleSelectorMethod; - options: ( - Record< - string, - keyof ByRoleOptions - > - ); + options: Record; role: AriaRole | AriaRole[]; // | "UNKNOWN" // This should be a `Symbol`, but it can't because this is ultimately going to be JSON stringified. -} +}; export type TextSelector = { method: TextSelectorMethod; - options: ( - Record< - string, - keyof SelectorMatcherOptions - > - ); + options: Record; text: string; -} +}; -export type Selector = ( - | RoleSelector - | TextSelector -); +export type Selector = RoleSelector | TextSelector; export type TestSelector = { selector: Selector; }; -expect type Feature = Record +export type Feature = Record< + string, + FeatureTestSelector & { isControlledElement?: true } +>; export type FeatureSelector = { - feature: Record; + feature: Feature; }; -export type AccessibleLabelSelectorType = "description" | "errorMessage" | "label"; +export type AccessibleLabelSelectorType = + | "description" + | "errorMessage" + | "label"; export type AccessibleLabelSelector = { /** An "accessible -> semantic" name mapping such as "`description` -> `hint`". */ diff --git a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts index d43f85e18d..0f3333893a 100644 --- a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts @@ -11,18 +11,21 @@ */ import { - Feature, - FeatureSelector, type AriaRole, + type FeatureSelector, type FeatureTestSelector, type TestSelector, } from "./featureTestSelector"; import { getComputedAccessibleText } from "./getComputedAccessibleText"; -import { getByRoleQuerySelector, getByTextQuerySelector, type QueryMethod } from "./getByQuerySelector"; +import { + getByRoleQuerySelector, + getByTextQuerySelector, + type QueryMethod, +} from "./getByQuerySelector"; import { getControlledElement } from "./linkedHtmlSelectors"; import { ElementError } from "./sanityChecks"; -// export type TestSelectorOptions = ( +// export type QuerySelectorOptions = ( // LocalFeatureTestSelector extends { // selector: { // options: Record; @@ -30,183 +33,177 @@ import { ElementError } from "./sanityChecks"; // } ? Record : {} // ) -export type QuerySelectorOptions = ( - LocalFeatureTestSelector extends TestSelector - ? Record - : {} -) +export type QuerySelectorOptions< + LocalFeatureTestSelector extends FeatureTestSelector, +> = LocalFeatureTestSelector extends TestSelector + ? Record< + keyof LocalFeatureTestSelector["selector"]["options"], + string | RegExp + > + : Record; -export const querySelector = ( - /** - * Selectors object including features and accessible text selections. - */ - featureTestSelector: LocalFeatureTestSelector, -) => ( - /** - * Refers to Testing Library's canvas. This is usually `screen`, but Storybook uses `within(canvas)`. - */ - containerElement: HTMLElement -) => ( +export const querySelector = + ( + /** + * Selectors object including features and accessible text selections. + */ + featureTestSelector: LocalFeatureTestSelector, + ) => + ( + /** + * Refers to Testing Library's canvas. This is usually `screen`, but Storybook uses `within(canvas)`. + */ + containerElement: HTMLElement, + ) => ( - props?: ( - { + props?: { /** * Testing Library method used to query elements. */ - queryMethod?: LocalQueryMethod + queryMethod?: LocalQueryMethod; + } & (LocalFeatureTestSelector extends { + selector: { + role: infer Role; + }; } - & ( - LocalFeatureTestSelector extends { - selector: { - 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] - } + /** + * Role is used when you have an optional `role`; otherwise, it'd baked into the metadata. + */ + role: Role[number]; + } : { - role?: never - } - : { - role?: never - } - ) - & ( - LocalFeatureTestSelector extends TestSelector - ? { - /** - * Helps narrow down HTML selection to the correct element. - */ - options: QuerySelectorOptions - } + role?: never; + } : { - options?: never - } - ) - ) -) => { - const { - options: querySelectorOptions, - queryMethod, - role, - } = props || {} + role?: never; + }) & + (LocalFeatureTestSelector extends TestSelector + ? { + /** + * Helps narrow down HTML selection to the correct element. + */ + options: Record< + keyof LocalFeatureTestSelector["selector"]["options"], + string | RegExp + >; + } + : { + options?: never; + }), + ) => { + const { options: querySelectorOptions, queryMethod, role } = props || {}; - const localQueryMethod = queryMethod || ("get" as const) + const localQueryMethod = queryMethod || ("get" as const); - let capturedElement: HTMLElement | null = null + let capturedElement: HTMLElement | null = null; if ("selector" in featureTestSelector && querySelectorOptions) { const sharedProps = { element: containerElement, queryMethod: localQueryMethod, - queryOptions: - Object.fromEntries( - (Object.entries(featureTestSelector.selector.options) as ( - Array<[keyof QuerySelectorOptions, QuerySelectorOptions[keyof QuerySelectorOptions]]> - )) - .map( - ([testSelectorsKey, testingLibraryKey]) => [ - testingLibraryKey, - querySelectorOptions[testSelectorsKey], - ], - ), + queryOptions: Object.fromEntries( + Object.entries(featureTestSelector.selector.options).map( + ([testSelectorsKey, testingLibraryKey]) => [ + testingLibraryKey, + querySelectorOptions[testSelectorsKey], + ], ), - } + ) as Record< + LocalFeatureTestSelector extends TestSelector + ? LocalFeatureTestSelector["selector"]["options"][keyof LocalFeatureTestSelector["selector"]["options"]] + : string, + string | RegExp + >, + }; - capturedElement = ( + capturedElement = featureTestSelector.selector.method === "ByRole" - ? ( - getByRoleQuerySelector({ - ...sharedProps, - role: Array.isArray(featureTestSelector.selector.role) || role - ? role || "" - : featureTestSelector.selector.role, - }) - ) - : ( - getByTextQuerySelector({ - ...sharedProps, - selectionMethod: featureTestSelector.selector.method, - text: featureTestSelector.selector.text, - }) - ) - ) - } - else if ("isControlledElement" in featureTestSelector && featureTestSelector.isControlledElement) { + ? getByRoleQuerySelector({ + ...sharedProps, + role: + Array.isArray(featureTestSelector.selector.role) || role + ? role || "" + : featureTestSelector.selector.role, + }) + : getByTextQuerySelector({ + ...sharedProps, + selectionMethod: featureTestSelector.selector.method, + text: featureTestSelector.selector.text, + }); + } else if ( + "isControlledElement" in featureTestSelector && + featureTestSelector.isControlledElement + ) { try { - capturedElement = getControlledElement({ element: containerElement }) + capturedElement = getControlledElement({ element: containerElement }); } catch (error) { if (queryMethod === "query") { - capturedElement = null + capturedElement = null; } - throw error + throw error; } } const getAccessibleText = "accessibleText" in featureTestSelector - ? ( - ( + ? < + LabelName extends + keyof (typeof featureTestSelector)["accessibleText"], + >( labelName: LabelName, ) => { if (!capturedElement) { - throw new ElementError("No child HTML element available", containerElement) + throw new ElementError( + "No child HTML element available", + containerElement, + ); } - return ( - getComputedAccessibleText({ - element: capturedElement, - type: featureTestSelector.accessibleText[labelName], - }) - ) + return getComputedAccessibleText({ + element: capturedElement, + type: featureTestSelector.accessibleText[labelName], + }); } - ) : null; - const selectChild = - < - FeatureName extends LocalFeatureTestSelector extends FeatureSelector + const selectChild = < + FeatureName extends LocalFeatureTestSelector extends FeatureSelector ? keyof LocalFeatureTestSelector["feature"] - : keyof FeatureSelector - >( - featureName: FeatureName, - ) => { - if (!capturedElement) { - throw new ElementError("No child HTML element available", containerElement) - } - - if (!("feature" in featureTestSelector)) { - throw new Error("Missing feature in featureTestSelector") - } + : keyof FeatureSelector, + >( + featureName: FeatureName, + ) => { + if (!capturedElement) { + throw new ElementError( + "No child HTML element available", + containerElement, + ); + } - return querySelector( - featureTestSelector.feature[featureName] as ( - LocalFeatureTestSelector extends FeatureSelector - ? LocalFeatureTestSelector["feature"][FeatureName] - : FeatureTestSelector - ), - )( - capturedElement - ) + if (!("feature" in featureTestSelector)) { + throw new Error("Missing feature in featureTestSelector"); } + return querySelector( + featureTestSelector.feature[ + featureName + ] as LocalFeatureTestSelector extends FeatureSelector + ? LocalFeatureTestSelector["feature"][FeatureName] + : FeatureTestSelector, + )(capturedElement); + }; + return { - element: capturedElement as ( - LocalQueryMethod extends "get" + element: capturedElement as LocalQueryMethod extends "get" ? HTMLElement - : HTMLElement | null - ), + : HTMLElement | null, getAccessibleText, - selectChild: selectChild as ( - LocalFeatureTestSelector extends FeatureSelector - ? typeof selectChild - : never - ), + selectChild: + selectChild as LocalFeatureTestSelector extends FeatureSelector + ? typeof selectChild + : never, }; - } -); + }; From 2b1fb72621cae5c4b4700ddeaf16fc3f8604bbc5 Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Thu, 22 Aug 2024 22:04:28 -0500 Subject: [PATCH 10/28] fix: improved test selectors API --- .../src/test-selectors/querySelector.ts | 125 +++++++++++------- .../odyssey-mui/Select/Select.stories.tsx | 18 ++- 2 files changed, 88 insertions(+), 55 deletions(-) diff --git a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts index 0f3333893a..534743962b 100644 --- a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts @@ -42,6 +42,40 @@ export type QuerySelectorOptions< > : Record; +export type InnerQuerySelectorProps< + LocalFeatureTestSelector extends FeatureTestSelector, + LocalQueryMethod extends QueryMethod, +> = { + /** + * Testing Library method used to query elements. + */ + queryMethod?: LocalQueryMethod; +} & (LocalFeatureTestSelector extends { + selector: { + 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) & + (LocalFeatureTestSelector extends TestSelector + ? { + /** + * Helps narrow down HTML selection to the correct element. + */ + options: Record< + keyof LocalFeatureTestSelector["selector"]["options"], + string | RegExp + >; + } + : object); + export const querySelector = ( /** @@ -49,54 +83,19 @@ export const querySelector = */ featureTestSelector: LocalFeatureTestSelector, ) => - ( + ( /** * Refers to Testing Library's canvas. This is usually `screen`, but Storybook uses `within(canvas)`. */ containerElement: HTMLElement, - ) => - ( - props?: { - /** - * Testing Library method used to query elements. - */ - queryMethod?: LocalQueryMethod; - } & (LocalFeatureTestSelector extends { - selector: { - 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]; - } - : { - role?: never; - } - : { - role?: never; - }) & - (LocalFeatureTestSelector extends TestSelector - ? { - /** - * Helps narrow down HTML selection to the correct element. - */ - options: Record< - keyof LocalFeatureTestSelector["selector"]["options"], - string | RegExp - >; - } - : { - options?: never; - }), + props: InnerQuerySelectorProps, ) => { - const { options: querySelectorOptions, queryMethod, role } = props || {}; - + const { queryMethod } = props || {}; const localQueryMethod = queryMethod || ("get" as const); + const querySelectorOptions = "options" in props ? props.options : undefined; + const role = "role" in props ? (props.role as AriaRole) : undefined; + // This `let` is difficult to make into a `const`. It makes the code unreadable. let capturedElement: HTMLElement | null = null; if ("selector" in featureTestSelector && querySelectorOptions) { @@ -173,9 +172,19 @@ export const querySelector = FeatureName extends LocalFeatureTestSelector extends FeatureSelector ? keyof LocalFeatureTestSelector["feature"] : keyof FeatureSelector, - >( - featureName: FeatureName, - ) => { + ChildQueryMethod extends QueryMethod, + >({ + featureName, + queryMethod, + ...otherProps + }: { + featureName: FeatureName; + } & InnerQuerySelectorProps< + LocalFeatureTestSelector extends FeatureSelector + ? LocalFeatureTestSelector["feature"][FeatureName] + : FeatureTestSelector, + ChildQueryMethod + >) => { if (!capturedElement) { throw new ElementError( "No child HTML element available", @@ -187,13 +196,39 @@ export const querySelector = throw new Error("Missing feature in featureTestSelector"); } + type Options = Record< + LocalFeatureTestSelector extends FeatureSelector + ? LocalFeatureTestSelector["feature"][FeatureName] extends TestSelector + ? keyof LocalFeatureTestSelector["feature"][FeatureName]["selector"]["options"] + : string + : string, + string | RegExp + >; + return querySelector( featureTestSelector.feature[ featureName ] as LocalFeatureTestSelector extends FeatureSelector ? LocalFeatureTestSelector["feature"][FeatureName] : FeatureTestSelector, - )(capturedElement); + )( + capturedElement, + // @ts-expect-error: Type '{ role?: AriaRole | undefined; options?: Record | undefined; queryMethod: ChildQueryMethod | undefined; }' is not assignable to type '(LocalFeatureTestSelector extends FeatureSelector ? LocalFeatureTestSelector["feature"][FeatureName] : FeatureTestSelector) extends { ...; } ? Role extends AriaRole[] ? { ...; } : object : object'.ts(2345) + // No matter what crazy antics I've done here, TS won't play nice, so I've put a `ts-expect-error` in case it gets fixed in the future. -Kevin Ghadyani + { + queryMethod, + ...("options" in otherProps + ? { + options: otherProps.options as Options, + } + : {}), + ...("role" in otherProps + ? { + role: otherProps.role as AriaRole, + } + : {}), + }, + ); }; return { 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 bdfbed078b..3f4c4bae76 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 @@ -251,30 +251,28 @@ export const DefaultValue: StoryObj = { }, play: async ({ canvasElement, step }) => { await step("can click dropdown option", async () => { - const querySelect = queryOdysseySelector("Select")( - canvasElement - ) - - const selector = querySelect({ + const selector = queryOdysseySelector("Select")(canvasElement, { options: { label: /Destination/, }, - }) + }); await userEvent.click(selector.element); - const list = selector.selectChild("list")(); + const list = selector.selectChild({ + featureName: "list", + }); await waitFor(() => { expect(list.element).toBeVisible(); }); - const listItemElement = list.selectChild("listItem")({ + const listItemElement = list.selectChild({ + featureName: "listItem", options: { label: "Mars", }, - }) - .element; + }).element; await waitFor(() => { expect(listItemElement).toBeVisible(); From 8af669fe4dd35a931dc2e7656ac83a4c2bc7b075 Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Thu, 22 Aug 2024 22:13:38 -0500 Subject: [PATCH 11/28] fix: adds typing for getAccessibleText --- .../src/test-selectors/querySelector.ts | 61 +++++++++++-------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts index 534743962b..e978bf690f 100644 --- a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts @@ -11,6 +11,7 @@ */ import { + type AccessibleLabelSelector, type AriaRole, type FeatureSelector, type FeatureTestSelector, @@ -146,27 +147,36 @@ export const querySelector = } } - const getAccessibleText = - "accessibleText" in featureTestSelector - ? < - LabelName extends - keyof (typeof featureTestSelector)["accessibleText"], - >( - labelName: LabelName, - ) => { - if (!capturedElement) { - throw new ElementError( - "No child HTML element available", - containerElement, - ); - } - - return getComputedAccessibleText({ - element: capturedElement, - type: featureTestSelector.accessibleText[labelName], - }); - } - : null; + if (!capturedElement) { + throw new ElementError( + "No child HTML element available", + containerElement, + ); + } + + if (!("accessibleText" in featureTestSelector)) { + throw new Error("Missing `accessibleText` in `FeatureTestSelector`"); + } + + const getAccessibleText = < + LabelName extends LocalFeatureTestSelector extends AccessibleLabelSelector + ? keyof LocalFeatureTestSelector["accessibleText"] + : keyof AccessibleLabelSelector, + >( + labelName: LabelName, + ) => { + if (!capturedElement) { + throw new ElementError( + "No child HTML element available", + containerElement, + ); + } + + return getComputedAccessibleText({ + element: capturedElement, + type: featureTestSelector.accessibleText[labelName], + }); + }; const selectChild = < FeatureName extends LocalFeatureTestSelector extends FeatureSelector @@ -193,7 +203,7 @@ export const querySelector = } if (!("feature" in featureTestSelector)) { - throw new Error("Missing feature in featureTestSelector"); + throw new Error("Missing `feature` in `FeatureTestSelector`"); } type Options = Record< @@ -214,7 +224,7 @@ export const querySelector = )( capturedElement, // @ts-expect-error: Type '{ role?: AriaRole | undefined; options?: Record | undefined; queryMethod: ChildQueryMethod | undefined; }' is not assignable to type '(LocalFeatureTestSelector extends FeatureSelector ? LocalFeatureTestSelector["feature"][FeatureName] : FeatureTestSelector) extends { ...; } ? Role extends AriaRole[] ? { ...; } : object : object'.ts(2345) - // No matter what crazy antics I've done here, TS won't play nice, so I've put a `ts-expect-error` in case it gets fixed in the future. -Kevin Ghadyani + // The `as` on `featureTestSelector.feature[featureName]` is the cause, but we can't remove that or other things break. The type `FeatureTestSelector` is probably the important one. -Kevin Ghadyani { queryMethod, ...("options" in otherProps @@ -235,7 +245,10 @@ export const querySelector = element: capturedElement as LocalQueryMethod extends "get" ? HTMLElement : HTMLElement | null, - getAccessibleText, + getAccessibleText: + getAccessibleText as LocalFeatureTestSelector extends AccessibleLabelSelector + ? typeof getAccessibleText + : never, selectChild: selectChild as LocalFeatureTestSelector extends FeatureSelector ? typeof selectChild From 04df4e5c10146b2dfa95c6939155237cbe958ffc Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Fri, 23 Aug 2024 13:57:00 -0500 Subject: [PATCH 12/28] fix: cleanup test selectors code --- .../src/test-selectors/querySelector.ts | 61 +++++++--------- .../src/test-selectors/transducers.tsx | 73 ------------------- .../odyssey-mui/Callout/Callout.stories.tsx | 11 +-- .../odyssey-mui/Select/Select.stories.tsx | 5 +- 4 files changed, 34 insertions(+), 116 deletions(-) delete mode 100644 packages/odyssey-react-mui/src/test-selectors/transducers.tsx diff --git a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts index e978bf690f..fb5d0479dd 100644 --- a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts @@ -26,14 +26,6 @@ import { import { getControlledElement } from "./linkedHtmlSelectors"; import { ElementError } from "./sanityChecks"; -// export type QuerySelectorOptions = ( -// LocalFeatureTestSelector extends { -// selector: { -// options: Record; -// }; -// } ? Record : {} -// ) - export type QuerySelectorOptions< LocalFeatureTestSelector extends FeatureTestSelector, > = LocalFeatureTestSelector extends TestSelector @@ -85,13 +77,14 @@ export const querySelector = featureTestSelector: LocalFeatureTestSelector, ) => ( - /** - * Refers to Testing Library's canvas. This is usually `screen`, but Storybook uses `within(canvas)`. - */ - containerElement: HTMLElement, - props: InnerQuerySelectorProps, + props: { + /** + * Refers to Testing Library's canvas. This is usually `screen`, but Storybook uses `within(canvas)`. + */ + element: HTMLElement; + } & InnerQuerySelectorProps, ) => { - const { queryMethod } = props || {}; + const { element: containerElement, queryMethod } = props; const localQueryMethod = queryMethod || ("get" as const); const querySelectorOptions = "options" in props ? props.options : undefined; const role = "role" in props ? (props.role as AriaRole) : undefined; @@ -183,18 +176,16 @@ export const querySelector = ? keyof LocalFeatureTestSelector["feature"] : keyof FeatureSelector, ChildQueryMethod extends QueryMethod, - >({ - featureName, - queryMethod, - ...otherProps - }: { - featureName: FeatureName; - } & InnerQuerySelectorProps< - LocalFeatureTestSelector extends FeatureSelector - ? LocalFeatureTestSelector["feature"][FeatureName] - : FeatureTestSelector, - ChildQueryMethod - >) => { + >( + childProps: { + featureName: FeatureName; + } & InnerQuerySelectorProps< + LocalFeatureTestSelector extends FeatureSelector + ? LocalFeatureTestSelector["feature"][FeatureName] + : FeatureTestSelector, + ChildQueryMethod + >, + ) => { if (!capturedElement) { throw new ElementError( "No child HTML element available", @@ -217,24 +208,24 @@ export const querySelector = return querySelector( featureTestSelector.feature[ - featureName + childProps.featureName ] as LocalFeatureTestSelector extends FeatureSelector ? LocalFeatureTestSelector["feature"][FeatureName] : FeatureTestSelector, )( - capturedElement, - // @ts-expect-error: Type '{ role?: AriaRole | undefined; options?: Record | undefined; queryMethod: ChildQueryMethod | undefined; }' is not assignable to type '(LocalFeatureTestSelector extends FeatureSelector ? LocalFeatureTestSelector["feature"][FeatureName] : FeatureTestSelector) extends { ...; } ? Role extends AriaRole[] ? { ...; } : object : object'.ts(2345) - // The `as` on `featureTestSelector.feature[featureName]` is the cause, but we can't remove that or other things break. The type `FeatureTestSelector` is probably the important one. -Kevin Ghadyani + // @ts-expect-error: Type '{ role?: AriaRole | undefined; options?: Record | undefined; element: HTMLElement...' is not assignable to type '(LocalFeatureTestSelector extends FeatureSelector ? LocalFeatureTestSelector["feature"][FeatureName] : FeatureTestSelector) extends { ...; } ? Role extends AriaRole[] ? { ...; } : object : object'.ts(2345) + // `as featureTestSelector.feature[featureName]` 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 { - queryMethod, - ...("options" in otherProps + element: capturedElement, + queryMethod: childProps.queryMethod, + ...("options" in childProps && childProps.options ? { - options: otherProps.options as Options, + options: childProps.options as Options, } : {}), - ...("role" in otherProps + ...("role" in childProps && childProps.role ? { - role: otherProps.role as AriaRole, + role: childProps.role as AriaRole, } : {}), }, diff --git a/packages/odyssey-react-mui/src/test-selectors/transducers.tsx b/packages/odyssey-react-mui/src/test-selectors/transducers.tsx deleted file mode 100644 index d630e6e507..0000000000 --- a/packages/odyssey-react-mui/src/test-selectors/transducers.tsx +++ /dev/null @@ -1,73 +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 { map } from "rxjs"; - -import { - type AriaRole, - type FeatureTestSelector, - type TestSelector, -} from "./featureTestSelector"; -import { getComputedAccessibleText } from "./getComputedAccessibleText"; -import { getByQuerySelector } from "./getByQuerySelector"; -import { interpolateString } from "./interpolateString"; -import { getControlledElement } from "./linkedHtmlSelectors"; - -const selectChild = () => { - map(({ - element: parentElement, - options: querySelectorOptions, - testSelectors, - }: { - /** - * Refers to Testing Library's canvas. This is usually `screen`, but Storybook uses `within(canvas)`. - */ - element: HTMLElement; - /** - * Required values help narrow down selection. - */ - options?: (TestSelectors extends TestSelector - ? Record< - TestSelectors["selector"]["templateVariableNames"][number], - string | RegExp - > & - (TestSelectors extends TestSelector & { - method: "ByRole"; - role: infer Role; - } - ? Role extends AriaRole[] - ? { - role: Role[number]; - } - : object - : object) - : object) & - (TestSelectors extends { - selector: { - method: "ByRole"; - role: AriaRole[]; - }; - } - ? { - role: TestSelectors["selector"]["role"][number]; - } - : object) & { - queryMethod?: Parameters[0]["queryMethod"]; - }; - /** - * Selectors object. - */ - testSelectors: TestSelectors; - }) => ( - // - )), -} 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 ddcb442db2..3cd40dedcc 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 @@ -211,22 +211,19 @@ export const TitleWithLink: StoryObj = { step: PlaywrightProps["step"]; }) => { await step("has visible link", async () => { - const querySelector = queryOdysseySelector("Callout") + const querySelect = queryOdysseySelector("Callout"); - const element = querySelector({ - element: canvasElement, + const element = querySelect(canvasElement, { role: "alert", options: { title: /Safety checks failed/, }, - }) - .selectChild?.({ + }).selectChild?.({ featureName: "link", options: { linkText: "Visit fueling console", }, - }) - .element; + }).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 3f4c4bae76..c29cc22d12 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 @@ -251,7 +251,10 @@ export const DefaultValue: StoryObj = { }, play: async ({ canvasElement, step }) => { await step("can click dropdown option", async () => { - const selector = queryOdysseySelector("Select")(canvasElement, { + const querySelect = queryOdysseySelector("Select"); + + const selector = querySelect({ + element: canvasElement, options: { label: /Destination/, }, From d88b7a083f17541872aedb0907b012409548d104 Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Tue, 27 Aug 2024 13:10:18 -0500 Subject: [PATCH 13/28] fix: removes rxjs --- packages/odyssey-react-mui/package.json | 1 - yarn.lock | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/odyssey-react-mui/package.json b/packages/odyssey-react-mui/package.json index 002287c2d6..b90c5e7792 100644 --- a/packages/odyssey-react-mui/package.json +++ b/packages/odyssey-react-mui/package.json @@ -88,7 +88,6 @@ "react-i18next": "^14.0.5", "react-virtualized-auto-sizer": "^1.0.22", "react-window": "^1.8.10", - "rxjs": "^7.8.1", "word-wrap": "^1.2.5" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 78e2b7aff6..f22ac88402 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5298,7 +5298,6 @@ __metadata: react-window: "npm:^1.8.10" regenerator-runtime: "npm:^0.14.1" rimraf: "npm:^5.0.1" - rxjs: "npm:^7.8.1" stylelint: "npm:^14.13.0" tsx: "npm:^4.7.3" typescript: "npm:^5.5.4" From 71549c2884c132c64493b0618a7ad3cac78a2dbb Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Tue, 27 Aug 2024 13:39:30 -0500 Subject: [PATCH 14/28] fix: prettify code --- .../src/test-selectors/getByQuerySelector.ts | 47 ++++++++----------- .../test-selectors/queryOdysseySelector.ts | 7 +-- 2 files changed, 22 insertions(+), 32 deletions(-) diff --git a/packages/odyssey-react-mui/src/test-selectors/getByQuerySelector.ts b/packages/odyssey-react-mui/src/test-selectors/getByQuerySelector.ts index 1cb474649c..a64bff21aa 100644 --- a/packages/odyssey-react-mui/src/test-selectors/getByQuerySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/getByQuerySelector.ts @@ -89,7 +89,9 @@ export const executeTestingLibraryMethod = < > ]; -export const getByQuerySelector = ({ +export const getByQuerySelector = < + LocalQueryMethod extends QueryMethod = "get", +>({ element, queryMethod, queryOptions, @@ -115,29 +117,22 @@ export const getByQuerySelector = )) => { const canvas = within(element); - const capturedElement = ( + const capturedElement = selectionMethod === "ByRole" - ? ( - executeTestingLibraryMethod({ - canvas, - queryMethod, - selectionMethod, - })(role, queryOptions) - ) - : ( - executeTestingLibraryMethod({ - canvas, - queryMethod, - selectionMethod, - })(text, queryOptions) - ) - ) + ? executeTestingLibraryMethod({ + canvas, + queryMethod, + selectionMethod, + })(role, queryOptions) + : executeTestingLibraryMethod({ + canvas, + queryMethod, + selectionMethod, + })(text, queryOptions); - return capturedElement as ( - LocalQueryMethod extends "get" + return capturedElement as LocalQueryMethod extends "get" ? HTMLElement - : HTMLElement | null - ) + : HTMLElement | null; }; export const getByRoleQuerySelector = ({ @@ -150,15 +145,14 @@ export const getByRoleQuerySelector = ({ queryMethod: LocalQueryMethod; queryOptions?: ByRoleOptions; role: ByRoleMatcher; -}) => ( +}) => getByQuerySelector({ element, queryMethod, queryOptions, role, selectionMethod: "ByRole", - }) -) + }); export const getByTextQuerySelector = ({ element, @@ -172,12 +166,11 @@ export const getByTextQuerySelector = ({ queryOptions?: SelectorMatcherOptions; selectionMethod: TextSelectorMethod; text: Matcher; -}) => ( +}) => getByQuerySelector({ element, queryMethod, queryOptions, selectionMethod, text, - }) -) + }); diff --git a/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts b/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts index 167b49e628..3551dd8dd5 100644 --- a/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts @@ -32,8 +32,5 @@ export const queryOdysseySelector = < /** * Name of the component you want to select within. */ - componentName: ComponentName -) => - querySelector( - odysseyTestSelector[componentName], - ); + componentName: ComponentName, +) => querySelector(odysseyTestSelector[componentName]); From 025d48a7ec5a4e5cd8909baba7be980c6632e50c Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Tue, 27 Aug 2024 13:40:12 -0500 Subject: [PATCH 15/28] fix: fixed test error in Callout stories --- .../src/components/odyssey-mui/Callout/Callout.stories.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 3cd40dedcc..a2aac3a2b5 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 @@ -213,7 +213,8 @@ export const TitleWithLink: StoryObj = { await step("has visible link", async () => { const querySelect = queryOdysseySelector("Callout"); - const element = querySelect(canvasElement, { + const element = querySelect({ + element: canvasElement, role: "alert", options: { title: /Safety checks failed/, From 183ecf5db3075ea4e92e119b324c6f3114ab67b7 Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Tue, 27 Aug 2024 13:42:07 -0500 Subject: [PATCH 16/28] fix: removes explicit odysseyTestSelectors file --- packages/odyssey-react-mui/src/test-selectors/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/odyssey-react-mui/src/test-selectors/index.ts b/packages/odyssey-react-mui/src/test-selectors/index.ts index 6bb17f4821..81558d150c 100644 --- a/packages/odyssey-react-mui/src/test-selectors/index.ts +++ b/packages/odyssey-react-mui/src/test-selectors/index.ts @@ -11,6 +11,5 @@ */ export * from "./featureTestSelector"; -export * from "./odysseyTestSelectors"; export * from "./queryOdysseySelector"; export * from "./querySelector"; From b72ac414b58ae3b3cbaf744bcf25c233b4ca5c8c Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Tue, 27 Aug 2024 14:49:26 -0500 Subject: [PATCH 17/28] fix: updates ElementError logging with message denoting ElementError --- packages/odyssey-react-mui/src/test-selectors/sanityChecks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/odyssey-react-mui/src/test-selectors/sanityChecks.ts b/packages/odyssey-react-mui/src/test-selectors/sanityChecks.ts index 3fd0f56f09..283ce27173 100644 --- a/packages/odyssey-react-mui/src/test-selectors/sanityChecks.ts +++ b/packages/odyssey-react-mui/src/test-selectors/sanityChecks.ts @@ -18,7 +18,7 @@ export class ElementError extends Error { this.name = "ElementError"; - console.error(element); + console.error("ElementError", element); } } From 40fb286918979eb6d35f2b17e66cf1f9e3e9d9ba Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Tue, 27 Aug 2024 14:50:15 -0500 Subject: [PATCH 18/28] fix: defaults `ChildQueryMethod` as `"get"` to match other instances --- packages/odyssey-react-mui/src/test-selectors/querySelector.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts index fb5d0479dd..860db49314 100644 --- a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts @@ -175,7 +175,7 @@ export const querySelector = FeatureName extends LocalFeatureTestSelector extends FeatureSelector ? keyof LocalFeatureTestSelector["feature"] : keyof FeatureSelector, - ChildQueryMethod extends QueryMethod, + ChildQueryMethod extends QueryMethod = "get", >( childProps: { featureName: FeatureName; From 101c196e93eb557015e11cf004c652077c90a112 Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Tue, 27 Aug 2024 15:00:07 -0500 Subject: [PATCH 19/28] fix: minor fix to change Options key to never where it wouldn't exist anyway --- .../odyssey-react-mui/src/test-selectors/querySelector.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts index 860db49314..77da1bdbd8 100644 --- a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts @@ -201,8 +201,8 @@ export const querySelector = LocalFeatureTestSelector extends FeatureSelector ? LocalFeatureTestSelector["feature"][FeatureName] extends TestSelector ? keyof LocalFeatureTestSelector["feature"][FeatureName]["selector"]["options"] - : string - : string, + : never + : never, string | RegExp >; From d2a196507493047fbe4091130d7ca03d03807cd1 Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Tue, 27 Aug 2024 15:03:08 -0500 Subject: [PATCH 20/28] fix: adds comment around `interpolateString` to make it clear how dangerous it could be --- .../odyssey-react-mui/src/test-selectors/interpolateString.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/odyssey-react-mui/src/test-selectors/interpolateString.ts b/packages/odyssey-react-mui/src/test-selectors/interpolateString.ts index 6bb70af664..2f0cac2775 100644 --- a/packages/odyssey-react-mui/src/test-selectors/interpolateString.ts +++ b/packages/odyssey-react-mui/src/test-selectors/interpolateString.ts @@ -12,6 +12,7 @@ export const isRegExpString = (string: string) => /^\/*(.+)\/$/.test(string); +/** 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, From b51e8e37f1e8a6b608d697a97bec25fcd233c6bd Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Tue, 27 Aug 2024 15:06:35 -0500 Subject: [PATCH 21/28] fix: marks interpolateString as @deprecated as it will probably be removed in a future PR --- .../odyssey-react-mui/src/test-selectors/interpolateString.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/odyssey-react-mui/src/test-selectors/interpolateString.ts b/packages/odyssey-react-mui/src/test-selectors/interpolateString.ts index 2f0cac2775..927a44f3aa 100644 --- a/packages/odyssey-react-mui/src/test-selectors/interpolateString.ts +++ b/packages/odyssey-react-mui/src/test-selectors/interpolateString.ts @@ -12,7 +12,7 @@ export const isRegExpString = (string: string) => /^\/*(.+)\/$/.test(string); -/** 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. */ +/** @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, From 4f10bd440ec9fcba649db48af334620dcd68f5bb Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Tue, 27 Aug 2024 15:09:25 -0500 Subject: [PATCH 22/28] fix: removes unused type in querySelector --- .../src/test-selectors/querySelector.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts index 77da1bdbd8..b18d35f15e 100644 --- a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts @@ -26,15 +26,6 @@ import { import { getControlledElement } from "./linkedHtmlSelectors"; import { ElementError } from "./sanityChecks"; -export type QuerySelectorOptions< - LocalFeatureTestSelector extends FeatureTestSelector, -> = LocalFeatureTestSelector extends TestSelector - ? Record< - keyof LocalFeatureTestSelector["selector"]["options"], - string | RegExp - > - : Record; - export type InnerQuerySelectorProps< LocalFeatureTestSelector extends FeatureTestSelector, LocalQueryMethod extends QueryMethod, From b5b428953dcd0d5f4e62de3e86d5dbffb02e5f26 Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Tue, 27 Aug 2024 16:33:04 -0500 Subject: [PATCH 23/28] fix: massively renamed TestSelector types to be easily understood --- .../odyssey-react-mui/src/Autocomplete.tsx | 12 +- packages/odyssey-react-mui/src/Callout.tsx | 10 +- packages/odyssey-react-mui/src/Select.tsx | 12 +- packages/odyssey-react-mui/src/Tabs.tsx | 10 +- packages/odyssey-react-mui/src/TextField.tsx | 10 +- .../getComputedAccessibleText.ts | 6 +- .../src/test-selectors/index.ts | 2 +- .../test-selectors/queryOdysseySelector.ts | 4 +- .../src/test-selectors/querySelector.ts | 112 ++++++++---------- ...featureTestSelector.ts => testSelector.ts} | 42 +++---- .../odyssey-mui/Callout/Callout.stories.tsx | 2 +- .../odyssey-mui/Select/Select.stories.tsx | 4 +- 12 files changed, 110 insertions(+), 116 deletions(-) rename packages/odyssey-react-mui/src/test-selectors/{featureTestSelector.ts => testSelector.ts} (70%) diff --git a/packages/odyssey-react-mui/src/Autocomplete.tsx b/packages/odyssey-react-mui/src/Autocomplete.tsx index 68e5ff68cd..92f5b24cd6 100644 --- a/packages/odyssey-react-mui/src/Autocomplete.tsx +++ b/packages/odyssey-react-mui/src/Autocomplete.tsx @@ -47,7 +47,7 @@ import { useInputValues, getControlState, } from "./inputUtils"; -import { FeatureTestSelector } from "./test-selectors"; +import { TestSelector } from "./test-selectors"; // This is required to get around a react-types issue for "AutoSizer is not a valid JSX element." // @see https://github.com/bvaughn/react-virtualized/issues/1739#issuecomment-1291444246 @@ -59,11 +59,11 @@ export const AutocompleteTestSelectors = { hint: "description", label: "label", }, - feature: { + children: { list: { - feature: { + children: { listItem: { - selector: { + elementSelector: { method: "ByRole", options: { label: "name", @@ -75,14 +75,14 @@ export const AutocompleteTestSelectors = { isControlledElement: true, }, }, - selector: { + elementSelector: { method: "ByRole", options: { label: "name", }, role: "combobox", }, -} as const satisfies FeatureTestSelector; +} as const satisfies TestSelector; export type AutocompleteProps< OptionType, diff --git a/packages/odyssey-react-mui/src/Callout.tsx b/packages/odyssey-react-mui/src/Callout.tsx index e2877c74ce..580fdd9d23 100644 --- a/packages/odyssey-react-mui/src/Callout.tsx +++ b/packages/odyssey-react-mui/src/Callout.tsx @@ -20,7 +20,7 @@ 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"; @@ -29,9 +29,9 @@ export const CalloutTestSelectors = { text: "description", title: "label", }, - feature: { + children: { link: { - selector: { + elementSelector: { method: "ByRole", options: { linkText: "name", @@ -40,14 +40,14 @@ export const CalloutTestSelectors = { }, }, }, - selector: { + elementSelector: { method: "ByRole", options: { title: "name", }, 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 3b0e5f9a24..1a9d426463 100644 --- a/packages/odyssey-react-mui/src/Select.tsx +++ b/packages/odyssey-react-mui/src/Select.tsx @@ -52,7 +52,7 @@ import { useOdysseyDesignTokens, DesignTokens, } from "./OdysseyDesignTokensContext"; -import { FeatureTestSelector } from "./test-selectors"; +import { TestSelector } from "./test-selectors"; export const SelectTestSelectors = { accessibleText: { @@ -60,11 +60,11 @@ export const SelectTestSelectors = { hint: "description", label: "label", }, - feature: { + children: { list: { - feature: { + children: { listItem: { - selector: { + elementSelector: { method: "ByRole", options: { label: "name", @@ -76,14 +76,14 @@ export const SelectTestSelectors = { isControlledElement: true, }, }, - selector: { + elementSelector: { method: "ByRole", options: { label: "name", }, role: "combobox", }, -} as const satisfies FeatureTestSelector; +} 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 534d11ec9d..c68b5cbe46 100644 --- a/packages/odyssey-react-mui/src/Tabs.tsx +++ b/packages/odyssey-react-mui/src/Tabs.tsx @@ -30,12 +30,12 @@ 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: { + children: { tabItem: { - selector: { + elementSelector: { method: "ByRole", options: { label: "name", @@ -44,14 +44,14 @@ export const TabsTestSelectors = { }, }, }, - selector: { + elementSelector: { method: "ByRole", options: { label: "name", }, role: "tablist", }, -} 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 2adc8c78fb..eed249cf73 100644 --- a/packages/odyssey-react-mui/src/TextField.tsx +++ b/packages/odyssey-react-mui/src/TextField.tsx @@ -30,7 +30,7 @@ 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 = { accessibleText: { @@ -38,9 +38,9 @@ export const TextFieldTestSelectors = { hint: "description", label: "label", }, - feature: { + children: { link: { - selector: { + elementSelector: { method: "ByRole", options: { label: "name", @@ -49,14 +49,14 @@ export const TextFieldTestSelectors = { }, }, }, - selector: { + elementSelector: { method: "ByRole", options: { label: "name", }, role: "textbox", }, -} as const satisfies FeatureTestSelector; +} as const satisfies TestSelector; export const textFieldTypeValues = [ "email", diff --git a/packages/odyssey-react-mui/src/test-selectors/getComputedAccessibleText.ts b/packages/odyssey-react-mui/src/test-selectors/getComputedAccessibleText.ts index a63811cbea..0bbcd5efda 100644 --- a/packages/odyssey-react-mui/src/test-selectors/getComputedAccessibleText.ts +++ b/packages/odyssey-react-mui/src/test-selectors/getComputedAccessibleText.ts @@ -15,7 +15,7 @@ import { computeAccessibleDescription, } from "dom-accessibility-api"; -import { type AccessibleLabelSelectorType } from "./featureTestSelector"; +import { type AccessibleTextSelectorValue } from "./testSelector"; import { getComputedAccessibleErrorMessageText } from "./getComputedAccessibleErrorMessageText"; export const accessibleTextSelector = { @@ -23,7 +23,7 @@ export const accessibleTextSelector = { errorMessage: getComputedAccessibleErrorMessageText, label: computeAccessibleName, } as const satisfies Record< - AccessibleLabelSelectorType, + AccessibleTextSelectorValue, (element: HTMLElement) => string >; @@ -32,5 +32,5 @@ export const getComputedAccessibleText = ({ type, }: { element: HTMLElement; - type: AccessibleLabelSelectorType; + 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 81558d150c..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 "./testSelector"; diff --git a/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts b/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts index 3551dd8dd5..ed4fdf6475 100644 --- a/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts @@ -11,7 +11,7 @@ */ import { querySelector } from "./querySelector"; -import { type FeatureTestSelector } from "./featureTestSelector"; +import { type TestSelector } from "./testSelector"; import { AutocompleteTestSelectors } from "../Autocomplete"; import { CalloutTestSelectors } from "../Callout"; import { SelectTestSelectors } from "../Select"; @@ -24,7 +24,7 @@ export const odysseyTestSelector = { Select: SelectTestSelectors, Tabs: TabsTestSelectors, TextField: TextFieldTestSelectors, -} as const satisfies Record; +} as const satisfies Record; export const queryOdysseySelector = < ComponentName extends keyof typeof odysseyTestSelector, diff --git a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts index b18d35f15e..d0d01ca116 100644 --- a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts @@ -11,12 +11,12 @@ */ import { - type AccessibleLabelSelector, + type AccessibleTextSelector, type AriaRole, - type FeatureSelector, - type FeatureTestSelector, + type ElementChildSelector, type TestSelector, -} from "./featureTestSelector"; + type ElementSelector, +} from "./testSelector"; import { getComputedAccessibleText } from "./getComputedAccessibleText"; import { getByRoleQuerySelector, @@ -27,14 +27,14 @@ import { getControlledElement } from "./linkedHtmlSelectors"; import { ElementError } from "./sanityChecks"; export type InnerQuerySelectorProps< - LocalFeatureTestSelector extends FeatureTestSelector, + LocalTestSelector extends TestSelector, LocalQueryMethod extends QueryMethod, > = { /** * Testing Library method used to query elements. */ queryMethod?: LocalQueryMethod; -} & (LocalFeatureTestSelector extends { +} & (LocalTestSelector extends { selector: { role: infer Role; }; @@ -48,24 +48,24 @@ export type InnerQuerySelectorProps< } : object : object) & - (LocalFeatureTestSelector extends TestSelector + (LocalTestSelector extends ElementSelector ? { /** * Helps narrow down HTML selection to the correct element. */ options: Record< - keyof LocalFeatureTestSelector["selector"]["options"], + keyof LocalTestSelector["elementSelector"]["options"], string | RegExp >; } : object); export const querySelector = - ( + ( /** - * Selectors object including features and accessible text selections. + * Selectors object including children and accessible text selections. */ - featureTestSelector: LocalFeatureTestSelector, + testSelector: LocalTestSelector, ) => ( props: { @@ -73,7 +73,7 @@ export const querySelector = * Refers to Testing Library's canvas. This is usually `screen`, but Storybook uses `within(canvas)`. */ element: HTMLElement; - } & InnerQuerySelectorProps, + } & InnerQuerySelectorProps, ) => { const { element: containerElement, queryMethod } = props; const localQueryMethod = queryMethod || ("get" as const); @@ -83,42 +83,42 @@ export const querySelector = // This `let` is difficult to make into a `const`. It makes the code unreadable. let capturedElement: HTMLElement | null = null; - if ("selector" in featureTestSelector && querySelectorOptions) { + if ("elementSelector" in testSelector && querySelectorOptions) { const sharedProps = { element: containerElement, queryMethod: localQueryMethod, queryOptions: Object.fromEntries( - Object.entries(featureTestSelector.selector.options).map( + Object.entries(testSelector.elementSelector.options).map( ([testSelectorsKey, testingLibraryKey]) => [ testingLibraryKey, querySelectorOptions[testSelectorsKey], ], ), ) as Record< - LocalFeatureTestSelector extends TestSelector - ? LocalFeatureTestSelector["selector"]["options"][keyof LocalFeatureTestSelector["selector"]["options"]] + LocalTestSelector extends ElementSelector + ? LocalTestSelector["elementSelector"]["options"][keyof LocalTestSelector["elementSelector"]["options"]] : string, string | RegExp >, }; capturedElement = - featureTestSelector.selector.method === "ByRole" + testSelector.elementSelector.method === "ByRole" ? getByRoleQuerySelector({ ...sharedProps, role: - Array.isArray(featureTestSelector.selector.role) || role + Array.isArray(testSelector.elementSelector.role) || role ? role || "" - : featureTestSelector.selector.role, + : testSelector.elementSelector.role, }) : getByTextQuerySelector({ ...sharedProps, - selectionMethod: featureTestSelector.selector.method, - text: featureTestSelector.selector.text, + selectionMethod: testSelector.elementSelector.method, + text: testSelector.elementSelector.text, }); } else if ( - "isControlledElement" in featureTestSelector && - featureTestSelector.isControlledElement + "isControlledElement" in testSelector && + testSelector.isControlledElement ) { try { capturedElement = getControlledElement({ element: containerElement }); @@ -131,21 +131,14 @@ export const querySelector = } } - if (!capturedElement) { - throw new ElementError( - "No child HTML element available", - containerElement, - ); - } - - if (!("accessibleText" in featureTestSelector)) { - throw new Error("Missing `accessibleText` in `FeatureTestSelector`"); + if (!("accessibleText" in testSelector)) { + throw new Error("Missing `accessibleText` in `TestSelector`"); } const getAccessibleText = < - LabelName extends LocalFeatureTestSelector extends AccessibleLabelSelector - ? keyof LocalFeatureTestSelector["accessibleText"] - : keyof AccessibleLabelSelector, + LabelName extends LocalTestSelector extends AccessibleTextSelector + ? keyof LocalTestSelector["accessibleText"] + : never, >( labelName: LabelName, ) => { @@ -158,22 +151,22 @@ export const querySelector = return getComputedAccessibleText({ element: capturedElement, - type: featureTestSelector.accessibleText[labelName], + type: testSelector.accessibleText[labelName], }); }; const selectChild = < - FeatureName extends LocalFeatureTestSelector extends FeatureSelector - ? keyof LocalFeatureTestSelector["feature"] - : keyof FeatureSelector, + ChildName extends LocalTestSelector extends ElementChildSelector + ? keyof LocalTestSelector["children"] + : keyof ElementChildSelector, ChildQueryMethod extends QueryMethod = "get", >( childProps: { - featureName: FeatureName; + name: ChildName; } & InnerQuerySelectorProps< - LocalFeatureTestSelector extends FeatureSelector - ? LocalFeatureTestSelector["feature"][FeatureName] - : FeatureTestSelector, + LocalTestSelector extends ElementChildSelector + ? LocalTestSelector["children"][ChildName] + : TestSelector, ChildQueryMethod >, ) => { @@ -184,28 +177,28 @@ export const querySelector = ); } - if (!("feature" in featureTestSelector)) { - throw new Error("Missing `feature` in `FeatureTestSelector`"); + if (!("children" in testSelector)) { + throw new Error("Missing `children` in `TestSelector`"); } type Options = Record< - LocalFeatureTestSelector extends FeatureSelector - ? LocalFeatureTestSelector["feature"][FeatureName] extends TestSelector - ? keyof LocalFeatureTestSelector["feature"][FeatureName]["selector"]["options"] + LocalTestSelector extends ElementChildSelector + ? LocalTestSelector["children"][ChildName] extends ElementSelector + ? keyof LocalTestSelector["children"][ChildName]["elementSelector"]["options"] : never : never, string | RegExp >; return querySelector( - featureTestSelector.feature[ - childProps.featureName - ] as LocalFeatureTestSelector extends FeatureSelector - ? LocalFeatureTestSelector["feature"][FeatureName] - : FeatureTestSelector, + 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 '(LocalFeatureTestSelector extends FeatureSelector ? LocalFeatureTestSelector["feature"][FeatureName] : FeatureTestSelector) extends { ...; } ? Role extends AriaRole[] ? { ...; } : object : object'.ts(2345) - // `as featureTestSelector.feature[featureName]` 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 + // @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, @@ -228,12 +221,11 @@ export const querySelector = ? HTMLElement : HTMLElement | null, getAccessibleText: - getAccessibleText as LocalFeatureTestSelector extends AccessibleLabelSelector + getAccessibleText as LocalTestSelector extends AccessibleTextSelector ? typeof getAccessibleText : never, - selectChild: - selectChild as LocalFeatureTestSelector extends FeatureSelector - ? typeof selectChild - : never, + selectChild: selectChild as LocalTestSelector extends ElementChildSelector + ? typeof selectChild + : never, }; }; diff --git a/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts b/packages/odyssey-react-mui/src/test-selectors/testSelector.ts similarity index 70% rename from packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts rename to packages/odyssey-react-mui/src/test-selectors/testSelector.ts index f5590a23d0..1e486af023 100644 --- a/packages/odyssey-react-mui/src/test-selectors/featureTestSelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/testSelector.ts @@ -95,47 +95,49 @@ export type AriaRole = | "treegrid" | "treeitem"; -export type RoleSelector = { +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. + // | "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 TextSelector = { +export type TextSelectorOptions = { method: TextSelectorMethod; options: Record; text: string; }; -export type Selector = RoleSelector | TextSelector; +export type ElementSelectorValue = RoleSelectorOptions | TextSelectorOptions; -export type TestSelector = { - selector: Selector; +export type ElementSelector = { + elementSelector: ElementSelectorValue; }; -export type Feature = Record< +export type ElementChildSelectorValue = Record< string, - FeatureTestSelector & { isControlledElement?: true } + TestSelector & ControlledElementSelector >; -export type FeatureSelector = { - feature: Feature; +export type ElementChildSelector = { + children: ElementChildSelectorValue; }; -export type AccessibleLabelSelectorType = +export type AccessibleTextSelectorValue = | "description" | "errorMessage" | "label"; -export type AccessibleLabelSelector = { - /** An "accessible -> semantic" name mapping such as "`description` -> `hint`". */ - accessibleText: Record; +export type AccessibleTextSelector = { + /** An "accessible -> semantic" name mapping such as "`description` -> `hint`" where "description" equates to `"aria-description"`. */ + accessibleText: Record; }; -export type FeatureTestSelector = - | FeatureSelector - | TestSelector - | (FeatureSelector & TestSelector) - | (AccessibleLabelSelector & TestSelector) - | (FeatureSelector & AccessibleLabelSelector & TestSelector); +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 a2aac3a2b5..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 @@ -220,7 +220,7 @@ export const TitleWithLink: StoryObj = { title: /Safety checks failed/, }, }).selectChild?.({ - featureName: "link", + name: "link", options: { linkText: "Visit fueling console", }, 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 c29cc22d12..1f50e91c43 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 @@ -263,7 +263,7 @@ export const DefaultValue: StoryObj = { await userEvent.click(selector.element); const list = selector.selectChild({ - featureName: "list", + name: "list", }); await waitFor(() => { @@ -271,7 +271,7 @@ export const DefaultValue: StoryObj = { }); const listItemElement = list.selectChild({ - featureName: "listItem", + name: "listItem", options: { label: "Mars", }, From fba00ac2304a5505350398a6d112b1ef0c15f05d Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Thu, 29 Aug 2024 12:13:41 -0500 Subject: [PATCH 24/28] fix: moves element selection outside of querySelector --- packages/odyssey-react-mui/src/Select.tsx | 3 + .../src/test-selectors/linkedHtmlSelectors.ts | 2 +- .../src/test-selectors/querySelector.ts | 132 ++++++++++-------- 3 files changed, 79 insertions(+), 58 deletions(-) diff --git a/packages/odyssey-react-mui/src/Select.tsx b/packages/odyssey-react-mui/src/Select.tsx index 1a9d426463..d722ac621e 100644 --- a/packages/odyssey-react-mui/src/Select.tsx +++ b/packages/odyssey-react-mui/src/Select.tsx @@ -64,6 +64,9 @@ export const SelectTestSelectors = { list: { children: { listItem: { + accessibleText: { + label: "label", + }, elementSelector: { method: "ByRole", options: { diff --git a/packages/odyssey-react-mui/src/test-selectors/linkedHtmlSelectors.ts b/packages/odyssey-react-mui/src/test-selectors/linkedHtmlSelectors.ts index 9d739199fd..629707f928 100644 --- a/packages/odyssey-react-mui/src/test-selectors/linkedHtmlSelectors.ts +++ b/packages/odyssey-react-mui/src/test-selectors/linkedHtmlSelectors.ts @@ -11,7 +11,7 @@ */ import { type AriaRole } from "react"; -import { ElementError } from "./sanityChecks"; +import { type ElementError } from "./sanityChecks"; import { getRole } from "dom-accessibility-api"; /** diff --git a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts index d0d01ca116..3fd9224b09 100644 --- a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts @@ -60,6 +60,70 @@ export type InnerQuerySelectorProps< } : object); +export const captureElement = < + LocalTestSelector extends TestSelector, + QuerySelectorOptions extends Record, + LocalQueryMethod extends QueryMethod = "get", +>({ + containerElement, + queryMethod, + querySelectorOptions, + role, + testSelector, +}: { + containerElement: HTMLElement; + queryMethod: LocalQueryMethod; + querySelectorOptions?: QuerySelectorOptions; + role?: AriaRole; + testSelector: LocalTestSelector; +}) => { + 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 = ( /** @@ -76,64 +140,14 @@ export const querySelector = } & InnerQuerySelectorProps, ) => { const { element: containerElement, queryMethod } = props; - const localQueryMethod = queryMethod || ("get" as const); - const querySelectorOptions = "options" in props ? props.options : undefined; - const role = "role" in props ? (props.role as AriaRole) : undefined; - - // This `let` is difficult to make into a `const`. It makes the code unreadable. - let capturedElement: HTMLElement | null = null; - - if ("elementSelector" in testSelector && querySelectorOptions) { - const sharedProps = { - element: containerElement, - queryMethod: localQueryMethod, - queryOptions: Object.fromEntries( - Object.entries(testSelector.elementSelector.options).map( - ([testSelectorsKey, testingLibraryKey]) => [ - testingLibraryKey, - querySelectorOptions[testSelectorsKey], - ], - ), - ) as Record< - LocalTestSelector extends ElementSelector - ? LocalTestSelector["elementSelector"]["options"][keyof LocalTestSelector["elementSelector"]["options"]] - : string, - string | RegExp - >, - }; - - capturedElement = - testSelector.elementSelector.method === "ByRole" - ? getByRoleQuerySelector({ - ...sharedProps, - role: - Array.isArray(testSelector.elementSelector.role) || role - ? role || "" - : testSelector.elementSelector.role, - }) - : getByTextQuerySelector({ - ...sharedProps, - selectionMethod: testSelector.elementSelector.method, - text: testSelector.elementSelector.text, - }); - } else if ( - "isControlledElement" in testSelector && - testSelector.isControlledElement - ) { - try { - capturedElement = getControlledElement({ element: containerElement }); - } catch (error) { - if (queryMethod === "query") { - capturedElement = null; - } - - throw error; - } - } - if (!("accessibleText" in testSelector)) { - throw new Error("Missing `accessibleText` in `TestSelector`"); - } + 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 @@ -149,6 +163,10 @@ export const querySelector = ); } + if (!("accessibleText" in testSelector)) { + throw new Error("Missing `accessibleText` in `TestSelector`"); + } + return getComputedAccessibleText({ element: capturedElement, type: testSelector.accessibleText[labelName], From 801ed833a495f938412cefe41fc247fbf03006b4 Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Thu, 29 Aug 2024 15:08:05 -0500 Subject: [PATCH 25/28] fix: adds missing accessibleText to Select --- packages/odyssey-react-mui/src/Select.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/odyssey-react-mui/src/Select.tsx b/packages/odyssey-react-mui/src/Select.tsx index d722ac621e..aab935dcc1 100644 --- a/packages/odyssey-react-mui/src/Select.tsx +++ b/packages/odyssey-react-mui/src/Select.tsx @@ -62,6 +62,9 @@ export const SelectTestSelectors = { }, children: { list: { + accessibleText: { + label: "label", + }, children: { listItem: { accessibleText: { From d449bda7c5f422d73d599e5fa687ffc510cbf6a7 Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Thu, 29 Aug 2024 15:11:53 -0500 Subject: [PATCH 26/28] fix: renames testSelectors to testSelector in all supported components --- .../odyssey-react-mui/src/Autocomplete.tsx | 2 +- packages/odyssey-react-mui/src/Callout.tsx | 2 +- packages/odyssey-react-mui/src/Select.tsx | 2 +- packages/odyssey-react-mui/src/Tabs.tsx | 2 +- packages/odyssey-react-mui/src/TextField.tsx | 2 +- .../test-selectors/queryOdysseySelector.ts | 20 +++++++++---------- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/odyssey-react-mui/src/Autocomplete.tsx b/packages/odyssey-react-mui/src/Autocomplete.tsx index 92f5b24cd6..5ae23cf4bf 100644 --- a/packages/odyssey-react-mui/src/Autocomplete.tsx +++ b/packages/odyssey-react-mui/src/Autocomplete.tsx @@ -53,7 +53,7 @@ import { TestSelector } from "./test-selectors"; // @see https://github.com/bvaughn/react-virtualized/issues/1739#issuecomment-1291444246 const AutoSizer = _AutoSizer as unknown as FC; -export const AutocompleteTestSelectors = { +export const AutocompleteTestSelector = { accessibleText: { errorMessage: "errorMessage", hint: "description", diff --git a/packages/odyssey-react-mui/src/Callout.tsx b/packages/odyssey-react-mui/src/Callout.tsx index 580fdd9d23..8b22564533 100644 --- a/packages/odyssey-react-mui/src/Callout.tsx +++ b/packages/odyssey-react-mui/src/Callout.tsx @@ -24,7 +24,7 @@ import { type TestSelector } from "./test-selectors"; import { Paragraph } from "./Typography"; import { useUniqueId } from "./useUniqueId"; -export const CalloutTestSelectors = { +export const CalloutTestSelector = { accessibleText: { text: "description", title: "label", diff --git a/packages/odyssey-react-mui/src/Select.tsx b/packages/odyssey-react-mui/src/Select.tsx index aab935dcc1..4dcf4b54cd 100644 --- a/packages/odyssey-react-mui/src/Select.tsx +++ b/packages/odyssey-react-mui/src/Select.tsx @@ -54,7 +54,7 @@ import { } from "./OdysseyDesignTokensContext"; import { TestSelector } from "./test-selectors"; -export const SelectTestSelectors = { +export const SelectTestSelector = { accessibleText: { errorMessage: "errorMessage", hint: "description", diff --git a/packages/odyssey-react-mui/src/Tabs.tsx b/packages/odyssey-react-mui/src/Tabs.tsx index c68b5cbe46..76440f7f68 100644 --- a/packages/odyssey-react-mui/src/Tabs.tsx +++ b/packages/odyssey-react-mui/src/Tabs.tsx @@ -32,7 +32,7 @@ import { HtmlProps } from "./HtmlProps"; import { useOdysseyDesignTokens } from "./OdysseyDesignTokensContext"; import { type TestSelector } from "./test-selectors"; -export const TabsTestSelectors = { +export const TabsTestSelector = { children: { tabItem: { elementSelector: { diff --git a/packages/odyssey-react-mui/src/TextField.tsx b/packages/odyssey-react-mui/src/TextField.tsx index eed249cf73..122f34bcf2 100644 --- a/packages/odyssey-react-mui/src/TextField.tsx +++ b/packages/odyssey-react-mui/src/TextField.tsx @@ -32,7 +32,7 @@ import { HtmlProps } from "./HtmlProps"; import { FocusHandle, useInputValues, getControlState } from "./inputUtils"; import { type TestSelector } from "./test-selectors"; -export const TextFieldTestSelectors = { +export const TextFieldTestSelector = { accessibleText: { errorMessage: "errorMessage", hint: "description", diff --git a/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts b/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts index ed4fdf6475..36b297c954 100644 --- a/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/queryOdysseySelector.ts @@ -12,18 +12,18 @@ import { querySelector } from "./querySelector"; import { type TestSelector } from "./testSelector"; -import { AutocompleteTestSelectors } from "../Autocomplete"; -import { CalloutTestSelectors } from "../Callout"; -import { SelectTestSelectors } from "../Select"; -import { TabsTestSelectors } from "../Tabs"; -import { TextFieldTestSelectors } from "../TextField"; +import { AutocompleteTestSelector } from "../Autocomplete"; +import { CalloutTestSelector } from "../Callout"; +import { SelectTestSelector } from "../Select"; +import { TabsTestSelector } from "../Tabs"; +import { TextFieldTestSelector } from "../TextField"; export const odysseyTestSelector = { - Autocomplete: AutocompleteTestSelectors, - Callout: CalloutTestSelectors, - Select: SelectTestSelectors, - Tabs: TabsTestSelectors, - TextField: TextFieldTestSelectors, + Autocomplete: AutocompleteTestSelector, + Callout: CalloutTestSelector, + Select: SelectTestSelector, + Tabs: TabsTestSelector, + TextField: TextFieldTestSelector, } as const satisfies Record; export const queryOdysseySelector = < From 7af68becb7f055514b27e99f2ee4a8fa27fa6dc5 Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Wed, 11 Sep 2024 12:32:02 -0500 Subject: [PATCH 27/28] fix: fixes small type issues and Autocomplete merge issue --- .../odyssey-react-mui/src/Autocomplete.tsx | 4 ---- .../src/test-selectors/linkedHtmlSelectors.ts | 2 +- .../src/test-selectors/querySelector.ts | 24 +++++++++---------- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/packages/odyssey-react-mui/src/Autocomplete.tsx b/packages/odyssey-react-mui/src/Autocomplete.tsx index 0427bf790b..f740721b6c 100644 --- a/packages/odyssey-react-mui/src/Autocomplete.tsx +++ b/packages/odyssey-react-mui/src/Autocomplete.tsx @@ -45,10 +45,6 @@ import { } from "./inputUtils"; import { TestSelector } from "./test-selectors"; -// This is required to get around a react-types issue for "AutoSizer is not a valid JSX element." -// @see https://github.com/bvaughn/react-virtualized/issues/1739#issuecomment-1291444246 -const AutoSizer = _AutoSizer as unknown as FC; - export const AutocompleteTestSelector = { accessibleText: { errorMessage: "errorMessage", diff --git a/packages/odyssey-react-mui/src/test-selectors/linkedHtmlSelectors.ts b/packages/odyssey-react-mui/src/test-selectors/linkedHtmlSelectors.ts index 629707f928..9d739199fd 100644 --- a/packages/odyssey-react-mui/src/test-selectors/linkedHtmlSelectors.ts +++ b/packages/odyssey-react-mui/src/test-selectors/linkedHtmlSelectors.ts @@ -11,7 +11,7 @@ */ import { type AriaRole } from "react"; -import { type ElementError } from "./sanityChecks"; +import { ElementError } from "./sanityChecks"; import { getRole } from "dom-accessibility-api"; /** diff --git a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts index 3fd9224b09..a45d3ccf75 100644 --- a/packages/odyssey-react-mui/src/test-selectors/querySelector.ts +++ b/packages/odyssey-react-mui/src/test-selectors/querySelector.ts @@ -34,18 +34,18 @@ export type InnerQuerySelectorProps< * Testing Library method used to query elements. */ queryMethod?: LocalQueryMethod; -} & (LocalTestSelector extends { - selector: { - 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]; - } +} & (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 From 97b1c22a614a462bc5788844801f24719f296396 Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Wed, 11 Sep 2024 13:11:57 -0500 Subject: [PATCH 28/28] fix: fixes broken Select interaction test --- .../src/components/odyssey-mui/Select/Select.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 49a3708b7e..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 @@ -257,7 +257,7 @@ export const DefaultValue: StoryObj = { const selector = querySelect({ element: canvasElement, options: { - label: /Destination/, + label: /Okta documentation/, }, }); @@ -274,7 +274,7 @@ export const DefaultValue: StoryObj = { const listItemElement = list.selectChild({ name: "listItem", options: { - label: "Mars", + label: "Roles and permissions", }, }).element;