diff --git a/packages/calcite-components/.eslintrc.cjs b/packages/calcite-components/.eslintrc.cjs index 79c44f70aeb..eb65d213326 100644 --- a/packages/calcite-components/.eslintrc.cjs +++ b/packages/calcite-components/.eslintrc.cjs @@ -91,6 +91,18 @@ module.exports = { }, ], "no-new-func": "error", + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["tests/commonTests/*"], + message: + "Import named functions from commonTests instead of direct module imports, e.g., import { disabled } from 'tests/commonTests'", + }, + ], + }, + ], "no-unneeded-ternary": "error", "one-var": ["error", "never"], "react/forbid-component-props": [ @@ -155,10 +167,16 @@ module.exports = { ignorePrivate: true, }, }, - overrides: [{ - files: ["**/*.e2e.ts", "src/tests/**/*"], - rules: { - "@esri/calcite-components/no-dynamic-createelement": "off", - } - }] + overrides: [ + { + files: ["**/*.e2e.ts", "src/tests/**/*"], + rules: { + "@esri/calcite-components/no-dynamic-createelement": "off", + }, + }, + { + extends: ["plugin:@typescript-eslint/disable-type-checked"], + files: ["*.cjs"], + }, + ], }; diff --git a/packages/calcite-components/src/components/input-time-zone/input-time-zone.e2e.ts b/packages/calcite-components/src/components/input-time-zone/input-time-zone.e2e.ts index 09b9ccbcf26..6beb2983b38 100644 --- a/packages/calcite-components/src/components/input-time-zone/input-time-zone.e2e.ts +++ b/packages/calcite-components/src/components/input-time-zone/input-time-zone.e2e.ts @@ -11,8 +11,8 @@ import { reflects, renders, t9n, - TagAndPage, } from "../../tests/commonTests"; +import { TagAndPage } from "../../tests/commonTests/interfaces"; import { toUserFriendlyName } from "./utils"; describe("calcite-input-time-zone", () => { diff --git a/packages/calcite-components/src/tests/commonTests.ts b/packages/calcite-components/src/tests/commonTests.ts deleted file mode 100644 index 7bea0db677c..00000000000 --- a/packages/calcite-components/src/tests/commonTests.ts +++ /dev/null @@ -1,1841 +0,0 @@ -/* eslint-disable jest/no-conditional-expect -- Using conditional logic in a confined test helper to handle specific scenarios, reducing duplication, balancing test readability and maintainability. **/ -/* eslint-disable jest/no-export -- Util functions are now imported to be used as `it` blocks within `describe` instead of assertions within `it` blocks. */ -import { E2EElement, E2EPage, EventSpy, newE2EPage } from "@stencil/core/testing"; -import axe from "axe-core"; -import { toHaveNoViolations } from "jest-axe"; -import { KeyInput } from "puppeteer"; -import { config } from "../../stencil.config"; -import { html } from "../../support/formatting"; -import type { JSX } from "../components"; -import { getClearValidationEventName, hiddenFormInputSlotName, componentsWithInputEvent } from "../utils/form"; -import { MessageBundle } from "../utils/t9n"; -import { - GlobalTestProps, - IntrinsicElementsWithProp, - isElementFocused, - newProgrammaticE2EPage, - skipAnimations, -} from "./utils"; - -expect.extend(toHaveNoViolations); - -type ComponentTag = keyof JSX.IntrinsicElements; -type AxeOwningWindow = GlobalTestProps<{ axe: typeof axe }>; -type ComponentHTML = string; -type TagOrHTML = ComponentTag | ComponentHTML; -type BeforeContent = (page: E2EPage) => Promise; - -export type TagAndPage = { - tag: ComponentTag; - page: E2EPage; -}; - -type TagOrHTMLWithBeforeContent = { - tagOrHTML: TagOrHTML; - - /** - * Allows for custom setup of the page. - * - * This is useful for test helpers that need to create and configure the test page before running tests. - * - * @param page - */ - beforeContent: BeforeContent; -}; - -export const HYDRATED_ATTR = config.hydratedFlag.name; - -function isHTML(tagOrHTML: string): boolean { - return tagOrHTML.trim().startsWith("<"); -} - -function getTag(tagOrHTML: string): ComponentTag { - if (isHTML(tagOrHTML)) { - const calciteTagRegex = / { - const componentTag = getTag(componentTagOrHTML); - const page = await newE2EPage({ - html: isHTML(componentTagOrHTML) ? componentTagOrHTML : `<${componentTag}>`, - failOnConsoleError: true, - }); - await page.waitForChanges(); - - return page; -} - -/** - * Helper for asserting that a component is accessible. - * - * Note that this helper should be used within a describe block. - * - * @example - * describe("accessible"), () => { - * accessible(``); - * }); - * - * @param {ComponentTestSetup} componentTestSetup - A component tag, html, or the tag and e2e page for setting up a test - */ -export function accessible(componentTestSetup: ComponentTestSetup): void { - it("is accessible", async () => { - const { page, tag } = await getTagAndPage(componentTestSetup); - - await page.addScriptTag({ path: require.resolve("axe-core") }); - await page.waitForFunction(() => (window as AxeOwningWindow).axe); - - expect( - await page.evaluate(async (componentTag: ComponentTag) => (window as AxeOwningWindow).axe.run(componentTag), tag), - ).toHaveNoViolations(); - }); -} - -/** - * Note that this helper should be used within a describe block. - * - * @example - * describe("renders", () => { - * renders(``); - * }); - * @param componentTestSetup - * - * @param {string} componentTagOrHTML - the component tag or HTML markup to test against - * @param {object} options - additional options to assert - * @param {string} options.visible - is the component visible - * @param {string} options.display - is the component's display "inline" - */ -export async function renders( - componentTestSetup: ComponentTestSetup, - options?: { - visible?: boolean; - display: string; - }, -): Promise { - it(`renders`, async () => { - const { page, tag } = await getTagAndPage(componentTestSetup); - const element = await page.find(tag); - - expect(element).toHaveAttribute(HYDRATED_ATTR); - expect(await element.isVisible()).toBe(options?.visible ?? true); - expect((await element.getComputedStyle()).display).toBe(options?.display ?? "inline"); - }); -} - -/** - * - * Helper for asserting that a component reflects - * - * Note that this helper should be used within a describe block. - * - * @example - * describe("reflects", () => { - * reflects("calcite-action-bar", [ - * { - * propertyName: "expandDisabled", - * value: true - * }, - * { - * propertyName: "expanded", - * value: true - * } - * ]) - * }) - * - * @param {string} componentTagOrHTML - the component tag or HTML markup to test against - * @param componentTestSetup - * @param {object[]} propsToTest - the properties to test - * @param {string} propsToTest.propertyName - the property name - * @param {any} propsToTest.value - the property value (if boolean, needs to be `true` to ensure reflection) - */ -export function reflects( - componentTestSetup: ComponentTestSetup, - propsToTest: { - propertyName: string; - value: any; - }[], -): void { - const cases = propsToTest.map(({ propertyName, value }) => [propertyName, value]); - - it.each(cases)("%p", async (propertyName, value) => { - const { page, tag: componentTag } = await getTagAndPage(componentTestSetup); - await skipAnimations(page); - const element = await page.find(componentTag); - - const attrName = propToAttr(propertyName); - const componentAttributeSelector = `${componentTag}[${attrName}]`; - - element.setProperty(propertyName, value); - await page.waitForChanges(); - - expect(await page.find(componentAttributeSelector)).toBeTruthy(); - - if (typeof value === "boolean") { - const getExpectedValue = (propValue: boolean): string | null => (propValue ? "" : null); - const negated = !value; - - element.setProperty(propertyName, negated); - await page.waitForChanges(); - - expect(element.getAttribute(attrName)).toBe(getExpectedValue(negated)); - - element.setProperty(propertyName, value); - await page.waitForChanges(); - - expect(element.getAttribute(attrName)).toBe(getExpectedValue(value)); - } - }); -} - -function propToAttr(name: string): string { - return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); -} - -/** - * Helper for asserting that a property's value is its default - * - * Note that this helper should be used within a describe block. - * - * @example - * describe("defaults", () => { - * defaults("calcite-action", [ - * { - * propertyName: "active", - * defaultValue: false - * }, - * { - * propertyName: "appearance", - * defaultValue: "solid" - * } - * ]) - * }) - * - * @param {string} componentTagOrHTML - the component tag or HTML markup to test against - * @param componentTestSetup - * @param {object[]} propsToTest - the properties to test - * @param {string} propsToTest.propertyName - the property name - * @param {any} propsToTest.value - the property value - */ -export function defaults( - componentTestSetup: ComponentTestSetup, - propsToTest: { - propertyName: string; - defaultValue: any; - }[], -): void { - it.each(propsToTest.map(({ propertyName, defaultValue }) => [propertyName, defaultValue]))( - "%p", - async (propertyName, defaultValue) => { - const { page, tag } = await getTagAndPage(componentTestSetup); - const element = await page.find(tag); - const prop = await element.getProperty(propertyName); - expect(prop).toEqual(defaultValue); - }, - ); -} - -/** - * Helper for asserting that a component is not visible when hidden - * - * Note that this helper should be used within a describe block. - * - * @example - * @param componentTestSetup - * describe("honors hidden attribute", () => { - * hidden("calcite-accordion") - * }); - * - * @param {string} componentTagOrHTML - the component tag or HTML markup to test against - */ -export async function hidden(componentTestSetup: ComponentTestSetup): Promise { - it("is hidden", async () => { - const { page, tag } = await getTagAndPage(componentTestSetup); - const element = await page.find(tag); - - element.setAttribute("hidden", ""); - await page.waitForChanges(); - - expect(await element.isVisible()).toBe(false); - }); -} - -interface FocusableOptions { - /** - * use this to pass an ID to setFocus() - * - * @deprecated components should no longer use a focusId parameter for setFocus() - */ - focusId?: string; - - /** - * selector used to assert the focused DOM element - */ - focusTargetSelector?: string; - - /** - * selector used to assert the focused shadow DOM element - */ - shadowFocusTargetSelector?: string; -} - -/** - * Helper for asserting that a component is focusable - * - * Note that this helper should be used within a describe block. - * - * @example - * describe("is focusable", () => { - * focusable(`calcite-input-number`, { shadowFocusTargetSelector: "input" }) - * }); - * - * @param {string} componentTagOrHTML - the component tag or HTML markup to test against - * @param componentTestSetup - * @param {FocusableOptions} [options] - additional options for asserting focus - */ -export function focusable(componentTestSetup: ComponentTestSetup, options?: FocusableOptions): void { - it("is focusable", async () => { - const { page, tag } = await getTagAndPage(componentTestSetup); - const element = await page.find(tag); - const focusTargetSelector = options?.focusTargetSelector || tag; - await element.callMethod("setFocus", options?.focusId); // assumes element is FocusableElement - - if (options?.shadowFocusTargetSelector) { - expect( - await page.$eval( - tag, - (element: HTMLElement, selector: string) => element.shadowRoot.activeElement?.matches(selector), - options?.shadowFocusTargetSelector, - ), - ).toBe(true); - } - - // wait for next frame before checking focus - await page.waitForChanges(); - - expect(await page.evaluate((selector) => document.activeElement?.matches(selector), focusTargetSelector)).toBe( - true, - ); - }); -} - -/** - * Helper for asserting slots. - * - * Note that this helper should be used within a describe block. - * - * @example - * describe("slots", () => { - * slots("calcite-stack", SLOTS) - * }) - * - * @param {string} componentTagOrHTML - The component tag or HTML markup to test against. - * @param {Record | string[]} slots - A component's SLOTS resource object or an array of slot names. - * @param {boolean} includeDefaultSlot - When true, it will run assertions on the default slot. - */ -export function slots( - componentTagOrHTML: TagOrHTML, - slots: Record | string[], - includeDefaultSlot = false, -): void { - it("has slots", async () => { - const page = await simplePageSetup(componentTagOrHTML); - const tag = getTag(componentTagOrHTML); - const slotNames = Array.isArray(slots) ? slots : Object.values(slots); - - await page.$eval( - tag, - async (component, slotNames: string[], includeDefaultSlot?: boolean) => { - async function slotTestElement(testClass: string, slotName?: string): Promise { - const el = document.createElement("div"); // slotting a
will suffice for our purposes - el.classList.add(testClass); - - if (slotName) { - el.slot = slotName; - } - - component.append(el); - await new Promise((resolve) => requestAnimationFrame(() => resolve())); - } - - for (let i = 0; i < slotNames.length; i++) { - await slotTestElement("slotted-into-named-slot", slotNames[i]); - } - - if (includeDefaultSlot) { - await slotTestElement("slotted-into-default-slot"); - } - }, - slotNames, - includeDefaultSlot, - ); - - await page.waitForChanges(); - - const slotted = await page.evaluate(() => - Array.from(document.querySelectorAll(".slotted-into-named-slot")) - .filter((slotted) => slotted.assignedSlot) - .map((slotted) => slotted.slot), - ); - - expect(slotNames).toEqual(slotted); - - if (includeDefaultSlot) { - const hasDefaultSlotted = await page.evaluate(() => { - const defaultSlotted = document.querySelector(".slotted-into-default-slot"); - return defaultSlotted.assignedSlot?.name === "" && defaultSlotted.slot === ""; - }); - - expect(hasDefaultSlotted).toBe(true); - } - }); -} - -async function assertLabelable({ - page, - componentTag, - propertyToToggle, - focusTargetSelector = componentTag, - shadowFocusTargetSelector, -}: { - page: E2EPage; - componentTag: string; - propertyToToggle?: string; - focusTargetSelector?: string; - shadowFocusTargetSelector?: string; -}): Promise { - let initialPropertyValue: boolean; - const component = await page.find(componentTag); - - if (propertyToToggle) { - initialPropertyValue = await component.getProperty(propertyToToggle); - } - - const label = await page.find("calcite-label"); - await label.callMethod("click"); // we call the method to avoid clicking the child element - await page.waitForChanges(); - - expect( - await page.evaluate( - (focusTargetSelector: string): boolean => !!document.activeElement?.closest(focusTargetSelector), - focusTargetSelector, - ), - ).toBe(true); - - if (shadowFocusTargetSelector) { - expect( - await page.$eval( - componentTag, - (element: HTMLElement, selector: string) => element.shadowRoot.activeElement.matches(selector), - shadowFocusTargetSelector, - ), - ).toBe(true); - } - - if (propertyToToggle) { - const toggledPropertyValue = !initialPropertyValue; - expect(await component.getProperty(propertyToToggle)).toBe(toggledPropertyValue); - - // assert that direct clicks on component toggle property correctly - component.setProperty(propertyToToggle, initialPropertyValue); // we reset as not all components toggle when clicked - await page.waitForChanges(); - await component.click(); - await page.waitForChanges(); - expect(await component.getProperty(propertyToToggle)).toBe(toggledPropertyValue); - } - - // assert clicking on labelable keeps focus - await component.callMethod("click"); - await page.waitForChanges(); - - expect(await isElementFocused(page, focusTargetSelector)).toBe(true); -} - -interface LabelableOptions extends Pick { - /** - * If clicking on a label toggles the labelable component, use this prop to specify the name of the toggled prop. - */ - propertyToToggle?: string; -} - -/** - * Helper for asserting label clicking functionality works. - * - * Note that this helper should be used within a describe block. - * - * @example - * describe("labelable", () => { - * async () => labelable("calcite-button") - * }) - * - * @param {string} componentTagOrHtml - The component tag or HTML used to test label support. - * @param {LabelableOptions} [options] - Labelable options. - */ -export function labelable( - componentTagOrHtml: TagOrHTML | TagOrHTMLWithBeforeContent, - options?: LabelableOptions, -): void { - const id = "labelable-id"; - const labelTitle = "My Component"; - const propertyToToggle = options?.propertyToToggle; - const focusTargetSelector = options?.focusTargetSelector || `#${id}`; - const shadowFocusTargetSelector = options?.shadowFocusTargetSelector; - const { beforeContent, tagOrHTML } = getTagOrHTMLWithBeforeContent(componentTagOrHtml); - const componentTag = getTag(tagOrHTML); - const componentHtml = isHTML(tagOrHTML) ? ensureId(tagOrHTML) : `<${componentTag} id="${id}">`; - - function ensureId(html: string): string { - return html.includes("id=") ? html : html.replace(componentTag, `${componentTag} id="${id}" `); - } - - describe("label wraps labelables", () => { - it("is labelable when component is wrapped in a label", async () => { - const wrappedHtml = html`${labelTitle} ${componentHtml}`; - const wrappedPage: E2EPage = await newE2EPage(); - beforeContent?.(wrappedPage); - await wrappedPage.setContent(wrappedHtml); - await wrappedPage.waitForChanges(); - - await assertLabelable({ - page: wrappedPage, - componentTag, - propertyToToggle, - focusTargetSelector, - shadowFocusTargetSelector, - }); - }); - - it("is labelable when wrapping label is set prior to component", async () => { - const labelFirstWrappedPage: E2EPage = await newE2EPage(); - beforeContent?.(labelFirstWrappedPage); - await labelFirstWrappedPage.setContent(html` - - - `); - await labelFirstWrappedPage.waitForChanges(); - await labelFirstWrappedPage.evaluate(() => { - const template = document.querySelector("template"); - const labelEl = document.querySelector("calcite-label"); - - labelEl.append(template.content.cloneNode(true)); - }, componentHtml); - await labelFirstWrappedPage.waitForChanges(); - - await assertLabelable({ - page: labelFirstWrappedPage, - componentTag, - propertyToToggle, - focusTargetSelector, - shadowFocusTargetSelector, - }); - }); - - it("is labelable when a component is set first before being wrapped in a label", async () => { - const componentFirstWrappedPage: E2EPage = await newE2EPage(); - beforeContent?.(componentFirstWrappedPage); - await componentFirstWrappedPage.setContent(componentHtml); - await componentFirstWrappedPage.waitForChanges(); - await componentFirstWrappedPage.evaluate((id: string) => { - const componentEl = document.querySelector(`[id='${id}']`); - const labelEl = document.createElement("calcite-label"); - document.body.append(labelEl); - labelEl.append(componentEl); - }, id); - await componentFirstWrappedPage.waitForChanges(); - - await assertLabelable({ - page: componentFirstWrappedPage, - componentTag, - propertyToToggle, - focusTargetSelector, - shadowFocusTargetSelector, - }); - }); - - it("only sets focus on the first labelable when label is clicked", async () => { - const firstLabelableId = `${id}`; - const componentFirstWrappedPage: E2EPage = await newE2EPage(); - beforeContent?.(componentFirstWrappedPage); - const content = html` - - - ${componentHtml.replace(id, firstLabelableId)} ${componentHtml.replace(id, `${id}-2`)} - ${componentHtml.replace(id, `${id}-3`)} - - `; - - await componentFirstWrappedPage.setContent(content); - await componentFirstWrappedPage.waitForChanges(); - - const firstLabelableSelector = `#${firstLabelableId}`; - - await assertLabelable({ - page: componentFirstWrappedPage, - componentTag, - propertyToToggle, - focusTargetSelector: - focusTargetSelector === firstLabelableSelector - ? firstLabelableSelector - : `${firstLabelableSelector} ${focusTargetSelector}`, - }); - }); - }); - - describe("label is sibling to labelables", () => { - it("is labelable with label set as a sibling to the component", async () => { - const siblingHtml = html` - ${labelTitle} - ${componentHtml} - `; - const siblingPage: E2EPage = await newE2EPage(); - beforeContent?.(siblingPage); - - await siblingPage.setContent(siblingHtml); - await siblingPage.waitForChanges(); - - await assertLabelable({ - page: siblingPage, - componentTag, - propertyToToggle, - focusTargetSelector, - shadowFocusTargetSelector, - }); - }); - - it("is labelable when sibling label is set prior to component", async () => { - const labelFirstSiblingPage: E2EPage = await newE2EPage(); - beforeContent?.(labelFirstSiblingPage); - await labelFirstSiblingPage.setContent(html` - - - `); - await labelFirstSiblingPage.waitForChanges(); - await labelFirstSiblingPage.evaluate(() => { - const template = document.querySelector("template"); - document.body.append(template.content.cloneNode(true)); - }, componentHtml); - await labelFirstSiblingPage.waitForChanges(); - - await assertLabelable({ - page: labelFirstSiblingPage, - componentTag, - propertyToToggle, - focusTargetSelector, - shadowFocusTargetSelector, - }); - }); - - it("is labelable for a component set before sibling label", async () => { - const componentFirstSiblingPage: E2EPage = await newE2EPage(); - beforeContent?.(componentFirstSiblingPage); - await componentFirstSiblingPage.setContent(componentHtml); - await componentFirstSiblingPage.waitForChanges(); - await componentFirstSiblingPage.evaluate((id: string) => { - const label = document.createElement("calcite-label"); - label.setAttribute("for", `${id}`); - document.body.append(label); - }, id); - await componentFirstSiblingPage.waitForChanges(); - - await assertLabelable({ - page: componentFirstSiblingPage, - componentTag, - propertyToToggle, - focusTargetSelector, - shadowFocusTargetSelector, - }); - }); - - it("is labelable when label's for is set after initialization", async () => { - const siblingHtml = html` - ${labelTitle} - ${componentHtml} - `; - const siblingPage: E2EPage = await newE2EPage(); - beforeContent?.(siblingPage); - - await siblingPage.setContent(siblingHtml); - await siblingPage.waitForChanges(); - - const label = await siblingPage.find("calcite-label"); - label.setProperty("for", id); - await siblingPage.waitForChanges(); - - await assertLabelable({ - page: siblingPage, - componentTag, - propertyToToggle, - focusTargetSelector, - shadowFocusTargetSelector, - }); - }); - }); -} - -interface FormAssociatedOptions { - /** - * This value will be set on the component and submitted by the form. - */ - testValue: any; - - /** - * Set this if the expected submit value **is different** from stringifying `testValue`. - * For example, a component may transform an object to a serializable string. - */ - expectedSubmitValue?: any; - - /* - * Set this if the value required to emit an input/change event is different from `testValue`. - * The value is passed to `page.keyboard.type()`. For example, input-time-picker requires - * appending AM or PM before the value commits and calciteInputTimePickerChange emits. - * - * This option is only relevant when the `validation` option is enabled. - */ - validUserInputTestValue?: string; - - /* - * Set this if emitting an input/change event requires key presses. Each array item will be passed - * to `page.keyboard.press()`. For example, the combobox value can be changed by pressing "Space" - * to open the component and "Enter" to select a value. - * - * This option is only relevant when the `validation` option is enabled. - */ - changeValueKeys?: KeyInput[]; - - /** - * Specifies the input type that will be used to capture the value. - */ - inputType?: HTMLInputElement["type"]; - - /** - * Specifies if the component supports submitting the form on Enter key press. - */ - submitsOnEnter?: boolean; - - /** - * Specifies if the component supports clearing its value (i.e., setting to null). - */ - clearable?: boolean; - - /** - * Specifies if the component supports preventing submission and displaying validation messages. - */ - validation?: boolean; -} - -/** - * Helper for testing form-associated components; specifically form submitting and resetting. - * - * Note that this helper should be used within a describe block. - * - * @param {string} componentTagOrHtml - the component tag or HTML markup to test against - * @param {FormAssociatedOptions} options - form associated options - */ -export function formAssociated( - componentTagOrHtml: TagOrHTML | TagOrHTMLWithBeforeContent, - options: FormAssociatedOptions, -): void { - const inputTypeContext = options?.inputType ? ` (input type="${options.inputType}")` : ""; - - it(`supports association via ancestry${inputTypeContext}`, () => testAncestorFormAssociated()); - it(`supports association via form ID${inputTypeContext}`, () => testIdFormAssociated()); - - if (options?.validation && !["color", "month", "time"].includes(options?.inputType)) { - it(`supports required property validation${inputTypeContext}`, () => testRequiredPropertyValidation()); - } - - async function testAncestorFormAssociated(): Promise { - const { beforeContent, tagOrHTML } = getTagOrHTMLWithBeforeContent(componentTagOrHtml); - const tag = getTag(tagOrHTML); - const componentHtml = ensureName(isHTML(tagOrHTML) ? tagOrHTML : `<${tag}>`, tag); - - const page = await newE2EPage(); - await beforeContent?.(page); - - const content = html`
- ${componentHtml} - - -
`; - await page.setContent(content); - await page.waitForChanges(); - const component = await page.find(tag); - - await assertValueSubmissionType(page, component, options); - await assertValueResetOnFormReset(page, component, options); - await assertValueSubmittedOnFormSubmit(page, component, options); - - if (options.submitsOnEnter) { - await assertFormSubmitOnEnter(page, component, options); - } - } - - async function testIdFormAssociated(): Promise { - const { beforeContent, tagOrHTML } = getTagOrHTMLWithBeforeContent(componentTagOrHtml); - const tag = getTag(tagOrHTML); - const componentHtml = ensureForm(ensureName(isHTML(tagOrHTML) ? tagOrHTML : `<${tag}>`, tag), tag); - - const page = await newE2EPage(); - await beforeContent?.(page); - await page.setContent( - html`
- ${componentHtml} - - `, - ); - await page.waitForChanges(); - const component = await page.find(tag); - - await assertValueSubmissionType(page, component, options); - await assertValueResetOnFormReset(page, component, options); - await assertValueSubmittedOnFormSubmit(page, component, options); - - if (options.submitsOnEnter) { - await assertFormSubmitOnEnter(page, component, options); - } - } - - async function testRequiredPropertyValidation(): Promise { - const requiredValidationMessage = "Please fill out this field."; - const { beforeContent, tagOrHTML } = getTagOrHTMLWithBeforeContent(componentTagOrHtml); - const tag = getTag(tagOrHTML); - const componentHtml = ensureUnchecked( - ensureRequired(ensureName(isHTML(tagOrHTML) ? tagOrHTML : `<${tag}>`, tag), tag), - ); - - const page = await newE2EPage(); - await beforeContent?.(page); - - const content = html` -
- ${componentHtml} - Submit -
- `; - - await page.setContent(content); - await page.waitForChanges(); - const component = await page.find(tag); - - const submitButton = await page.find("#submitButton"); - const spyEvent = await page.spyOnEvent(getClearValidationEventName(tag)); - - await assertPreventsFormSubmission(page, component, submitButton, requiredValidationMessage); - await assertClearsValidationOnValueChange(page, component, options, spyEvent, tag); - await assertUserMessageNotOverridden(page, component, submitButton); - } - - function ensureName(html: string, componentTag: string): string { - return html.includes("name=") ? html : html.replace(componentTag, `${componentTag} name="testName" `); - } - - function ensureRequired(html: string, componentTag: string): string { - return html.includes("required") ? html : html.replace(componentTag, `${componentTag} required `); - } - - function ensureUnchecked(html: string): string { - return html.replace(/(checked|selected)/, ""); - } - - function ensureForm(html: string, componentTag: string): string { - return html.includes("form=") ? html : html.replace(componentTag, `${componentTag} form="test-form" `); - } - - async function isCheckable(page: E2EPage, component: E2EElement, options: FormAssociatedOptions): Promise { - return ( - typeof options.testValue === "boolean" && - (await page.$eval(component.tagName.toLowerCase(), (component) => "checked" in component)) - ); - } - - function stringifyTestValue(value: any): string | string[] { - return Array.isArray(value) ? value.map((value) => value.toString()) : value.toString(); - } - - async function assertValueSubmissionType( - page: E2EPage, - component: E2EElement, - options: FormAssociatedOptions, - ): Promise { - const name = await component.getProperty("name"); - const inputType = options.inputType ?? "text"; - - const hiddenFormInputType = await page.evaluate( - async (inputName: string, hiddenFormInputSlotName: string): Promise => { - const hiddenFormInput = document.querySelector( - `[name="${inputName}"] input[slot=${hiddenFormInputSlotName}]`, - ); - - return hiddenFormInput.type; - }, - name, - hiddenFormInputSlotName, - ); - - if (await isCheckable(page, component, options)) { - expect(hiddenFormInputType).toMatch(/radio|checkbox/); - } else { - expect(hiddenFormInputType).toMatch(inputType); - } - } - - async function assertValueResetOnFormReset( - page: E2EPage, - component: E2EElement, - options: FormAssociatedOptions, - ): Promise { - const resettablePropName = (await isCheckable(page, component, options)) ? "checked" : "value"; - const initialValue = await component.getProperty(resettablePropName); - component.setProperty(resettablePropName, options.testValue); - await page.waitForChanges(); - - await page.$eval("form", (form: HTMLFormElement) => form.reset()); - await page.waitForChanges(); - - expect(await component.getProperty(resettablePropName)).toBe(initialValue); - } - - async function assertValueSubmittedOnFormSubmit( - page: E2EPage, - component: E2EElement, - options: FormAssociatedOptions, - ): Promise { - const stringifiedTestValue = stringifyTestValue(options.testValue); - const name = await component.getProperty("name"); - - if (await isCheckable(page, component, options)) { - component.setProperty("checked", true); - await page.waitForChanges(); - expect(await submitAndGetValue()).toEqual("on"); - - component.setProperty("value", options.testValue); - await page.waitForChanges(); - expect(await submitAndGetValue()).toEqual(stringifiedTestValue); - - component.setProperty("disabled", true); - await page.waitForChanges(); - expect(await submitAndGetValue()).toBe(null); - - component.setProperty("checked", true); - component.setProperty("disabled", false); - await page.waitForChanges(); - expect(await submitAndGetValue()).toEqual(stringifiedTestValue); - - component.setProperty("checked", false); - await page.waitForChanges(); - expect(await submitAndGetValue()).toBe(null); - } else { - if (options.clearable) { - component.setProperty("required", true); - component.setProperty("value", null); - await page.waitForChanges(); - expect(await submitAndGetValue()).toBe( - options.inputType === "color" - ? // `input[type="color"]` will set its value to #000000 when set to an invalid value - // see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/color#value - "#000" - : undefined, - ); - - component.setProperty("required", false); - component.setProperty("value", options.testValue); - await page.waitForChanges(); - expect(await submitAndGetValue()).toEqual(options?.expectedSubmitValue || stringifiedTestValue); - } - - component.setProperty("disabled", true); - await page.waitForChanges(); - expect(await submitAndGetValue()).toBe(null); - - component.setProperty("disabled", false); - component.setProperty("value", options.testValue); - await page.waitForChanges(); - expect(await submitAndGetValue()).toEqual(options?.expectedSubmitValue || stringifiedTestValue); - - component.setProperty("value", options.testValue); - await page.waitForChanges(); - expect(await submitAndGetValue()).toEqual(options?.expectedSubmitValue || stringifiedTestValue); - } - - type SubmitValueResult = ReturnType | ReturnType; - - /** - * This method will submit the form and return the submitted value: - * - * For single-value components, it will return a string or null if the value was not submitted - * For multi-value components, it will return an array of strings - * - * If the input cannot be submitted because it is invalid, undefined will be returned - */ - async function submitAndGetValue(): Promise { - return page.$eval( - "form", - async ( - form: HTMLFormElement, - inputName: string, - hiddenFormInputSlotName: string, - ): Promise => { - const hiddenFormInput = document.querySelector( - `[name="${inputName}"] input[slot=${hiddenFormInputSlotName}]`, - ); - - let resolve: (value: SubmitValueResult) => void; - const submitPromise = new Promise((yes) => (resolve = yes)); - - function handleFormSubmit(event: Event): void { - event.preventDefault(); - const formData = new FormData(form); - const values = formData.getAll(inputName); - - if (values.length > 1) { - resolve(values as string[]); - return; - } - - resolve(formData.get(inputName)); - hiddenFormInput.removeEventListener("invalid", handleInvalidInput); - } - - function handleInvalidInput(): void { - resolve(undefined); - form.removeEventListener("submit", handleFormSubmit); - } - - form.addEventListener("submit", handleFormSubmit, { once: true }); - hiddenFormInput.addEventListener("invalid", handleInvalidInput, { once: true }); - - document.querySelector("#submitter").click(); - - return submitPromise; - }, - name, - hiddenFormInputSlotName, - ); - } - } - - async function assertFormSubmitOnEnter( - page: E2EPage, - component: E2EElement, - options: FormAssociatedOptions, - ): Promise { - type TestWindow = GlobalTestProps<{ - called: boolean; - }>; - - await page.$eval("form", (form: HTMLFormElement) => { - form.addEventListener("submit", (event) => { - event.preventDefault(); - (window as TestWindow).called = true; - }); - }); - - const stringifiedTestValue = stringifyTestValue(options.testValue); - - component.setProperty("value", stringifiedTestValue); - await component.callMethod("setFocus"); - await page.keyboard.press("Enter"); - const called = await page.evaluate(() => (window as TestWindow).called); - - expect(called).toBe(true); - } - - async function assertPreventsFormSubmission( - page: E2EPage, - component: E2EElement, - submitButton: E2EElement, - message: string, - ) { - await submitButton.click(); - await page.waitForChanges(); - - await expectValidationInvalid(component, message); - } - - async function assertClearsValidationOnValueChange( - page: E2EPage, - component: E2EElement, - options: FormAssociatedOptions, - event: EventSpy, - tag: string, - ) { - if (options?.changeValueKeys) { - for (const key of options.changeValueKeys) { - await page.keyboard.press(key); - } - } else { - await page.keyboard.type(options?.validUserInputTestValue ?? options.testValue); - await page.keyboard.press("Tab"); - } - - await page.waitForChanges(); - - // components with an Input event will emit multiple times depending on the length of testValue - if (componentsWithInputEvent.includes(tag)) { - expect(event.length).toBeGreaterThanOrEqual(1); - } else { - expect(event).toHaveReceivedEventTimes(1); - } - - await expectValidationIdle(component); - } - - async function assertUserMessageNotOverridden(page: E2EPage, component: E2EElement, submitButton: E2EElement) { - const customValidationMessage = "This is a custom message."; - const customValidationIcon = "banana"; - - // don't override custom validation message and icon - component.setProperty("validationMessage", customValidationMessage); - component.setProperty("validationIcon", customValidationIcon); - component.setProperty("value", undefined); - await page.waitForChanges(); - - await submitButton.click(); - await page.waitForChanges(); - - await expectValidationInvalid(component, customValidationMessage, customValidationIcon); - } - - async function expectValidationIdle(element: E2EElement) { - expect(await element.getProperty("status")).toBe("idle"); - expect(await element.getProperty("validationMessage")).toBe(""); - expect(await element.getProperty("validationIcon")).toBe(false); - } - - async function expectValidationInvalid(element: E2EElement, message: string, icon: string = "") { - expect(await element.getProperty("status")).toBe("invalid"); - expect(await element.getProperty("validationMessage")).toBe(message); - expect(element.getAttribute("validation-icon")).toBe(icon); - } -} - -interface TabAndClickTargets { - tab: string; - click: string; -} - -type FocusTarget = "host" | "child" | "none"; - -interface DisabledOptions { - /** - * Use this to specify whether the test should cover focusing. - */ - focusTarget?: FocusTarget | TabAndClickTargets; - - /** - * Use this to specify the main wrapped component in shadow DOM that handles disabling interaction. - * - * Note: this should only be used for components that wrap a single component that implements disabled behavior. - */ - shadowAriaAttributeTargetSelector?: string; -} - -type ComponentTestContent = TagOrHTML | TagAndPage; -type ComponentTestSetupProvider = (() => ComponentTestContent) | (() => Promise); -type ComponentTestSetup = ComponentTestContent | ComponentTestSetupProvider; - -async function getTagAndPage(componentTestSetup: ComponentTestSetup): Promise { - if (typeof componentTestSetup === "function") { - componentTestSetup = await componentTestSetup(); - } - - if (typeof componentTestSetup === "string") { - const page = await simplePageSetup(componentTestSetup); - const tag = getTag(componentTestSetup); - - return { page, tag }; - } - - return componentTestSetup; -} - -function getTagOrHTMLWithBeforeContent(componentTestSetup: TagOrHTML | TagOrHTMLWithBeforeContent): { - tagOrHTML: TagOrHTML; - beforeContent?: BeforeContent; -} { - if (typeof componentTestSetup === "string") { - return { tagOrHTML: componentTestSetup }; - } - - return { - tagOrHTML: componentTestSetup.tagOrHTML, - beforeContent: componentTestSetup.beforeContent, - }; -} - -/** - * Helper to test the disabled prop disabling user interaction. - * - * Note that this helper should be used within a describe block. - * - * @example - * describe("disabled", () => { - * disabled("calcite-input") - * }); - * - * @param {ComponentTestSetup} componentTestSetup - A component tag, html, or the tag and e2e page for setting up a test. - * @param {DisabledOptions} [options] - Disabled options. - */ -export function disabled(componentTestSetup: ComponentTestSetup, options?: DisabledOptions): void { - options = { focusTarget: "host", ...options }; - - const addRedirectPrevention = async (page: E2EPage, tag: string): Promise => { - await page.$eval(tag, (el) => { - el.addEventListener( - "click", - (event) => { - const path = event.composedPath() as HTMLElement[]; - const anchor = path.find((el) => el?.tagName === "A"); - - if (anchor) { - // we prevent the default behavior to avoid a page redirect - anchor.addEventListener("click", (event) => event.preventDefault(), { once: true }); - } - }, - true, - ); - }); - }; - - async function expectToBeFocused(page: E2EPage, tag: string): Promise { - const focusedTag = await page.evaluate(() => document.activeElement?.tagName.toLowerCase()); - expect(focusedTag).toBe(tag); - } - - function assertOnMouseAndPointerEvents(spies: EventSpy[], expectCallback: (spy: EventSpy) => void): void { - for (const spy of spies) { - expectCallback(spy); - } - } - - // only testing events from https://github.com/web-platform-tests/wpt/blob/master/html/semantics/disabled-elements/event-propagate-disabled.tentative.html#L66 - const eventsExpectedToBubble = ["mousemove", "pointermove", "pointerdown", "pointerup"]; - const eventsExpectedToNotBubble = ["mousedown", "mouseup", "click"]; - const allExpectedEvents = [...eventsExpectedToBubble, ...eventsExpectedToNotBubble]; - - const createEventSpiesForExpectedEvents = async (component: E2EElement): Promise => { - const eventSpies: EventSpy[] = []; - - for (const event of allExpectedEvents) { - eventSpies.push(await component.spyOnEvent(event)); - } - - return eventSpies; - }; - - async function getFocusTarget(page: E2EPage, tag: string, focusTarget: FocusTarget): Promise { - return focusTarget === "host" ? tag : await page.evaluate(() => document.activeElement?.tagName.toLowerCase()); - } - - const getTabAndClickFocusTarget = async ( - page: E2EPage, - tag: string, - focusTarget: DisabledOptions["focusTarget"], - ): Promise => { - if (typeof focusTarget === "object") { - return [focusTarget.tab, focusTarget.click]; - } - - const sameClickAndTabFocusTarget = await getFocusTarget(page, tag, focusTarget); - - return [sameClickAndTabFocusTarget, sameClickAndTabFocusTarget]; - }; - - const getShadowFocusableCenterCoordinates = async (page: E2EPage, tabFocusTarget: string): Promise => { - return await page.$eval(tabFocusTarget, (element: HTMLElement) => { - const focusTarget = element.shadowRoot.activeElement || element; - const rect = focusTarget.getBoundingClientRect(); - - return [rect.x + rect.width / 2, rect.y + rect.height / 2]; - }); - }; - - it("prevents focusing via keyboard and mouse", async () => { - const { page, tag } = await getTagAndPage(componentTestSetup); - - const component = await page.find(tag); - const ariaAttributeTargetElement = options.shadowAriaAttributeTargetSelector - ? await page.find(`${tag} >>> ${options.shadowAriaAttributeTargetSelector}`) - : component; - await skipAnimations(page); - await addRedirectPrevention(page, tag); - - const eventSpies = await createEventSpiesForExpectedEvents(component); - - expect(ariaAttributeTargetElement.getAttribute("aria-disabled")).toBeNull(); - - if (options.focusTarget === "none") { - await page.click(tag); - await page.waitForChanges(); - await expectToBeFocused(page, "body"); - - assertOnMouseAndPointerEvents(eventSpies, (spy) => expect(spy).toHaveReceivedEventTimes(1)); - - component.setProperty("disabled", true); - await page.waitForChanges(); - - expect(ariaAttributeTargetElement.getAttribute("aria-disabled")).toBe("true"); - - await page.click(tag); - await page.waitForChanges(); - await expectToBeFocused(page, "body"); - - await component.callMethod("click"); - await page.waitForChanges(); - await expectToBeFocused(page, "body"); - - assertOnMouseAndPointerEvents(eventSpies, (spy) => { - expect(spy).toHaveReceivedEventTimes(eventsExpectedToBubble.includes(spy.eventName) ? 2 : 1); - }); - - return; - } - - await page.keyboard.press("Tab"); - - const [tabFocusTarget, clickFocusTarget] = await getTabAndClickFocusTarget(page, tag, options.focusTarget); - - expect(tabFocusTarget).not.toBe("body"); - await expectToBeFocused(page, tabFocusTarget); - - const [shadowFocusableCenterX, shadowFocusableCenterY] = await getShadowFocusableCenterCoordinates( - page, - tabFocusTarget, - ); - - async function resetFocusOrder(): Promise { - // test page has default margin, so clicking on 0,0 will not hit the test element - await page.mouse.click(0, 0); - } - - await resetFocusOrder(); - await expectToBeFocused(page, "body"); - - await page.mouse.click(shadowFocusableCenterX, shadowFocusableCenterY); - await page.waitForChanges(); - await expectToBeFocused(page, clickFocusTarget); - - await component.callMethod("click"); - await page.waitForChanges(); - await expectToBeFocused(page, clickFocusTarget); - - assertOnMouseAndPointerEvents(eventSpies, (spy) => { - if (spy.eventName === "click") { - // some components emit more than one click event (e.g., from calling `click()`), - // so we check if at least one event is received - expect(spy.length).toBeGreaterThanOrEqual(2); - } else { - expect(spy).toHaveReceivedEventTimes(1); - } - }); - - component.setProperty("disabled", true); - await page.waitForChanges(); - - expect(ariaAttributeTargetElement.getAttribute("aria-disabled")).toBe("true"); - - await resetFocusOrder(); - await page.keyboard.press("Tab"); - await expectToBeFocused(page, "body"); - - await page.mouse.click(shadowFocusableCenterX, shadowFocusableCenterY); - await expectToBeFocused(page, "body"); - - assertOnMouseAndPointerEvents(eventSpies, (spy) => { - if (spy.eventName === "click") { - // some components emit more than one click event (e.g., from calling `click()`), - // so we check if at least one event is received - expect(spy.length).toBeGreaterThanOrEqual(2); - } else { - expect(spy).toHaveReceivedEventTimes(eventsExpectedToBubble.includes(spy.eventName) ? 2 : 1); - } - }); - }); - - it("events are no longer blocked right after enabling", async () => { - const { page, tag } = await getTagAndPage(componentTestSetup); - - const component = await page.find(tag); - const ariaAttributeTargetElement = options.shadowAriaAttributeTargetSelector - ? await page.find(`${tag} >>> ${options.shadowAriaAttributeTargetSelector}`) - : component; - - await skipAnimations(page); - await addRedirectPrevention(page, tag); - - const eventSpies = await createEventSpiesForExpectedEvents(component); - - component.setProperty("disabled", true); - await page.waitForChanges(); - - expect(ariaAttributeTargetElement.getAttribute("aria-disabled")).toBe("true"); - - await page.click(tag); - await page.waitForChanges(); - - assertOnMouseAndPointerEvents(eventSpies, (spy) => { - expect(spy).toHaveReceivedEventTimes(eventsExpectedToBubble.includes(spy.eventName) ? 1 : 0); - }); - - type InteractiveCalciteComponents = IntrinsicElementsWithProp<"disabled"> & HTMLElement; - - // this needs to run in the browser context to ensure disabling and events fire immediately after being set - await page.$eval( - tag, - (component: InteractiveCalciteComponents, allExpectedEvents: string[]) => { - component.disabled = false; - allExpectedEvents.forEach((event) => component.dispatchEvent(new MouseEvent(event))); - - component.disabled = true; - allExpectedEvents.forEach((event) => component.dispatchEvent(new MouseEvent(event))); - }, - allExpectedEvents, - ); - - assertOnMouseAndPointerEvents(eventSpies, (spy) => { - if (spy.eventName === "click") { - // some components emit more than one click event (e.g., from calling `click()`), - // so we check if at least one event is received - expect(spy.length).toBeGreaterThanOrEqual(1); - } else { - expect(spy).toHaveReceivedEventTimes(eventsExpectedToBubble.includes(spy.eventName) ? 3 : 1); - } - }); - }); -} - -/** - * This helper will test if a floating-ui-owning component has configured the floating-ui correctly. - * At the moment, this only tests if the scroll event listeners are only active when the floating-ui is displayed. - * - * Note that this helper should be used within a describe block. - * - * @example - * describe("owns a floating-ui", () => { - * floatingUIOwner( - * ``, - * "open", - * { shadowSelector: ".menu-container" } - * ) - * }); - * - * @param {TagOrHTML} componentTagOrHTML - The component tag or HTML markup to test against. - * @param {string} togglePropName - The component property that toggles the floating-ui. - * @param [options] - additional options for asserting focus - * @param {string} [options.shadowSelector] - The selector in the shadow DOM for the floating-ui element. - */ -export function floatingUIOwner( - componentTagOrHTML: TagOrHTML, - togglePropName: string, - options?: { - /** - * Use this to specify the selector in the shadow DOM for the floating-ui element. - */ - shadowSelector?: string; - }, -): void { - it("owns a floating-ui", async () => { - const page = await simplePageSetup(componentTagOrHTML); - - const scrollablePageSizeInPx = 2400; - await page.addStyleTag({ - content: `body { - height: ${scrollablePageSizeInPx}px; - width: ${scrollablePageSizeInPx}px; - }`, - }); - await page.waitForChanges(); - - const tag = getTag(componentTagOrHTML); - const component = await page.find(tag); - - async function getTransform(): Promise { - // need to get the style attribute from the browser context since the E2E element returns null - return page.$eval( - tag, - (component: HTMLElement, shadowSelector: string): string => { - const floatingUIEl = shadowSelector - ? component.shadowRoot.querySelector(shadowSelector) - : component; - - return floatingUIEl.getAttribute("style"); - }, - options?.shadowSelector, - ); - } - - async function scrollTo(x: number, y: number): Promise { - await page.evaluate((x: number, y: number) => document.firstElementChild.scrollTo(x, y), x, y); - } - - component.setProperty(togglePropName, false); - await page.waitForChanges(); - - const initialClosedTransform = await getTransform(); - - await scrollTo(scrollablePageSizeInPx, scrollablePageSizeInPx); - await page.waitForChanges(); - - expect(await getTransform()).toBe(initialClosedTransform); - - await scrollTo(0, 0); - await page.waitForChanges(); - - expect(await getTransform()).toBe(initialClosedTransform); - - component.setProperty(togglePropName, true); - await page.waitForChanges(); - - const initialOpenTransform = await getTransform(); - - await scrollTo(scrollablePageSizeInPx, scrollablePageSizeInPx); - await page.waitForChanges(); - - expect(await getTransform()).not.toBe(initialOpenTransform); - - await scrollTo(0, 0); - await page.waitForChanges(); - - expect(await getTransform()).toBe(initialOpenTransform); - }); -} - -/** - * Helper to test if a component has a floating-UI-owning component wired up. - * - * Note: this performs a shallow test and assumes the underlying component has floating-ui properly configured. - * - * @example - * describe("delegates to floating-ui-owner component", () => { - * delegatesToFloatingUiOwningComponent("calcite-pad", "calcite-action-group"); - * }); - * - * @param componentTagOrHTML - * @param floatingUiOwnerComponentTag - */ -export async function delegatesToFloatingUiOwningComponent( - componentTagOrHTML: TagOrHTML, - floatingUiOwnerComponentTag: ComponentTag, -): Promise { - it("delegates to floating-ui owning component", async () => { - const page = await simplePageSetup(componentTagOrHTML); - const tag = getTag(componentTagOrHTML); - - // we assume if `overlay-positioning` is used by an internal component that it is a floating-ui component - - const floatingUiOwningComponent = await page.find(`${tag} >>> ${floatingUiOwnerComponentTag}`); - expect(await floatingUiOwningComponent.getProperty("overlayPositioning")).toBe("absolute"); - - const component = await page.find(tag); - await component.setProperty("overlayPositioning", "fixed"); - await page.waitForChanges(); - - expect(await floatingUiOwningComponent.getProperty("overlayPositioning")).toBe("fixed"); - }); -} - -/** - * Helper to test t9n component setup. - * - * Note that this helper should be used within a describe block. - * - * @example - * describe("translation support", () => { - * t9n("calcite-action"); - * }); - * - * @param {ComponentTestSetup} componentTestSetup - A component tag, html, or the tag and e2e page for setting up a test. - */ - -export async function t9n(componentTestSetup: ComponentTestSetup): Promise { - let component: E2EElement; - let page: E2EPage; - let getCurrentMessages: () => Promise; - - beforeEach(async () => { - const { page: e2ePage, tag } = await getTagAndPage(componentTestSetup); - page = e2ePage; - - type CalciteComponentsWithMessages = IntrinsicElementsWithProp<"messages"> & HTMLElement; - - component = await page.find(tag); - getCurrentMessages = async (): Promise => { - return page.$eval(tag, (component: CalciteComponentsWithMessages) => component.messages); - }; - }); - - it("has defined default messages", async () => await assertDefaultMessages()); - it("overrides messages", async () => await assertOverrides()); - it("switches messages", async () => await assertLangSwitch()); - - async function assertDefaultMessages(): Promise { - expect(await getCurrentMessages()).toBeDefined(); - } - - async function assertOverrides(): Promise { - const messages = await getCurrentMessages(); - const firstMessageProp = Object.keys(messages)[0]; - const messageOverride = { [firstMessageProp]: "override test" }; - - component.setProperty("messageOverrides", messageOverride); - await page.waitForChanges(); - - expect(await getCurrentMessages()).toEqual({ - ...messages, - ...messageOverride, - }); - - // reset test changes - component.setProperty("messageOverrides", undefined); - await page.waitForChanges(); - } - - async function assertLangSwitch(): Promise { - const enMessages = await getCurrentMessages(); - const fakeBundleIdentifier = "__fake__"; - await page.evaluate( - (enMessages, fakeBundleIdentifier) => { - const orig = window.fetch; - window.fetch = async function (input, init) { - if (typeof input === "string" && input.endsWith("messages_es.json")) { - const fakeEsMessages = { - ...enMessages, // reuse real message bundle in case component rendering depends on strings - - [fakeBundleIdentifier]: true, // we inject a fake identifier for assertion-purposes - }; - window.fetch = orig; - return new Response(new Blob([JSON.stringify(fakeEsMessages, null, 2)], { type: "application/json" })); - } - - return orig.call(input, init); - }; - }, - enMessages, - fakeBundleIdentifier, - ); - - component.setAttribute("lang", "es"); - await page.waitForChanges(); - await page.waitForTimeout(3000); - const esMessages = await getCurrentMessages(); - - expect(esMessages).toHaveProperty(fakeBundleIdentifier); - - // reset test changes - component.removeAttribute("lang"); - await page.waitForChanges(); - } -} - -interface BeforeToggle { - /** - * Function argument to simulate user input (mouse or keyboard), to open the component. - */ - open: (page: E2EPage) => Promise; - - /** - * Function argument to simulate user input (mouse or keyboard), to close the component. - */ - close: (page: E2EPage) => Promise; -} - -interface OpenCloseOptions { - /** - * Toggle property to test. Currently, either "open" or "expanded". - */ - openPropName?: string; - - /** - * Indicates the initial value of the toggle property. - */ - initialToggleValue?: boolean; - - /** - * Optional argument with functions to simulate user input (mouse or keyboard), to open or close the component. - */ - beforeToggle?: BeforeToggle; -} - -/** - * Helper to test openClose component setup. - * - * Note that this helper should be used within a `describe` block. - * - * @example - * - * describe("openClose", () => { - * openClose("calcite-combobox opened with a tab", { - * beforeToggle: { - * open: async (page) => { - * await page.keyboard.press("Tab"); - * await page.waitForChanges(); - * }, - * close: async (page) => { - * await page.keyboard.press("Tab"); - * await page.waitForChanges(); - * }, - * } - * }); - * - * openClose("open calcite-combobox closed with a tab", { - * initialToggleValue: true, - * beforeToggle: { - * close: async (page) => { - * await page.keyboard.press("Tab"); - * await page.waitForChanges(); - * }, - * } - * } - * }) - * - * @param componentTagOrHTML - The component tag or HTML markup to test against. - * @param {object} [options] - Additional options to assert. - */ - -export function openClose(componentTagOrHTML: TagOrHTML, options?: OpenCloseOptions): void { - const defaultOptions: OpenCloseOptions = { - initialToggleValue: false, - openPropName: "open", - }; - const customizedOptions = { ...defaultOptions, ...options }; - - type EventOrderWindow = GlobalTestProps<{ events: string[] }>; - const eventSequence = setUpEventSequence(componentTagOrHTML); - - function setUpEventSequence(componentTagOrHTML: TagOrHTML): string[] { - const tag = getTag(componentTagOrHTML); - - const camelCaseTag = tag.replace(/-([a-z])/g, (lettersAfterHyphen) => lettersAfterHyphen[1].toUpperCase()); - const eventSuffixes = [`BeforeOpen`, `Open`, `BeforeClose`, `Close`]; - - return eventSuffixes.map((suffix) => `${camelCaseTag}${suffix}`); - } - - async function setUpPage(componentTagOrHTML: TagOrHTML, page: E2EPage): Promise { - await page.evaluate( - (eventSequence: string[], initialToggleValue: boolean, openPropName: string, componentTagOrHTML: string) => { - const receivedEvents: string[] = []; - - (window as EventOrderWindow).events = receivedEvents; - - eventSequence.forEach((eventType) => { - document.addEventListener(eventType, (event) => receivedEvents.push(event.type)); - }); - - if (!initialToggleValue) { - return; - } - - const component = document.createElement(componentTagOrHTML); - component[openPropName] = true; - - document.body.append(component); - }, - eventSequence, - customizedOptions.initialToggleValue, - customizedOptions.openPropName, - componentTagOrHTML, - ); - } - - async function testOpenCloseEvents(componentTagOrHTML: TagOrHTML, page: E2EPage): Promise { - const tag = getTag(componentTagOrHTML); - const element = await page.find(tag); - - const [beforeOpenEvent, openEvent, beforeCloseEvent, closeEvent] = eventSequence.map((event) => - page.waitForEvent(event), - ); - - const [beforeOpenSpy, openSpy, beforeCloseSpy, closeSpy] = await Promise.all( - eventSequence.map(async (event) => await element.spyOnEvent(event)), - ); - - await page.waitForChanges(); - - if (customizedOptions.beforeToggle) { - await customizedOptions.beforeToggle.open(page); - } else { - element.setProperty(customizedOptions.openPropName, true); - } - - await page.waitForChanges(); - - await beforeOpenEvent; - await openEvent; - - expect(beforeOpenSpy).toHaveReceivedEventTimes(1); - expect(openSpy).toHaveReceivedEventTimes(1); - expect(beforeCloseSpy).toHaveReceivedEventTimes(0); - expect(closeSpy).toHaveReceivedEventTimes(0); - - if (customizedOptions.beforeToggle) { - await customizedOptions.beforeToggle.close(page); - } else { - element.setProperty(customizedOptions.openPropName, false); - } - - await page.waitForChanges(); - - await beforeCloseEvent; - await closeEvent; - - expect(beforeCloseSpy).toHaveReceivedEventTimes(1); - expect(closeSpy).toHaveReceivedEventTimes(1); - expect(beforeOpenSpy).toHaveReceivedEventTimes(1); - expect(openSpy).toHaveReceivedEventTimes(1); - - expect(await page.evaluate(() => (window as EventOrderWindow).events)).toEqual(eventSequence); - } - - /** - * `skipAnimations` utility sets the animation duration to 0.01. This is a workaround for an issue with the animation utility. - * Because this still leaves a very small duration, we can still test the animation events, but faster. - */ - - if (customizedOptions.initialToggleValue === true) { - it("emits on initialization with animations enabled", async () => { - const page = await newProgrammaticE2EPage(); - await skipAnimations(page); - await setUpPage(componentTagOrHTML, page); - await testOpenCloseEvents(componentTagOrHTML, page); - }); - - it("emits on initialization with animations disabled", async () => { - const page = await newProgrammaticE2EPage(); - await page.addStyleTag({ - content: `:root { --calcite-duration-factor: 0; }`, - }); - await setUpPage(componentTagOrHTML, page); - await testOpenCloseEvents(componentTagOrHTML, page); - }); - } else { - it(`emits with animations enabled`, async () => { - const page = await simplePageSetup(componentTagOrHTML); - await skipAnimations(page); - await setUpPage(componentTagOrHTML, page); - await testOpenCloseEvents(componentTagOrHTML, page); - }); - - it(`emits with animations disabled`, async () => { - const page = await simplePageSetup(componentTagOrHTML); - await page.addStyleTag({ - content: `:root { --calcite-duration-factor: 0; }`, - }); - await setUpPage(componentTagOrHTML, page); - await testOpenCloseEvents(componentTagOrHTML, page); - }); - } -} diff --git a/packages/calcite-components/src/tests/commonTests/.eslintrc.cjs b/packages/calcite-components/src/tests/commonTests/.eslintrc.cjs new file mode 100644 index 00000000000..dd6b3d9ba76 --- /dev/null +++ b/packages/calcite-components/src/tests/commonTests/.eslintrc.cjs @@ -0,0 +1,8 @@ +module.exports = { + rules: { + /* Using conditional logic in a confined test helper to handle specific scenarios, reducing duplication, balancing test readability and maintainability. **/ + "jest/no-conditional-expect": "off", + /* Util functions are now imported to be used as `it` blocks within `describe` instead of assertions within `it` blocks. */ + "jest/no-export": "off", + }, +}; diff --git a/packages/calcite-components/src/tests/commonTests/accessible.ts b/packages/calcite-components/src/tests/commonTests/accessible.ts new file mode 100644 index 00000000000..7449b1c40e3 --- /dev/null +++ b/packages/calcite-components/src/tests/commonTests/accessible.ts @@ -0,0 +1,34 @@ +import axe from "axe-core"; +import { toHaveNoViolations } from "jest-axe"; +import { GlobalTestProps } from "./../utils"; +import { getTagAndPage } from "./utils"; +import { ComponentTestSetup, ComponentTag } from "./interfaces"; + +expect.extend(toHaveNoViolations); + +type AxeOwningWindow = GlobalTestProps<{ axe: typeof axe }>; + +/** + * Helper for asserting that a component is accessible. + * + * Note that this helper should be used within a describe block. + * + * @example + * describe("accessible"), () => { + * accessible(``); + * }); + * + * @param {ComponentTestSetup} componentTestSetup - A component tag, html, or the tag and e2e page for setting up a test + */ +export function accessible(componentTestSetup: ComponentTestSetup): void { + it("is accessible", async () => { + const { page, tag } = await getTagAndPage(componentTestSetup); + + await page.addScriptTag({ path: require.resolve("axe-core") }); + await page.waitForFunction(() => (window as AxeOwningWindow).axe); + + expect( + await page.evaluate(async (componentTag: ComponentTag) => (window as AxeOwningWindow).axe.run(componentTag), tag), + ).toHaveNoViolations(); + }); +} diff --git a/packages/calcite-components/src/tests/commonTests/defaults.ts b/packages/calcite-components/src/tests/commonTests/defaults.ts new file mode 100644 index 00000000000..767e4ed7a16 --- /dev/null +++ b/packages/calcite-components/src/tests/commonTests/defaults.ts @@ -0,0 +1,48 @@ +import { toHaveNoViolations } from "jest-axe"; +import { getTagAndPage } from "./utils"; +import { ComponentTestSetup } from "./interfaces"; + +expect.extend(toHaveNoViolations); + +/** + * Helper for asserting that a property's value is its default + * + * Note that this helper should be used within a describe block. + * + * @example + * describe("defaults", () => { + * defaults("calcite-action", [ + * { + * propertyName: "active", + * defaultValue: false + * }, + * { + * propertyName: "appearance", + * defaultValue: "solid" + * } + * ]) + * }) + * + * @param {string} componentTagOrHTML - the component tag or HTML markup to test against + * @param componentTestSetup + * @param {object[]} propsToTest - the properties to test + * @param {string} propsToTest.propertyName - the property name + * @param {any} propsToTest.value - the property value + */ +export function defaults( + componentTestSetup: ComponentTestSetup, + propsToTest: { + propertyName: string; + defaultValue: any; + }[], +): void { + it.each(propsToTest.map(({ propertyName, defaultValue }) => [propertyName, defaultValue]))( + "%p", + async (propertyName, defaultValue) => { + const { page, tag } = await getTagAndPage(componentTestSetup); + const element = await page.find(tag); + const prop = await element.getProperty(propertyName); + expect(prop).toEqual(defaultValue); + }, + ); +} diff --git a/packages/calcite-components/src/tests/commonTests/disabled.ts b/packages/calcite-components/src/tests/commonTests/disabled.ts new file mode 100644 index 00000000000..321ef74f970 --- /dev/null +++ b/packages/calcite-components/src/tests/commonTests/disabled.ts @@ -0,0 +1,248 @@ +import { E2EElement, E2EPage, EventSpy } from "@stencil/core/testing"; +import { toHaveNoViolations } from "jest-axe"; +import { IntrinsicElementsWithProp, skipAnimations } from "./../utils"; +import { getTagAndPage } from "./utils"; +import { ComponentTestSetup, DisabledOptions, FocusTarget } from "./interfaces"; + +expect.extend(toHaveNoViolations); + +/** + * Helper to test the disabled prop disabling user interaction. + * + * Note that this helper should be used within a describe block. + * + * @example + * describe("disabled", () => { + * disabled("calcite-input") + * }); + * + * @param {ComponentTestSetup} componentTestSetup - A component tag, html, or the tag and e2e page for setting up a test. + * @param {DisabledOptions} [options] - Disabled options. + */ +export function disabled(componentTestSetup: ComponentTestSetup, options?: DisabledOptions): void { + options = { focusTarget: "host", ...options }; + + const addRedirectPrevention = async (page: E2EPage, tag: string): Promise => { + await page.$eval(tag, (el) => { + el.addEventListener( + "click", + (event) => { + const path = event.composedPath() as HTMLElement[]; + const anchor = path.find((el) => el?.tagName === "A"); + + if (anchor) { + // we prevent the default behavior to avoid a page redirect + anchor.addEventListener("click", (event) => event.preventDefault(), { once: true }); + } + }, + true, + ); + }); + }; + + async function expectToBeFocused(page: E2EPage, tag: string): Promise { + const focusedTag = await page.evaluate(() => document.activeElement?.tagName.toLowerCase()); + expect(focusedTag).toBe(tag); + } + + function assertOnMouseAndPointerEvents(spies: EventSpy[], expectCallback: (spy: EventSpy) => void): void { + for (const spy of spies) { + expectCallback(spy); + } + } + + // only testing events from https://github.com/web-platform-tests/wpt/blob/master/html/semantics/disabled-elements/event-propagate-disabled.tentative.html#L66 + const eventsExpectedToBubble = ["mousemove", "pointermove", "pointerdown", "pointerup"]; + const eventsExpectedToNotBubble = ["mousedown", "mouseup", "click"]; + const allExpectedEvents = [...eventsExpectedToBubble, ...eventsExpectedToNotBubble]; + + const createEventSpiesForExpectedEvents = async (component: E2EElement): Promise => { + const eventSpies: EventSpy[] = []; + + for (const event of allExpectedEvents) { + eventSpies.push(await component.spyOnEvent(event)); + } + + return eventSpies; + }; + + async function getFocusTarget(page: E2EPage, tag: string, focusTarget: FocusTarget): Promise { + return focusTarget === "host" ? tag : await page.evaluate(() => document.activeElement?.tagName.toLowerCase()); + } + + const getTabAndClickFocusTarget = async ( + page: E2EPage, + tag: string, + focusTarget: DisabledOptions["focusTarget"], + ): Promise => { + if (typeof focusTarget === "object") { + return [focusTarget.tab, focusTarget.click]; + } + + const sameClickAndTabFocusTarget = await getFocusTarget(page, tag, focusTarget); + + return [sameClickAndTabFocusTarget, sameClickAndTabFocusTarget]; + }; + + const getShadowFocusableCenterCoordinates = async (page: E2EPage, tabFocusTarget: string): Promise => { + return await page.$eval(tabFocusTarget, (element: HTMLElement) => { + const focusTarget = element.shadowRoot.activeElement || element; + const rect = focusTarget.getBoundingClientRect(); + + return [rect.x + rect.width / 2, rect.y + rect.height / 2]; + }); + }; + + it("prevents focusing via keyboard and mouse", async () => { + const { page, tag } = await getTagAndPage(componentTestSetup); + + const component = await page.find(tag); + const ariaAttributeTargetElement = options.shadowAriaAttributeTargetSelector + ? await page.find(`${tag} >>> ${options.shadowAriaAttributeTargetSelector}`) + : component; + await skipAnimations(page); + await addRedirectPrevention(page, tag); + + const eventSpies = await createEventSpiesForExpectedEvents(component); + + expect(ariaAttributeTargetElement.getAttribute("aria-disabled")).toBeNull(); + + if (options.focusTarget === "none") { + await page.click(tag); + await page.waitForChanges(); + await expectToBeFocused(page, "body"); + + assertOnMouseAndPointerEvents(eventSpies, (spy) => expect(spy).toHaveReceivedEventTimes(1)); + + component.setProperty("disabled", true); + await page.waitForChanges(); + + expect(ariaAttributeTargetElement.getAttribute("aria-disabled")).toBe("true"); + + await page.click(tag); + await page.waitForChanges(); + await expectToBeFocused(page, "body"); + + await component.callMethod("click"); + await page.waitForChanges(); + await expectToBeFocused(page, "body"); + + assertOnMouseAndPointerEvents(eventSpies, (spy) => { + expect(spy).toHaveReceivedEventTimes(eventsExpectedToBubble.includes(spy.eventName) ? 2 : 1); + }); + + return; + } + + await page.keyboard.press("Tab"); + + const [tabFocusTarget, clickFocusTarget] = await getTabAndClickFocusTarget(page, tag, options.focusTarget); + + expect(tabFocusTarget).not.toBe("body"); + await expectToBeFocused(page, tabFocusTarget); + + const [shadowFocusableCenterX, shadowFocusableCenterY] = await getShadowFocusableCenterCoordinates( + page, + tabFocusTarget, + ); + + async function resetFocusOrder(): Promise { + // test page has default margin, so clicking on 0,0 will not hit the test element + await page.mouse.click(0, 0); + } + + await resetFocusOrder(); + await expectToBeFocused(page, "body"); + + await page.mouse.click(shadowFocusableCenterX, shadowFocusableCenterY); + await page.waitForChanges(); + await expectToBeFocused(page, clickFocusTarget); + + await component.callMethod("click"); + await page.waitForChanges(); + await expectToBeFocused(page, clickFocusTarget); + + assertOnMouseAndPointerEvents(eventSpies, (spy) => { + if (spy.eventName === "click") { + // some components emit more than one click event (e.g., from calling `click()`), + // so we check if at least one event is received + expect(spy.length).toBeGreaterThanOrEqual(2); + } else { + expect(spy).toHaveReceivedEventTimes(1); + } + }); + + component.setProperty("disabled", true); + await page.waitForChanges(); + + expect(ariaAttributeTargetElement.getAttribute("aria-disabled")).toBe("true"); + + await resetFocusOrder(); + await page.keyboard.press("Tab"); + await expectToBeFocused(page, "body"); + + await page.mouse.click(shadowFocusableCenterX, shadowFocusableCenterY); + await expectToBeFocused(page, "body"); + + assertOnMouseAndPointerEvents(eventSpies, (spy) => { + if (spy.eventName === "click") { + // some components emit more than one click event (e.g., from calling `click()`), + // so we check if at least one event is received + expect(spy.length).toBeGreaterThanOrEqual(2); + } else { + expect(spy).toHaveReceivedEventTimes(eventsExpectedToBubble.includes(spy.eventName) ? 2 : 1); + } + }); + }); + + it("events are no longer blocked right after enabling", async () => { + const { page, tag } = await getTagAndPage(componentTestSetup); + + const component = await page.find(tag); + const ariaAttributeTargetElement = options.shadowAriaAttributeTargetSelector + ? await page.find(`${tag} >>> ${options.shadowAriaAttributeTargetSelector}`) + : component; + + await skipAnimations(page); + await addRedirectPrevention(page, tag); + + const eventSpies = await createEventSpiesForExpectedEvents(component); + + component.setProperty("disabled", true); + await page.waitForChanges(); + + expect(ariaAttributeTargetElement.getAttribute("aria-disabled")).toBe("true"); + + await page.click(tag); + await page.waitForChanges(); + + assertOnMouseAndPointerEvents(eventSpies, (spy) => { + expect(spy).toHaveReceivedEventTimes(eventsExpectedToBubble.includes(spy.eventName) ? 1 : 0); + }); + + type InteractiveCalciteComponents = IntrinsicElementsWithProp<"disabled"> & HTMLElement; + + // this needs to run in the browser context to ensure disabling and events fire immediately after being set + await page.$eval( + tag, + (component: InteractiveCalciteComponents, allExpectedEvents: string[]) => { + component.disabled = false; + allExpectedEvents.forEach((event) => component.dispatchEvent(new MouseEvent(event))); + + component.disabled = true; + allExpectedEvents.forEach((event) => component.dispatchEvent(new MouseEvent(event))); + }, + allExpectedEvents, + ); + + assertOnMouseAndPointerEvents(eventSpies, (spy) => { + if (spy.eventName === "click") { + // some components emit more than one click event (e.g., from calling `click()`), + // so we check if at least one event is received + expect(spy.length).toBeGreaterThanOrEqual(1); + } else { + expect(spy).toHaveReceivedEventTimes(eventsExpectedToBubble.includes(spy.eventName) ? 3 : 1); + } + }); + }); +} diff --git a/packages/calcite-components/src/tests/commonTests/floatingUI.ts b/packages/calcite-components/src/tests/commonTests/floatingUI.ts new file mode 100644 index 00000000000..cb647efefed --- /dev/null +++ b/packages/calcite-components/src/tests/commonTests/floatingUI.ts @@ -0,0 +1,134 @@ +import { toHaveNoViolations } from "jest-axe"; +import { getTag, simplePageSetup } from "./utils"; +import { ComponentTag, TagOrHTML } from "./interfaces"; +expect.extend(toHaveNoViolations); + +/** + * This helper will test if a floating-ui-owning component has configured the floating-ui correctly. + * At the moment, this only tests if the scroll event listeners are only active when the floating-ui is displayed. + * + * Note that this helper should be used within a describe block. + * + * @example + * describe("owns a floating-ui", () => { + * floatingUIOwner( + * ``, + * "open", + * { shadowSelector: ".menu-container" } + * ) + * }); + * + * @param {TagOrHTML} componentTagOrHTML - The component tag or HTML markup to test against. + * @param {string} togglePropName - The component property that toggles the floating-ui. + * @param [options] - additional options for asserting focus + * @param {string} [options.shadowSelector] - The selector in the shadow DOM for the floating-ui element. + */ +export function floatingUIOwner( + componentTagOrHTML: TagOrHTML, + togglePropName: string, + options?: { + /** + * Use this to specify the selector in the shadow DOM for the floating-ui element. + */ + shadowSelector?: string; + }, +): void { + it("owns a floating-ui", async () => { + const page = await simplePageSetup(componentTagOrHTML); + + const scrollablePageSizeInPx = 2400; + await page.addStyleTag({ + content: `body { + height: ${scrollablePageSizeInPx}px; + width: ${scrollablePageSizeInPx}px; + }`, + }); + await page.waitForChanges(); + + const tag = getTag(componentTagOrHTML); + const component = await page.find(tag); + + async function getTransform(): Promise { + // need to get the style attribute from the browser context since the E2E element returns null + return page.$eval( + tag, + (component: HTMLElement, shadowSelector: string): string => { + const floatingUIEl = shadowSelector + ? component.shadowRoot.querySelector(shadowSelector) + : component; + + return floatingUIEl.getAttribute("style"); + }, + options?.shadowSelector, + ); + } + + async function scrollTo(x: number, y: number): Promise { + await page.evaluate((x: number, y: number) => document.firstElementChild.scrollTo(x, y), x, y); + } + + component.setProperty(togglePropName, false); + await page.waitForChanges(); + + const initialClosedTransform = await getTransform(); + + await scrollTo(scrollablePageSizeInPx, scrollablePageSizeInPx); + await page.waitForChanges(); + + expect(await getTransform()).toBe(initialClosedTransform); + + await scrollTo(0, 0); + await page.waitForChanges(); + + expect(await getTransform()).toBe(initialClosedTransform); + + component.setProperty(togglePropName, true); + await page.waitForChanges(); + + const initialOpenTransform = await getTransform(); + + await scrollTo(scrollablePageSizeInPx, scrollablePageSizeInPx); + await page.waitForChanges(); + + expect(await getTransform()).not.toBe(initialOpenTransform); + + await scrollTo(0, 0); + await page.waitForChanges(); + + expect(await getTransform()).toBe(initialOpenTransform); + }); +} + +/** + * Helper to test if a component has a floating-UI-owning component wired up. + * + * Note: this performs a shallow test and assumes the underlying component has floating-ui properly configured. + * + * @example + * describe("delegates to floating-ui-owner component", () => { + * delegatesToFloatingUiOwningComponent("calcite-pad", "calcite-action-group"); + * }); + * + * @param componentTagOrHTML + * @param floatingUiOwnerComponentTag + */ +export async function delegatesToFloatingUiOwningComponent( + componentTagOrHTML: TagOrHTML, + floatingUiOwnerComponentTag: ComponentTag, +): Promise { + it("delegates to floating-ui owning component", async () => { + const page = await simplePageSetup(componentTagOrHTML); + const tag = getTag(componentTagOrHTML); + + // we assume if `overlay-positioning` is used by an internal component that it is a floating-ui component + + const floatingUiOwningComponent = await page.find(`${tag} >>> ${floatingUiOwnerComponentTag}`); + expect(await floatingUiOwningComponent.getProperty("overlayPositioning")).toBe("absolute"); + + const component = await page.find(tag); + await component.setProperty("overlayPositioning", "fixed"); + await page.waitForChanges(); + + expect(await floatingUiOwningComponent.getProperty("overlayPositioning")).toBe("fixed"); + }); +} diff --git a/packages/calcite-components/src/tests/commonTests/focusable.ts b/packages/calcite-components/src/tests/commonTests/focusable.ts new file mode 100644 index 00000000000..56f429e3ed2 --- /dev/null +++ b/packages/calcite-components/src/tests/commonTests/focusable.ts @@ -0,0 +1,65 @@ +import { toHaveNoViolations } from "jest-axe"; +import {} from "./../utils"; +import { getTagAndPage } from "./utils"; +import { ComponentTestSetup } from "./interfaces"; + +expect.extend(toHaveNoViolations); + +export interface FocusableOptions { + /** + * use this to pass an ID to setFocus() + * + * @deprecated components should no longer use a focusId parameter for setFocus() + */ + focusId?: string; + + /** + * selector used to assert the focused DOM element + */ + focusTargetSelector?: string; + + /** + * selector used to assert the focused shadow DOM element + */ + shadowFocusTargetSelector?: string; +} + +/** + * Helper for asserting that a component is focusable + * + * Note that this helper should be used within a describe block. + * + * @example + * describe("is focusable", () => { + * focusable(`calcite-input-number`, { shadowFocusTargetSelector: "input" }) + * }); + * + * @param {string} componentTagOrHTML - the component tag or HTML markup to test against + * @param componentTestSetup + * @param {FocusableOptions} [options] - additional options for asserting focus + */ +export function focusable(componentTestSetup: ComponentTestSetup, options?: FocusableOptions): void { + it("is focusable", async () => { + const { page, tag } = await getTagAndPage(componentTestSetup); + const element = await page.find(tag); + const focusTargetSelector = options?.focusTargetSelector || tag; + await element.callMethod("setFocus", options?.focusId); // assumes element is FocusableElement + + if (options?.shadowFocusTargetSelector) { + expect( + await page.$eval( + tag, + (element: HTMLElement, selector: string) => element.shadowRoot.activeElement?.matches(selector), + options?.shadowFocusTargetSelector, + ), + ).toBe(true); + } + + // wait for next frame before checking focus + await page.waitForChanges(); + + expect(await page.evaluate((selector) => document.activeElement?.matches(selector), focusTargetSelector)).toBe( + true, + ); + }); +} diff --git a/packages/calcite-components/src/tests/commonTests/formAssociated.ts b/packages/calcite-components/src/tests/commonTests/formAssociated.ts new file mode 100644 index 00000000000..d636d880b23 --- /dev/null +++ b/packages/calcite-components/src/tests/commonTests/formAssociated.ts @@ -0,0 +1,453 @@ +import { E2EElement, E2EPage, EventSpy, newE2EPage } from "@stencil/core/testing"; +import { toHaveNoViolations } from "jest-axe"; +import { KeyInput } from "puppeteer"; +import { html } from "../../../support/formatting"; +import { getClearValidationEventName, hiddenFormInputSlotName, componentsWithInputEvent } from "../../utils/form"; +import { GlobalTestProps } from "./../utils"; +import { isHTML, getTag, getTagOrHTMLWithBeforeContent } from "./utils"; +import { TagOrHTMLWithBeforeContent, TagOrHTML } from "./interfaces"; + +expect.extend(toHaveNoViolations); + +interface FormAssociatedOptions { + /** + * This value will be set on the component and submitted by the form. + */ + testValue: any; + + /** + * Set this if the expected submit value **is different** from stringifying `testValue`. + * For example, a component may transform an object to a serializable string. + */ + expectedSubmitValue?: any; + + /* + * Set this if the value required to emit an input/change event is different from `testValue`. + * The value is passed to `page.keyboard.type()`. For example, input-time-picker requires + * appending AM or PM before the value commits and calciteInputTimePickerChange emits. + * + * This option is only relevant when the `validation` option is enabled. + */ + validUserInputTestValue?: string; + + /* + * Set this if emitting an input/change event requires key presses. Each array item will be passed + * to `page.keyboard.press()`. For example, the combobox value can be changed by pressing "Space" + * to open the component and "Enter" to select a value. + * + * This option is only relevant when the `validation` option is enabled. + */ + changeValueKeys?: KeyInput[]; + + /** + * Specifies the input type that will be used to capture the value. + */ + inputType?: HTMLInputElement["type"]; + + /** + * Specifies if the component supports submitting the form on Enter key press. + */ + submitsOnEnter?: boolean; + + /** + * Specifies if the component supports clearing its value (i.e., setting to null). + */ + clearable?: boolean; + + /** + * Specifies if the component supports preventing submission and displaying validation messages. + */ + validation?: boolean; +} + +/** + * Helper for testing form-associated components; specifically form submitting and resetting. + * + * Note that this helper should be used within a describe block. + * + * @param {string} componentTagOrHtml - the component tag or HTML markup to test against + * @param {FormAssociatedOptions} options - form associated options + */ +export function formAssociated( + componentTagOrHtml: TagOrHTML | TagOrHTMLWithBeforeContent, + options: FormAssociatedOptions, +): void { + const inputTypeContext = options?.inputType ? ` (input type="${options.inputType}")` : ""; + + it(`supports association via ancestry${inputTypeContext}`, () => testAncestorFormAssociated()); + it(`supports association via form ID${inputTypeContext}`, () => testIdFormAssociated()); + + if (options?.validation && !["color", "month", "time"].includes(options?.inputType)) { + it(`supports required property validation${inputTypeContext}`, () => testRequiredPropertyValidation()); + } + + async function testAncestorFormAssociated(): Promise { + const { beforeContent, tagOrHTML } = getTagOrHTMLWithBeforeContent(componentTagOrHtml); + const tag = getTag(tagOrHTML); + const componentHtml = ensureName(isHTML(tagOrHTML) ? tagOrHTML : `<${tag}>`, tag); + + const page = await newE2EPage(); + await beforeContent?.(page); + + const content = html`
+ ${componentHtml} + + +
`; + await page.setContent(content); + await page.waitForChanges(); + const component = await page.find(tag); + + await assertValueSubmissionType(page, component, options); + await assertValueResetOnFormReset(page, component, options); + await assertValueSubmittedOnFormSubmit(page, component, options); + + if (options.submitsOnEnter) { + await assertFormSubmitOnEnter(page, component, options); + } + } + + async function testIdFormAssociated(): Promise { + const { beforeContent, tagOrHTML } = getTagOrHTMLWithBeforeContent(componentTagOrHtml); + const tag = getTag(tagOrHTML); + const componentHtml = ensureForm(ensureName(isHTML(tagOrHTML) ? tagOrHTML : `<${tag}>`, tag), tag); + + const page = await newE2EPage(); + await beforeContent?.(page); + await page.setContent( + html`
+ ${componentHtml} + + `, + ); + await page.waitForChanges(); + const component = await page.find(tag); + + await assertValueSubmissionType(page, component, options); + await assertValueResetOnFormReset(page, component, options); + await assertValueSubmittedOnFormSubmit(page, component, options); + + if (options.submitsOnEnter) { + await assertFormSubmitOnEnter(page, component, options); + } + } + + async function testRequiredPropertyValidation(): Promise { + const requiredValidationMessage = "Please fill out this field."; + const { beforeContent, tagOrHTML } = getTagOrHTMLWithBeforeContent(componentTagOrHtml); + const tag = getTag(tagOrHTML); + const componentHtml = ensureUnchecked( + ensureRequired(ensureName(isHTML(tagOrHTML) ? tagOrHTML : `<${tag}>`, tag), tag), + ); + + const page = await newE2EPage(); + await beforeContent?.(page); + + const content = html` +
+ ${componentHtml} + Submit +
+ `; + + await page.setContent(content); + await page.waitForChanges(); + const component = await page.find(tag); + + const submitButton = await page.find("#submitButton"); + const spyEvent = await page.spyOnEvent(getClearValidationEventName(tag)); + + await assertPreventsFormSubmission(page, component, submitButton, requiredValidationMessage); + await assertClearsValidationOnValueChange(page, component, options, spyEvent, tag); + await assertUserMessageNotOverridden(page, component, submitButton); + } + + function ensureName(html: string, componentTag: string): string { + return html.includes("name=") ? html : html.replace(componentTag, `${componentTag} name="testName" `); + } + + function ensureRequired(html: string, componentTag: string): string { + return html.includes("required") ? html : html.replace(componentTag, `${componentTag} required `); + } + + function ensureUnchecked(html: string): string { + return html.replace(/(checked|selected)/, ""); + } + + function ensureForm(html: string, componentTag: string): string { + return html.includes("form=") ? html : html.replace(componentTag, `${componentTag} form="test-form" `); + } + + async function isCheckable(page: E2EPage, component: E2EElement, options: FormAssociatedOptions): Promise { + return ( + typeof options.testValue === "boolean" && + (await page.$eval(component.tagName.toLowerCase(), (component) => "checked" in component)) + ); + } + + function stringifyTestValue(value: any): string | string[] { + return Array.isArray(value) ? value.map((value) => value.toString()) : value.toString(); + } + + async function assertValueSubmissionType( + page: E2EPage, + component: E2EElement, + options: FormAssociatedOptions, + ): Promise { + const name = await component.getProperty("name"); + const inputType = options.inputType ?? "text"; + + const hiddenFormInputType = await page.evaluate( + async (inputName: string, hiddenFormInputSlotName: string): Promise => { + const hiddenFormInput = document.querySelector( + `[name="${inputName}"] input[slot=${hiddenFormInputSlotName}]`, + ); + + return hiddenFormInput.type; + }, + name, + hiddenFormInputSlotName, + ); + + if (await isCheckable(page, component, options)) { + expect(hiddenFormInputType).toMatch(/radio|checkbox/); + } else { + expect(hiddenFormInputType).toMatch(inputType); + } + } + + async function assertValueResetOnFormReset( + page: E2EPage, + component: E2EElement, + options: FormAssociatedOptions, + ): Promise { + const resettablePropName = (await isCheckable(page, component, options)) ? "checked" : "value"; + const initialValue = await component.getProperty(resettablePropName); + component.setProperty(resettablePropName, options.testValue); + await page.waitForChanges(); + + await page.$eval("form", (form: HTMLFormElement) => form.reset()); + await page.waitForChanges(); + + expect(await component.getProperty(resettablePropName)).toBe(initialValue); + } + + async function assertValueSubmittedOnFormSubmit( + page: E2EPage, + component: E2EElement, + options: FormAssociatedOptions, + ): Promise { + const stringifiedTestValue = stringifyTestValue(options.testValue); + const name = await component.getProperty("name"); + + if (await isCheckable(page, component, options)) { + component.setProperty("checked", true); + await page.waitForChanges(); + expect(await submitAndGetValue()).toEqual("on"); + + component.setProperty("value", options.testValue); + await page.waitForChanges(); + expect(await submitAndGetValue()).toEqual(stringifiedTestValue); + + component.setProperty("disabled", true); + await page.waitForChanges(); + expect(await submitAndGetValue()).toBe(null); + + component.setProperty("checked", true); + component.setProperty("disabled", false); + await page.waitForChanges(); + expect(await submitAndGetValue()).toEqual(stringifiedTestValue); + + component.setProperty("checked", false); + await page.waitForChanges(); + expect(await submitAndGetValue()).toBe(null); + } else { + if (options.clearable) { + component.setProperty("required", true); + component.setProperty("value", null); + await page.waitForChanges(); + expect(await submitAndGetValue()).toBe( + options.inputType === "color" + ? // `input[type="color"]` will set its value to #000000 when set to an invalid value + // see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/color#value + "#000" + : undefined, + ); + + component.setProperty("required", false); + component.setProperty("value", options.testValue); + await page.waitForChanges(); + expect(await submitAndGetValue()).toEqual(options?.expectedSubmitValue || stringifiedTestValue); + } + + component.setProperty("disabled", true); + await page.waitForChanges(); + expect(await submitAndGetValue()).toBe(null); + + component.setProperty("disabled", false); + component.setProperty("value", options.testValue); + await page.waitForChanges(); + expect(await submitAndGetValue()).toEqual(options?.expectedSubmitValue || stringifiedTestValue); + + component.setProperty("value", options.testValue); + await page.waitForChanges(); + expect(await submitAndGetValue()).toEqual(options?.expectedSubmitValue || stringifiedTestValue); + } + + type SubmitValueResult = ReturnType | ReturnType; + + /** + * This method will submit the form and return the submitted value: + * + * For single-value components, it will return a string or null if the value was not submitted + * For multi-value components, it will return an array of strings + * + * If the input cannot be submitted because it is invalid, undefined will be returned + */ + async function submitAndGetValue(): Promise { + return page.$eval( + "form", + async ( + form: HTMLFormElement, + inputName: string, + hiddenFormInputSlotName: string, + ): Promise => { + const hiddenFormInput = document.querySelector( + `[name="${inputName}"] input[slot=${hiddenFormInputSlotName}]`, + ); + + let resolve: (value: SubmitValueResult) => void; + const submitPromise = new Promise((yes) => (resolve = yes)); + + function handleFormSubmit(event: Event): void { + event.preventDefault(); + const formData = new FormData(form); + const values = formData.getAll(inputName); + + if (values.length > 1) { + resolve(values as string[]); + return; + } + + resolve(formData.get(inputName)); + hiddenFormInput.removeEventListener("invalid", handleInvalidInput); + } + + function handleInvalidInput(): void { + resolve(undefined); + form.removeEventListener("submit", handleFormSubmit); + } + + form.addEventListener("submit", handleFormSubmit, { once: true }); + hiddenFormInput.addEventListener("invalid", handleInvalidInput, { once: true }); + + document.querySelector("#submitter").click(); + + return submitPromise; + }, + name, + hiddenFormInputSlotName, + ); + } + } + + async function assertFormSubmitOnEnter( + page: E2EPage, + component: E2EElement, + options: FormAssociatedOptions, + ): Promise { + type TestWindow = GlobalTestProps<{ + called: boolean; + }>; + + await page.$eval("form", (form: HTMLFormElement) => { + form.addEventListener("submit", (event) => { + event.preventDefault(); + (window as TestWindow).called = true; + }); + }); + + const stringifiedTestValue = stringifyTestValue(options.testValue); + + component.setProperty("value", stringifiedTestValue); + await component.callMethod("setFocus"); + await page.keyboard.press("Enter"); + const called = await page.evaluate(() => (window as TestWindow).called); + + expect(called).toBe(true); + } + + async function assertPreventsFormSubmission( + page: E2EPage, + component: E2EElement, + submitButton: E2EElement, + message: string, + ) { + await submitButton.click(); + await page.waitForChanges(); + + await expectValidationInvalid(component, message); + } + + async function assertClearsValidationOnValueChange( + page: E2EPage, + component: E2EElement, + options: FormAssociatedOptions, + event: EventSpy, + tag: string, + ) { + if (options?.changeValueKeys) { + for (const key of options.changeValueKeys) { + await page.keyboard.press(key); + } + } else { + await page.keyboard.type(options?.validUserInputTestValue ?? options.testValue); + await page.keyboard.press("Tab"); + } + + await page.waitForChanges(); + + // components with an Input event will emit multiple times depending on the length of testValue + if (componentsWithInputEvent.includes(tag)) { + expect(event.length).toBeGreaterThanOrEqual(1); + } else { + expect(event).toHaveReceivedEventTimes(1); + } + + await expectValidationIdle(component); + } + + async function assertUserMessageNotOverridden(page: E2EPage, component: E2EElement, submitButton: E2EElement) { + const customValidationMessage = "This is a custom message."; + const customValidationIcon = "banana"; + + // don't override custom validation message and icon + component.setProperty("validationMessage", customValidationMessage); + component.setProperty("validationIcon", customValidationIcon); + component.setProperty("value", undefined); + await page.waitForChanges(); + + await submitButton.click(); + await page.waitForChanges(); + + await expectValidationInvalid(component, customValidationMessage, customValidationIcon); + } + + async function expectValidationIdle(element: E2EElement) { + expect(await element.getProperty("status")).toBe("idle"); + expect(await element.getProperty("validationMessage")).toBe(""); + expect(await element.getProperty("validationIcon")).toBe(false); + } + + async function expectValidationInvalid(element: E2EElement, message: string, icon: string = "") { + expect(await element.getProperty("status")).toBe("invalid"); + expect(await element.getProperty("validationMessage")).toBe(message); + expect(element.getAttribute("validation-icon")).toBe(icon); + } +} diff --git a/packages/calcite-components/src/tests/commonTests/hidden.ts b/packages/calcite-components/src/tests/commonTests/hidden.ts new file mode 100644 index 00000000000..10c1b6852ca --- /dev/null +++ b/packages/calcite-components/src/tests/commonTests/hidden.ts @@ -0,0 +1,30 @@ +import { toHaveNoViolations } from "jest-axe"; +import { getTagAndPage } from "./utils"; +import { ComponentTestSetup } from "./interfaces"; + +expect.extend(toHaveNoViolations); + +/** + * Helper for asserting that a component is not visible when hidden + * + * Note that this helper should be used within a describe block. + * + * @example + * @param componentTestSetup + * describe("honors hidden attribute", () => { + * hidden("calcite-accordion") + * }); + * + * @param {string} componentTagOrHTML - the component tag or HTML markup to test against + */ +export async function hidden(componentTestSetup: ComponentTestSetup): Promise { + it("is hidden", async () => { + const { page, tag } = await getTagAndPage(componentTestSetup); + const element = await page.find(tag); + + element.setAttribute("hidden", ""); + await page.waitForChanges(); + + expect(await element.isVisible()).toBe(false); + }); +} diff --git a/packages/calcite-components/src/tests/commonTests/index.ts b/packages/calcite-components/src/tests/commonTests/index.ts new file mode 100644 index 00000000000..3b74dcc2257 --- /dev/null +++ b/packages/calcite-components/src/tests/commonTests/index.ts @@ -0,0 +1,14 @@ +export { accessible } from "./accessible"; +export { defaults } from "./defaults"; +export { openClose } from "./openClose"; +export { reflects } from "./reflects"; +export { renders } from "./renders"; +export { disabled } from "./disabled"; +export { hidden } from "./hidden"; +export { floatingUIOwner, delegatesToFloatingUiOwningComponent } from "./floatingUI"; +export { focusable } from "./focusable"; +export { formAssociated } from "./formAssociated"; +export { slots } from "./slots"; +export { labelable } from "./labelable"; +export { t9n } from "./t9n"; +export { HYDRATED_ATTR } from "./utils"; diff --git a/packages/calcite-components/src/tests/commonTests/interfaces.ts b/packages/calcite-components/src/tests/commonTests/interfaces.ts new file mode 100644 index 00000000000..9c1f7e709c2 --- /dev/null +++ b/packages/calcite-components/src/tests/commonTests/interfaces.ts @@ -0,0 +1,50 @@ +import { E2EPage } from "@stencil/core/testing"; +import type { JSX } from "../../components"; + +export type ComponentTag = keyof JSX.IntrinsicElements; +export type ComponentHTML = string; +export type TagOrHTML = ComponentTag | ComponentHTML; +export type BeforeContent = (page: E2EPage) => Promise; + +export type TagAndPage = { + tag: ComponentTag; + page: E2EPage; +}; + +export type TagOrHTMLWithBeforeContent = { + tagOrHTML: TagOrHTML; + + /** + * Allows for custom setup of the page. + * + * This is useful for test helpers that need to create and configure the test page before running tests. + * + * @param page + */ + beforeContent: BeforeContent; +}; + +export type ComponentTestContent = TagOrHTML | TagAndPage; +export type ComponentTestSetupProvider = (() => ComponentTestContent) | (() => Promise); +export type ComponentTestSetup = ComponentTestContent | ComponentTestSetupProvider; + +interface TabAndClickTargets { + tab: string; + click: string; +} + +export type FocusTarget = "host" | "child" | "none"; + +export interface DisabledOptions { + /** + * Use this to specify whether the test should cover focusing. + */ + focusTarget?: FocusTarget | TabAndClickTargets; + + /** + * Use this to specify the main wrapped component in shadow DOM that handles disabling interaction. + * + * Note: this should only be used for components that wrap a single component that implements disabled behavior. + */ + shadowAriaAttributeTargetSelector?: string; +} diff --git a/packages/calcite-components/src/tests/commonTests/labelable.ts b/packages/calcite-components/src/tests/commonTests/labelable.ts new file mode 100644 index 00000000000..c425b0dcd86 --- /dev/null +++ b/packages/calcite-components/src/tests/commonTests/labelable.ts @@ -0,0 +1,291 @@ +import { E2EPage, newE2EPage } from "@stencil/core/testing"; +import { toHaveNoViolations } from "jest-axe"; +import { html } from "../../../support/formatting"; +import { isElementFocused } from "./../utils"; +import { isHTML, getTag, getTagOrHTMLWithBeforeContent } from "./utils"; +export { TagOrHTMLWithBeforeContent } from "./interfaces"; +import { FocusableOptions } from "./focusable"; +import { TagOrHTMLWithBeforeContent, TagOrHTML } from "./interfaces"; + +expect.extend(toHaveNoViolations); + +export async function assertLabelable({ + page, + componentTag, + propertyToToggle, + focusTargetSelector = componentTag, + shadowFocusTargetSelector, +}: { + page: E2EPage; + componentTag: string; + propertyToToggle?: string; + focusTargetSelector?: string; + shadowFocusTargetSelector?: string; +}): Promise { + let initialPropertyValue: boolean; + const component = await page.find(componentTag); + + if (propertyToToggle) { + initialPropertyValue = await component.getProperty(propertyToToggle); + } + + const label = await page.find("calcite-label"); + await label.callMethod("click"); // we call the method to avoid clicking the child element + await page.waitForChanges(); + + expect( + await page.evaluate( + (focusTargetSelector: string): boolean => !!document.activeElement?.closest(focusTargetSelector), + focusTargetSelector, + ), + ).toBe(true); + + if (shadowFocusTargetSelector) { + expect( + await page.$eval( + componentTag, + (element: HTMLElement, selector: string) => element.shadowRoot.activeElement.matches(selector), + shadowFocusTargetSelector, + ), + ).toBe(true); + } + + if (propertyToToggle) { + const toggledPropertyValue = !initialPropertyValue; + expect(await component.getProperty(propertyToToggle)).toBe(toggledPropertyValue); + + // assert that direct clicks on component toggle property correctly + component.setProperty(propertyToToggle, initialPropertyValue); // we reset as not all components toggle when clicked + await page.waitForChanges(); + await component.click(); + await page.waitForChanges(); + expect(await component.getProperty(propertyToToggle)).toBe(toggledPropertyValue); + } + + // assert clicking on labelable keeps focus + await component.callMethod("click"); + await page.waitForChanges(); + + expect(await isElementFocused(page, focusTargetSelector)).toBe(true); +} + +export interface LabelableOptions extends Pick { + /** + * If clicking on a label toggles the labelable component, use this prop to specify the name of the toggled prop. + */ + propertyToToggle?: string; +} + +/** + * Helper for asserting label clicking functionality works. + * + * Note that this helper should be used within a describe block. + * + * @example + * describe("labelable", () => { + * async () => labelable("calcite-button") + * }) + * + * @param {string} componentTagOrHtml - The component tag or HTML used to test label support. + * @param {LabelableOptions} [options] - Labelable options. + */ +export function labelable( + componentTagOrHtml: TagOrHTML | TagOrHTMLWithBeforeContent, + options?: LabelableOptions, +): void { + const id = "labelable-id"; + const labelTitle = "My Component"; + const propertyToToggle = options?.propertyToToggle; + const focusTargetSelector = options?.focusTargetSelector || `#${id}`; + const shadowFocusTargetSelector = options?.shadowFocusTargetSelector; + const { beforeContent, tagOrHTML } = getTagOrHTMLWithBeforeContent(componentTagOrHtml); + const componentTag = getTag(tagOrHTML); + const componentHtml = isHTML(tagOrHTML) ? ensureId(tagOrHTML) : `<${componentTag} id="${id}">`; + + function ensureId(html: string): string { + return html.includes("id=") ? html : html.replace(componentTag, `${componentTag} id="${id}" `); + } + + describe("label wraps labelables", () => { + it("is labelable when component is wrapped in a label", async () => { + const wrappedHtml = html`${labelTitle} ${componentHtml}`; + const wrappedPage: E2EPage = await newE2EPage(); + beforeContent?.(wrappedPage); + await wrappedPage.setContent(wrappedHtml); + await wrappedPage.waitForChanges(); + + await assertLabelable({ + page: wrappedPage, + componentTag, + propertyToToggle, + focusTargetSelector, + shadowFocusTargetSelector, + }); + }); + + it("is labelable when wrapping label is set prior to component", async () => { + const labelFirstWrappedPage: E2EPage = await newE2EPage(); + beforeContent?.(labelFirstWrappedPage); + await labelFirstWrappedPage.setContent(html` + + + `); + await labelFirstWrappedPage.waitForChanges(); + await labelFirstWrappedPage.evaluate(() => { + const template = document.querySelector("template"); + const labelEl = document.querySelector("calcite-label"); + + labelEl.append(template.content.cloneNode(true)); + }, componentHtml); + await labelFirstWrappedPage.waitForChanges(); + + await assertLabelable({ + page: labelFirstWrappedPage, + componentTag, + propertyToToggle, + focusTargetSelector, + shadowFocusTargetSelector, + }); + }); + + it("is labelable when a component is set first before being wrapped in a label", async () => { + const componentFirstWrappedPage: E2EPage = await newE2EPage(); + beforeContent?.(componentFirstWrappedPage); + await componentFirstWrappedPage.setContent(componentHtml); + await componentFirstWrappedPage.waitForChanges(); + await componentFirstWrappedPage.evaluate((id: string) => { + const componentEl = document.querySelector(`[id='${id}']`); + const labelEl = document.createElement("calcite-label"); + document.body.append(labelEl); + labelEl.append(componentEl); + }, id); + await componentFirstWrappedPage.waitForChanges(); + + await assertLabelable({ + page: componentFirstWrappedPage, + componentTag, + propertyToToggle, + focusTargetSelector, + shadowFocusTargetSelector, + }); + }); + + it("only sets focus on the first labelable when label is clicked", async () => { + const firstLabelableId = `${id}`; + const componentFirstWrappedPage: E2EPage = await newE2EPage(); + beforeContent?.(componentFirstWrappedPage); + const content = html` + + + ${componentHtml.replace(id, firstLabelableId)} ${componentHtml.replace(id, `${id}-2`)} + ${componentHtml.replace(id, `${id}-3`)} + + `; + + await componentFirstWrappedPage.setContent(content); + await componentFirstWrappedPage.waitForChanges(); + + const firstLabelableSelector = `#${firstLabelableId}`; + + await assertLabelable({ + page: componentFirstWrappedPage, + componentTag, + propertyToToggle, + focusTargetSelector: + focusTargetSelector === firstLabelableSelector + ? firstLabelableSelector + : `${firstLabelableSelector} ${focusTargetSelector}`, + }); + }); + }); + + describe("label is sibling to labelables", () => { + it("is labelable with label set as a sibling to the component", async () => { + const siblingHtml = html` + ${labelTitle} + ${componentHtml} + `; + const siblingPage: E2EPage = await newE2EPage(); + beforeContent?.(siblingPage); + + await siblingPage.setContent(siblingHtml); + await siblingPage.waitForChanges(); + + await assertLabelable({ + page: siblingPage, + componentTag, + propertyToToggle, + focusTargetSelector, + shadowFocusTargetSelector, + }); + }); + + it("is labelable when sibling label is set prior to component", async () => { + const labelFirstSiblingPage: E2EPage = await newE2EPage(); + beforeContent?.(labelFirstSiblingPage); + await labelFirstSiblingPage.setContent(html` + + + `); + await labelFirstSiblingPage.waitForChanges(); + await labelFirstSiblingPage.evaluate(() => { + const template = document.querySelector("template"); + document.body.append(template.content.cloneNode(true)); + }, componentHtml); + await labelFirstSiblingPage.waitForChanges(); + + await assertLabelable({ + page: labelFirstSiblingPage, + componentTag, + propertyToToggle, + focusTargetSelector, + shadowFocusTargetSelector, + }); + }); + + it("is labelable for a component set before sibling label", async () => { + const componentFirstSiblingPage: E2EPage = await newE2EPage(); + beforeContent?.(componentFirstSiblingPage); + await componentFirstSiblingPage.setContent(componentHtml); + await componentFirstSiblingPage.waitForChanges(); + await componentFirstSiblingPage.evaluate((id: string) => { + const label = document.createElement("calcite-label"); + label.setAttribute("for", `${id}`); + document.body.append(label); + }, id); + await componentFirstSiblingPage.waitForChanges(); + + await assertLabelable({ + page: componentFirstSiblingPage, + componentTag, + propertyToToggle, + focusTargetSelector, + shadowFocusTargetSelector, + }); + }); + + it("is labelable when label's for is set after initialization", async () => { + const siblingHtml = html` + ${labelTitle} + ${componentHtml} + `; + const siblingPage: E2EPage = await newE2EPage(); + beforeContent?.(siblingPage); + + await siblingPage.setContent(siblingHtml); + await siblingPage.waitForChanges(); + + const label = await siblingPage.find("calcite-label"); + label.setProperty("for", id); + await siblingPage.waitForChanges(); + + await assertLabelable({ + page: siblingPage, + componentTag, + propertyToToggle, + focusTargetSelector, + shadowFocusTargetSelector, + }); + }); + }); +} diff --git a/packages/calcite-components/src/tests/commonTests/openClose.ts b/packages/calcite-components/src/tests/commonTests/openClose.ts new file mode 100644 index 00000000000..3d4c470ad1a --- /dev/null +++ b/packages/calcite-components/src/tests/commonTests/openClose.ts @@ -0,0 +1,207 @@ +import { E2EPage } from "@stencil/core/testing"; +import { toHaveNoViolations } from "jest-axe"; +import { GlobalTestProps, newProgrammaticE2EPage, skipAnimations } from "../utils"; +import { getTag, simplePageSetup } from "./utils"; +import { TagOrHTML } from "./interfaces"; + +expect.extend(toHaveNoViolations); + +interface BeforeToggle { + /** + * Function argument to simulate user input (mouse or keyboard), to open the component. + */ + open: (page: E2EPage) => Promise; + + /** + * Function argument to simulate user input (mouse or keyboard), to close the component. + */ + close: (page: E2EPage) => Promise; +} + +interface OpenCloseOptions { + /** + * Toggle property to test. Currently, either "open" or "expanded". + */ + openPropName?: string; + + /** + * Indicates the initial value of the toggle property. + */ + initialToggleValue?: boolean; + + /** + * Optional argument with functions to simulate user input (mouse or keyboard), to open or close the component. + */ + beforeToggle?: BeforeToggle; +} + +/** + * Helper to test openClose component setup. + * + * Note that this helper should be used within a `describe` block. + * + * @example + * + * describe("openClose", () => { + * openClose("calcite-combobox opened with a tab", { + * beforeToggle: { + * open: async (page) => { + * await page.keyboard.press("Tab"); + * await page.waitForChanges(); + * }, + * close: async (page) => { + * await page.keyboard.press("Tab"); + * await page.waitForChanges(); + * }, + * } + * }); + * + * openClose("open calcite-combobox closed with a tab", { + * initialToggleValue: true, + * beforeToggle: { + * close: async (page) => { + * await page.keyboard.press("Tab"); + * await page.waitForChanges(); + * }, + * } + * } + * }) + * + * @param componentTagOrHTML - The component tag or HTML markup to test against. + * @param {object} [options] - Additional options to assert. + */ + +export function openClose(componentTagOrHTML: TagOrHTML, options?: OpenCloseOptions): void { + const defaultOptions: OpenCloseOptions = { + initialToggleValue: false, + openPropName: "open", + }; + const customizedOptions = { ...defaultOptions, ...options }; + + type EventOrderWindow = GlobalTestProps<{ events: string[] }>; + const eventSequence = setUpEventSequence(componentTagOrHTML); + + function setUpEventSequence(componentTagOrHTML: TagOrHTML): string[] { + const tag = getTag(componentTagOrHTML); + + const camelCaseTag = tag.replace(/-([a-z])/g, (lettersAfterHyphen) => lettersAfterHyphen[1].toUpperCase()); + const eventSuffixes = [`BeforeOpen`, `Open`, `BeforeClose`, `Close`]; + + return eventSuffixes.map((suffix) => `${camelCaseTag}${suffix}`); + } + + async function setUpPage(componentTagOrHTML: TagOrHTML, page: E2EPage): Promise { + await page.evaluate( + (eventSequence: string[], initialToggleValue: boolean, openPropName: string, componentTagOrHTML: string) => { + const receivedEvents: string[] = []; + + (window as EventOrderWindow).events = receivedEvents; + + eventSequence.forEach((eventType) => { + document.addEventListener(eventType, (event) => receivedEvents.push(event.type)); + }); + + if (!initialToggleValue) { + return; + } + + const component = document.createElement(componentTagOrHTML); + component[openPropName] = true; + + document.body.append(component); + }, + eventSequence, + customizedOptions.initialToggleValue, + customizedOptions.openPropName, + componentTagOrHTML, + ); + } + + async function testOpenCloseEvents(componentTagOrHTML: TagOrHTML, page: E2EPage): Promise { + const tag = getTag(componentTagOrHTML); + const element = await page.find(tag); + + const [beforeOpenEvent, openEvent, beforeCloseEvent, closeEvent] = eventSequence.map((event) => + page.waitForEvent(event), + ); + + const [beforeOpenSpy, openSpy, beforeCloseSpy, closeSpy] = await Promise.all( + eventSequence.map(async (event) => await element.spyOnEvent(event)), + ); + + await page.waitForChanges(); + + if (customizedOptions.beforeToggle) { + await customizedOptions.beforeToggle.open(page); + } else { + element.setProperty(customizedOptions.openPropName, true); + } + + await page.waitForChanges(); + + await beforeOpenEvent; + await openEvent; + + expect(beforeOpenSpy).toHaveReceivedEventTimes(1); + expect(openSpy).toHaveReceivedEventTimes(1); + expect(beforeCloseSpy).toHaveReceivedEventTimes(0); + expect(closeSpy).toHaveReceivedEventTimes(0); + + if (customizedOptions.beforeToggle) { + await customizedOptions.beforeToggle.close(page); + } else { + element.setProperty(customizedOptions.openPropName, false); + } + + await page.waitForChanges(); + + await beforeCloseEvent; + await closeEvent; + + expect(beforeCloseSpy).toHaveReceivedEventTimes(1); + expect(closeSpy).toHaveReceivedEventTimes(1); + expect(beforeOpenSpy).toHaveReceivedEventTimes(1); + expect(openSpy).toHaveReceivedEventTimes(1); + + expect(await page.evaluate(() => (window as EventOrderWindow).events)).toEqual(eventSequence); + } + + /** + * `skipAnimations` utility sets the animation duration to 0.01. This is a workaround for an issue with the animation utility. + * Because this still leaves a very small duration, we can still test the animation events, but faster. + */ + + if (customizedOptions.initialToggleValue === true) { + it("emits on initialization with animations enabled", async () => { + const page = await newProgrammaticE2EPage(); + await skipAnimations(page); + await setUpPage(componentTagOrHTML, page); + await testOpenCloseEvents(componentTagOrHTML, page); + }); + + it("emits on initialization with animations disabled", async () => { + const page = await newProgrammaticE2EPage(); + await page.addStyleTag({ + content: `:root { --calcite-duration-factor: 0; }`, + }); + await setUpPage(componentTagOrHTML, page); + await testOpenCloseEvents(componentTagOrHTML, page); + }); + } else { + it(`emits with animations enabled`, async () => { + const page = await simplePageSetup(componentTagOrHTML); + await skipAnimations(page); + await setUpPage(componentTagOrHTML, page); + await testOpenCloseEvents(componentTagOrHTML, page); + }); + + it(`emits with animations disabled`, async () => { + const page = await simplePageSetup(componentTagOrHTML); + await page.addStyleTag({ + content: `:root { --calcite-duration-factor: 0; }`, + }); + await setUpPage(componentTagOrHTML, page); + await testOpenCloseEvents(componentTagOrHTML, page); + }); + } +} diff --git a/packages/calcite-components/src/tests/commonTests/reflects.ts b/packages/calcite-components/src/tests/commonTests/reflects.ts new file mode 100644 index 00000000000..2cdca4897a8 --- /dev/null +++ b/packages/calcite-components/src/tests/commonTests/reflects.ts @@ -0,0 +1,71 @@ +import { toHaveNoViolations } from "jest-axe"; +import { skipAnimations } from "./../utils"; +import { getTagAndPage, propToAttr } from "./utils"; +import { ComponentTestSetup } from "./interfaces"; + +expect.extend(toHaveNoViolations); + +/** + * + * Helper for asserting that a component reflects + * + * Note that this helper should be used within a describe block. + * + * @example + * describe("reflects", () => { + * reflects("calcite-action-bar", [ + * { + * propertyName: "expandDisabled", + * value: true + * }, + * { + * propertyName: "expanded", + * value: true + * } + * ]) + * }) + * + * @param {string} componentTagOrHTML - the component tag or HTML markup to test against + * @param componentTestSetup + * @param {object[]} propsToTest - the properties to test + * @param {string} propsToTest.propertyName - the property name + * @param {any} propsToTest.value - the property value (if boolean, needs to be `true` to ensure reflection) + */ +export function reflects( + componentTestSetup: ComponentTestSetup, + propsToTest: { + propertyName: string; + value: any; + }[], +): void { + const cases = propsToTest.map(({ propertyName, value }) => [propertyName, value]); + + it.each(cases)("%p", async (propertyName, value) => { + const { page, tag: componentTag } = await getTagAndPage(componentTestSetup); + await skipAnimations(page); + const element = await page.find(componentTag); + + const attrName = propToAttr(propertyName); + const componentAttributeSelector = `${componentTag}[${attrName}]`; + + element.setProperty(propertyName, value); + await page.waitForChanges(); + + expect(await page.find(componentAttributeSelector)).toBeTruthy(); + + if (typeof value === "boolean") { + const getExpectedValue = (propValue: boolean): string | null => (propValue ? "" : null); + const negated = !value; + + element.setProperty(propertyName, negated); + await page.waitForChanges(); + + expect(element.getAttribute(attrName)).toBe(getExpectedValue(negated)); + + element.setProperty(propertyName, value); + await page.waitForChanges(); + + expect(element.getAttribute(attrName)).toBe(getExpectedValue(value)); + } + }); +} diff --git a/packages/calcite-components/src/tests/commonTests/renders.ts b/packages/calcite-components/src/tests/commonTests/renders.ts new file mode 100644 index 00000000000..4fde37a623a --- /dev/null +++ b/packages/calcite-components/src/tests/commonTests/renders.ts @@ -0,0 +1,36 @@ +import { toHaveNoViolations } from "jest-axe"; +import { getTagAndPage, HYDRATED_ATTR } from "./utils"; +import { ComponentTestSetup } from "./interfaces"; + +expect.extend(toHaveNoViolations); + +/** + * Note that this helper should be used within a describe block. + * + * @example + * describe("renders", () => { + * renders(``); + * }); + * @param componentTestSetup + * + * @param {string} componentTagOrHTML - the component tag or HTML markup to test against + * @param {object} options - additional options to assert + * @param {string} options.visible - is the component visible + * @param {string} options.display - is the component's display "inline" + */ +export async function renders( + componentTestSetup: ComponentTestSetup, + options?: { + visible?: boolean; + display: string; + }, +): Promise { + it(`renders`, async () => { + const { page, tag } = await getTagAndPage(componentTestSetup); + const element = await page.find(tag); + + expect(element).toHaveAttribute(HYDRATED_ATTR); + expect(await element.isVisible()).toBe(options?.visible ?? true); + expect((await element.getComputedStyle()).display).toBe(options?.display ?? "inline"); + }); +} diff --git a/packages/calcite-components/src/tests/commonTests/slots.ts b/packages/calcite-components/src/tests/commonTests/slots.ts new file mode 100644 index 00000000000..012d795f995 --- /dev/null +++ b/packages/calcite-components/src/tests/commonTests/slots.ts @@ -0,0 +1,77 @@ +import { toHaveNoViolations } from "jest-axe"; +import { getTag, simplePageSetup } from "./utils"; +import { TagOrHTML } from "./interfaces"; + +expect.extend(toHaveNoViolations); + +/** + * Helper for asserting slots. + * + * Note that this helper should be used within a describe block. + * + * @example + * describe("slots", () => { + * slots("calcite-stack", SLOTS) + * }) + * + * @param {string} componentTagOrHTML - The component tag or HTML markup to test against. + * @param {Record | string[]} slots - A component's SLOTS resource object or an array of slot names. + * @param {boolean} includeDefaultSlot - When true, it will run assertions on the default slot. + */ +export function slots( + componentTagOrHTML: TagOrHTML, + slots: Record | string[], + includeDefaultSlot = false, +): void { + it("has slots", async () => { + const page = await simplePageSetup(componentTagOrHTML); + const tag = getTag(componentTagOrHTML); + const slotNames = Array.isArray(slots) ? slots : Object.values(slots); + + await page.$eval( + tag, + async (component, slotNames: string[], includeDefaultSlot?: boolean) => { + async function slotTestElement(testClass: string, slotName?: string): Promise { + const el = document.createElement("div"); // slotting a
will suffice for our purposes + el.classList.add(testClass); + + if (slotName) { + el.slot = slotName; + } + + component.append(el); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + } + + for (let i = 0; i < slotNames.length; i++) { + await slotTestElement("slotted-into-named-slot", slotNames[i]); + } + + if (includeDefaultSlot) { + await slotTestElement("slotted-into-default-slot"); + } + }, + slotNames, + includeDefaultSlot, + ); + + await page.waitForChanges(); + + const slotted = await page.evaluate(() => + Array.from(document.querySelectorAll(".slotted-into-named-slot")) + .filter((slotted) => slotted.assignedSlot) + .map((slotted) => slotted.slot), + ); + + expect(slotNames).toEqual(slotted); + + if (includeDefaultSlot) { + const hasDefaultSlotted = await page.evaluate(() => { + const defaultSlotted = document.querySelector(".slotted-into-default-slot"); + return defaultSlotted.assignedSlot?.name === "" && defaultSlotted.slot === ""; + }); + + expect(hasDefaultSlotted).toBe(true); + } + }); +} diff --git a/packages/calcite-components/src/tests/commonTests/t9n.ts b/packages/calcite-components/src/tests/commonTests/t9n.ts new file mode 100644 index 00000000000..f12196de426 --- /dev/null +++ b/packages/calcite-components/src/tests/commonTests/t9n.ts @@ -0,0 +1,101 @@ +import { E2EElement, E2EPage } from "@stencil/core/testing"; +import { toHaveNoViolations } from "jest-axe"; +import { MessageBundle } from "../../utils/t9n"; +import { IntrinsicElementsWithProp } from "./../utils"; +import { getTagAndPage } from "./utils"; +import { ComponentTestSetup } from "./interfaces"; + +expect.extend(toHaveNoViolations); + +/** + * Helper to test t9n component setup. + * + * Note that this helper should be used within a describe block. + * + * @example + * describe("translation support", () => { + * t9n("calcite-action"); + * }); + * + * @param {ComponentTestSetup} componentTestSetup - A component tag, html, or the tag and e2e page for setting up a test. + */ + +export async function t9n(componentTestSetup: ComponentTestSetup): Promise { + let component: E2EElement; + let page: E2EPage; + let getCurrentMessages: () => Promise; + + beforeEach(async () => { + const { page: e2ePage, tag } = await getTagAndPage(componentTestSetup); + page = e2ePage; + + type CalciteComponentsWithMessages = IntrinsicElementsWithProp<"messages"> & HTMLElement; + + component = await page.find(tag); + getCurrentMessages = async (): Promise => { + return page.$eval(tag, (component: CalciteComponentsWithMessages) => component.messages); + }; + }); + + it("has defined default messages", async () => await assertDefaultMessages()); + it("overrides messages", async () => await assertOverrides()); + it("switches messages", async () => await assertLangSwitch()); + + async function assertDefaultMessages(): Promise { + expect(await getCurrentMessages()).toBeDefined(); + } + + async function assertOverrides(): Promise { + const messages = await getCurrentMessages(); + const firstMessageProp = Object.keys(messages)[0]; + const messageOverride = { [firstMessageProp]: "override test" }; + + component.setProperty("messageOverrides", messageOverride); + await page.waitForChanges(); + + expect(await getCurrentMessages()).toEqual({ + ...messages, + ...messageOverride, + }); + + // reset test changes + component.setProperty("messageOverrides", undefined); + await page.waitForChanges(); + } + + async function assertLangSwitch(): Promise { + const enMessages = await getCurrentMessages(); + const fakeBundleIdentifier = "__fake__"; + await page.evaluate( + (enMessages, fakeBundleIdentifier) => { + const orig = window.fetch; + window.fetch = async function (input, init) { + if (typeof input === "string" && input.endsWith("messages_es.json")) { + const fakeEsMessages = { + ...enMessages, // reuse real message bundle in case component rendering depends on strings + + [fakeBundleIdentifier]: true, // we inject a fake identifier for assertion-purposes + }; + window.fetch = orig; + return new Response(new Blob([JSON.stringify(fakeEsMessages, null, 2)], { type: "application/json" })); + } + + return orig.call(input, init); + }; + }, + enMessages, + fakeBundleIdentifier, + ); + + component.setAttribute("lang", "es"); + await page.waitForChanges(); + await page.waitForTimeout(3000); + const esMessages = await getCurrentMessages(); + + expect(esMessages).toHaveProperty(fakeBundleIdentifier); + + // reset test changes + component.removeAttribute("lang"); + await page.waitForChanges(); + } +} diff --git a/packages/calcite-components/src/tests/commonTests/utils.ts b/packages/calcite-components/src/tests/commonTests/utils.ts new file mode 100644 index 00000000000..679418eac61 --- /dev/null +++ b/packages/calcite-components/src/tests/commonTests/utils.ts @@ -0,0 +1,76 @@ +import { E2EPage, newE2EPage } from "@stencil/core/testing"; +import { toHaveNoViolations } from "jest-axe"; +import { config } from "../../../stencil.config"; +import type { + ComponentTag, + TagOrHTML, + ComponentTestSetup, + TagAndPage, + TagOrHTMLWithBeforeContent, + BeforeContent, +} from "./interfaces"; +expect.extend(toHaveNoViolations); + +export const HYDRATED_ATTR = config.hydratedFlag?.name; + +export function isHTML(tagOrHTML: string): boolean { + return tagOrHTML.trim().startsWith("<"); +} + +export function getTag(tagOrHTML: string): ComponentTag { + if (isHTML(tagOrHTML)) { + const calciteTagRegex = / { + const componentTag = getTag(componentTagOrHTML); + const page = await newE2EPage({ + html: isHTML(componentTagOrHTML) ? componentTagOrHTML : `<${componentTag}>`, + failOnConsoleError: true, + }); + await page.waitForChanges(); + + return page; +} + +export async function getTagAndPage(componentTestSetup: ComponentTestSetup): Promise { + if (typeof componentTestSetup === "function") { + componentTestSetup = await componentTestSetup(); + } + + if (typeof componentTestSetup === "string") { + const page = await simplePageSetup(componentTestSetup); + const tag = getTag(componentTestSetup); + + return { page, tag }; + } + + return componentTestSetup; +} + +export function getTagOrHTMLWithBeforeContent(componentTestSetup: TagOrHTML | TagOrHTMLWithBeforeContent): { + tagOrHTML: TagOrHTML; + beforeContent?: BeforeContent; +} { + if (typeof componentTestSetup === "string") { + return { tagOrHTML: componentTestSetup }; + } + + return { + tagOrHTML: componentTestSetup.tagOrHTML, + beforeContent: componentTestSetup.beforeContent, + }; +} + +export function propToAttr(name: string): string { + return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); +}