diff --git a/packages/dom/src/lib/ElementAssertion.ts b/packages/dom/src/lib/ElementAssertion.ts index 3b9bdc66..38899623 100644 --- a/packages/dom/src/lib/ElementAssertion.ts +++ b/packages/dom/src/lib/ElementAssertion.ts @@ -1,7 +1,7 @@ import { Assertion, AssertionError } from "@assertive-ts/core"; import equal from "fast-deep-equal"; -import { getExpectedAndReceivedStyles } from "./helpers/helpers"; +import { getExpectedAndReceivedStyles, getAccessibleDescription } from "./helpers/helpers"; export class ElementAssertion extends Assertion { @@ -260,6 +260,71 @@ export class ElementAssertion extends Assertion { }); } + /** + * Asserts that the element has an accessible description. + * + * The accessible description is computed from the `aria-describedby` + * attribute, which references one or more elements by ID. The text + * content of those elements is combined to form the description. + * + * @example + * ``` + * // Check if element has any description + * expect(element).toHaveDescription(); + * + * // Check if element has specific description text + * expect(element).toHaveDescription('Expected description text'); + * + * // Check if element description matches a regex pattern + * expect(element).toHaveDescription(/description pattern/i); + * ``` + * + * @param expectedDescription + * - Optional expected description (string or RegExp). + * @returns the assertion instance. + */ + + public toHaveDescription(expectedDescription?: string | RegExp): this { + const description = getAccessibleDescription(this.actual); + const hasExpectedValue = expectedDescription !== undefined; + + const matchesExpectation = (desc: string): boolean => { + if (!hasExpectedValue) { + return Boolean(desc); + } + return expectedDescription instanceof RegExp + ? expectedDescription.test(desc) + : desc === expectedDescription; + }; + + const formatExpectation = (isRegExp: boolean): string => + isRegExp ? `matching ${expectedDescription}` : `"${expectedDescription}"`; + + const error = new AssertionError({ + actual: description, + expected: expectedDescription, + message: hasExpectedValue + ? `Expected the element to have description ${formatExpectation(expectedDescription instanceof RegExp)}, ` + + `but received "${description}"` + : "Expected the element to have a description", + }); + + const invertedError = new AssertionError({ + actual: description, + expected: expectedDescription, + message: hasExpectedValue + ? `Expected the element NOT to have description ${formatExpectation(expectedDescription instanceof RegExp)}, ` + + `but received "${description}"` + : `Expected the element NOT to have a description, but received "${description}"`, + }); + + return this.execute({ + assertWhen: matchesExpectation(description), + error, + invertedError, + }); + } + /** * Helper method to assert the presence or absence of class names. * diff --git a/packages/dom/src/lib/helpers/helpers.ts b/packages/dom/src/lib/helpers/helpers.ts index e8eaab87..0a70990f 100644 --- a/packages/dom/src/lib/helpers/helpers.ts +++ b/packages/dom/src/lib/helpers/helpers.ts @@ -73,3 +73,42 @@ export const getExpectedAndReceivedStyles = elementProcessedStyle, ]; }; + +/** + * Normalizes text by collapsing whitespace and trimming. + * + * @param text - The text to normalize. + * @returns The normalized text. + */ +export function normalizeText(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +/** + * Gets the accessible description of an element based on aria-describedby. + * + * @param actual - The element to get the description from. + * @returns The normalized description text. + */ +export function getAccessibleDescription(actual: Element): string { + const ariaDescribedBy = actual.getAttribute("aria-describedby") || ""; + const descriptionIDs = ariaDescribedBy + .split(/\s+/) + .filter(Boolean); + + if (descriptionIDs.length === 0) { + return ""; + } + + const getElementText = (id: string): string | null => { + const element = actual.ownerDocument.getElementById(id); + return element?.textContent || null; + }; + + return normalizeText( + descriptionIDs + .map(getElementText) + .filter((text): text is string => text !== null) + .join(" "), + ); +} diff --git a/packages/dom/test/unit/lib/ElementAssertion.test.tsx b/packages/dom/test/unit/lib/ElementAssertion.test.tsx index 182ea345..b619c3b1 100644 --- a/packages/dom/test/unit/lib/ElementAssertion.test.tsx +++ b/packages/dom/test/unit/lib/ElementAssertion.test.tsx @@ -3,6 +3,7 @@ import { render } from "@testing-library/react"; import { ElementAssertion } from "../../../src/lib/ElementAssertion"; +import { DescriptionTestComponent } from "./fixtures/descriptionTestComponent"; import { FocusTestComponent } from "./fixtures/focusTestComponent"; import { HaveClassTestComponent } from "./fixtures/haveClassTestComponent"; import { NestedElementsTestComponent } from "./fixtures/nestedElementsTestComponent"; @@ -411,4 +412,124 @@ describe("[Unit] ElementAssertion.test.ts", () => { }); }); }); + + describe(".toHaveDescription", () => { + context("when checking for any description", () => { + context("when the element has a description", () => { + it("returns the assertion instance", () => { + const { getByTestId } = render(); + const button = getByTestId("button-single"); + const test = new ElementAssertion(button); + + expect(test.toHaveDescription()).toBeEqual(test); + + expect(() => test.not.toHaveDescription()) + .toThrowError(AssertionError) + .toHaveMessage('Expected the element NOT to have a description, but received "This is a description"'); + }); + }); + + context("when the element does not have a description", () => { + it("throws an assertion error", () => { + const { getByTestId } = render(); + const button = getByTestId("button-no-description"); + const test = new ElementAssertion(button); + + expect(() => test.toHaveDescription()) + .toThrowError(AssertionError) + .toHaveMessage("Expected the element to have a description"); + + expect(test.not.toHaveDescription()).toBeEqual(test); + }); + }); + }); + + context("when checking for specific description text", () => { + context("when the element has the expected description", () => { + it("returns the assertion instance", () => { + const { getByTestId } = render(); + const button = getByTestId("button-single"); + const test = new ElementAssertion(button); + + expect(test.toHaveDescription("This is a description")).toBeEqual(test); + + expect(() => test.not.toHaveDescription("This is a description")) + .toThrowError(AssertionError) + .toHaveMessage( + 'Expected the element NOT to have description "This is a description", ' + + 'but received "This is a description"', + ); + }); + }); + + context("when the element has multiple descriptions combined", () => { + it("returns the assertion instance", () => { + const { getByTestId } = render(); + const button = getByTestId("button-multiple"); + const test = new ElementAssertion(button); + + expect(test.toHaveDescription("This is a description Additional info")).toBeEqual(test); + + expect(() => test.not.toHaveDescription("This is a description Additional info")) + .toThrowError(AssertionError) + .toHaveMessage( + 'Expected the element NOT to have description "This is a description Additional info", ' + + 'but received "This is a description Additional info"', + ); + }); + }); + + context("when the element does not have the expected description", () => { + it("throws an assertion error", () => { + const { getByTestId } = render(); + const button = getByTestId("button-single"); + const test = new ElementAssertion(button); + + expect(() => test.toHaveDescription("Wrong description")) + .toThrowError(AssertionError) + .toHaveMessage( + 'Expected the element to have description "Wrong description", but received "This is a description"', + ); + + expect(test.not.toHaveDescription("Wrong description")).toBeEqual(test); + }); + }); + }); + + context("when checking with a RegExp pattern", () => { + context("when the description matches the pattern", () => { + it("returns the assertion instance", () => { + const { getByTestId } = render(); + const button = getByTestId("button-single"); + const test = new ElementAssertion(button); + + expect(test.toHaveDescription(/description/i)).toBeEqual(test); + + expect(() => test.not.toHaveDescription(/description/i)) + .toThrowError(AssertionError) + .toHaveMessage( + "Expected the element NOT to have description matching /description/i, " + + 'but received "This is a description"', + ); + }); + }); + + context("when the description does not match the pattern", () => { + it("throws an assertion error", () => { + const { getByTestId } = render(); + const button = getByTestId("button-single"); + const test = new ElementAssertion(button); + + expect(() => test.toHaveDescription(/wrong pattern/)) + .toThrowError(AssertionError) + .toHaveMessage( + "Expected the element to have description matching /wrong pattern/, " + + 'but received "This is a description"', + ); + + expect(test.not.toHaveDescription(/wrong pattern/)).toBeEqual(test); + }); + }); + }); + }); }); diff --git a/packages/dom/test/unit/lib/fixtures/descriptionTestComponent.tsx b/packages/dom/test/unit/lib/fixtures/descriptionTestComponent.tsx new file mode 100644 index 00000000..c37685d5 --- /dev/null +++ b/packages/dom/test/unit/lib/fixtures/descriptionTestComponent.tsx @@ -0,0 +1,29 @@ +import { ReactElement } from "react"; + +export function DescriptionTestComponent(): ReactElement { + return ( +
+
{"This is a description"}
+
{"Additional info"}
+
{"More details here"}
+ + + + + + + + +
+ ); +}