From ffde214ec4b0f857dc05cbea375889fc2fde9983 Mon Sep 17 00:00:00 2001 From: JDOM10 Date: Fri, 16 Jan 2026 16:09:35 -0500 Subject: [PATCH 1/3] feat(dom): add toHaveDescription assertion and normalizeText helper --- packages/dom/src/lib/ElementAssertion.ts | 90 +++++++++++++++++++++++- packages/dom/src/lib/helpers/helpers.ts | 10 +++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/packages/dom/src/lib/ElementAssertion.ts b/packages/dom/src/lib/ElementAssertion.ts index 3b9bdc6..39865ee 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, normalizeText } from "./helpers/helpers"; export class ElementAssertion extends Assertion { @@ -260,6 +260,67 @@ 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 = this.getAccessibleDescription(); + 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. * @@ -296,6 +357,33 @@ export class ElementAssertion extends Assertion { }); } + /** + * Gets the accessible description of an element based on aria-describedby. + * + * @returns The normalized description text. + */ + private getAccessibleDescription(): string { + const descriptionIDs = (this.actual.getAttribute('aria-describedby') || '') + .split(/\s+/) + .filter(Boolean); + + if (descriptionIDs.length === 0) { + return ''; + } + + const getElementText = (id: string): string | null => { + const element = this.actual.ownerDocument.getElementById(id); + return element?.textContent || null; + }; + + return normalizeText( + descriptionIDs + .map(getElementText) + .filter((text): text is string => text !== null) + .join(' '), + ); + } + private getClassList(): string[] { return this.actual.className.split(/\s+/).filter(Boolean); } diff --git a/packages/dom/src/lib/helpers/helpers.ts b/packages/dom/src/lib/helpers/helpers.ts index e8eaab8..8473e18 100644 --- a/packages/dom/src/lib/helpers/helpers.ts +++ b/packages/dom/src/lib/helpers/helpers.ts @@ -73,3 +73,13 @@ 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(); +} From 81c16cbacf731a8666d84cfe44c1825bd266d086 Mon Sep 17 00:00:00 2001 From: JDOM10 Date: Fri, 16 Jan 2026 16:09:42 -0500 Subject: [PATCH 2/3] feat(dom): implement toHaveDescription assertion with comprehensive tests --- .../test/unit/lib/ElementAssertion.test.tsx | 107 ++++++++++++++++++ .../lib/fixtures/descriptionTestComponent.tsx | 29 +++++ 2 files changed, 136 insertions(+) create mode 100644 packages/dom/test/unit/lib/fixtures/descriptionTestComponent.tsx diff --git a/packages/dom/test/unit/lib/ElementAssertion.test.tsx b/packages/dom/test/unit/lib/ElementAssertion.test.tsx index 182ea34..cf7fff9 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,110 @@ 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 0000000..c53d2e6 --- /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
+ + + + + + + + +
+ ); +} From cccddabde0b64ce5584a55e96a3effd97ea54279 Mon Sep 17 00:00:00 2001 From: JDOM10 Date: Mon, 19 Jan 2026 14:03:37 -0500 Subject: [PATCH 3/3] feat(dom): add toHaveDescription assertion and implement getAccessibleDescription helper --- packages/dom/src/lib/ElementAssertion.ts | 55 ++++++------------- packages/dom/src/lib/helpers/helpers.ts | 29 ++++++++++ .../test/unit/lib/ElementAssertion.test.tsx | 24 ++++++-- .../lib/fixtures/descriptionTestComponent.tsx | 20 +++---- 4 files changed, 74 insertions(+), 54 deletions(-) diff --git a/packages/dom/src/lib/ElementAssertion.ts b/packages/dom/src/lib/ElementAssertion.ts index 39865ee..3889962 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, normalizeText } from "./helpers/helpers"; +import { getExpectedAndReceivedStyles, getAccessibleDescription } from "./helpers/helpers"; export class ElementAssertion extends Assertion { @@ -262,28 +262,30 @@ 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. - * + * + * 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). + * @param expectedDescription + * - Optional expected description (string or RegExp). * @returns the assertion instance. */ + public toHaveDescription(expectedDescription?: string | RegExp): this { - const description = this.getAccessibleDescription(); + const description = getAccessibleDescription(this.actual); const hasExpectedValue = expectedDescription !== undefined; const matchesExpectation = (desc: string): boolean => { @@ -302,7 +304,8 @@ export class ElementAssertion extends Assertion { actual: description, expected: expectedDescription, message: hasExpectedValue - ? `Expected the element to have description ${formatExpectation(expectedDescription instanceof RegExp)}, but received "${description}"` + ? `Expected the element to have description ${formatExpectation(expectedDescription instanceof RegExp)}, ` + + `but received "${description}"` : "Expected the element to have a description", }); @@ -310,7 +313,8 @@ export class ElementAssertion extends Assertion { 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 description ${formatExpectation(expectedDescription instanceof RegExp)}, ` + + `but received "${description}"` : `Expected the element NOT to have a description, but received "${description}"`, }); @@ -357,33 +361,6 @@ export class ElementAssertion extends Assertion { }); } - /** - * Gets the accessible description of an element based on aria-describedby. - * - * @returns The normalized description text. - */ - private getAccessibleDescription(): string { - const descriptionIDs = (this.actual.getAttribute('aria-describedby') || '') - .split(/\s+/) - .filter(Boolean); - - if (descriptionIDs.length === 0) { - return ''; - } - - const getElementText = (id: string): string | null => { - const element = this.actual.ownerDocument.getElementById(id); - return element?.textContent || null; - }; - - return normalizeText( - descriptionIDs - .map(getElementText) - .filter((text): text is string => text !== null) - .join(' '), - ); - } - private getClassList(): string[] { return this.actual.className.split(/\s+/).filter(Boolean); } diff --git a/packages/dom/src/lib/helpers/helpers.ts b/packages/dom/src/lib/helpers/helpers.ts index 8473e18..0a70990 100644 --- a/packages/dom/src/lib/helpers/helpers.ts +++ b/packages/dom/src/lib/helpers/helpers.ts @@ -83,3 +83,32 @@ export const getExpectedAndReceivedStyles = 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 cf7fff9..b619c3b 100644 --- a/packages/dom/test/unit/lib/ElementAssertion.test.tsx +++ b/packages/dom/test/unit/lib/ElementAssertion.test.tsx @@ -455,7 +455,10 @@ describe("[Unit] ElementAssertion.test.ts", () => { 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"'); + .toHaveMessage( + 'Expected the element NOT to have description "This is a description", ' + + 'but received "This is a description"', + ); }); }); @@ -469,7 +472,10 @@ describe("[Unit] ElementAssertion.test.ts", () => { 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"'); + .toHaveMessage( + 'Expected the element NOT to have description "This is a description Additional info", ' + + 'but received "This is a description Additional info"', + ); }); }); @@ -481,7 +487,9 @@ describe("[Unit] ElementAssertion.test.ts", () => { expect(() => test.toHaveDescription("Wrong description")) .toThrowError(AssertionError) - .toHaveMessage('Expected the element to have description "Wrong description", but received "This is a description"'); + .toHaveMessage( + 'Expected the element to have description "Wrong description", but received "This is a description"', + ); expect(test.not.toHaveDescription("Wrong description")).toBeEqual(test); }); @@ -499,7 +507,10 @@ describe("[Unit] ElementAssertion.test.ts", () => { 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"'); + .toHaveMessage( + "Expected the element NOT to have description matching /description/i, " + + 'but received "This is a description"', + ); }); }); @@ -511,7 +522,10 @@ describe("[Unit] ElementAssertion.test.ts", () => { expect(() => test.toHaveDescription(/wrong pattern/)) .toThrowError(AssertionError) - .toHaveMessage('Expected the element to have description matching /wrong pattern/, but received "This is a description"'); + .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 index c53d2e6..c37685d 100644 --- a/packages/dom/test/unit/lib/fixtures/descriptionTestComponent.tsx +++ b/packages/dom/test/unit/lib/fixtures/descriptionTestComponent.tsx @@ -3,25 +3,25 @@ import { ReactElement } from "react"; export function DescriptionTestComponent(): ReactElement { return (
-
This is a description
-
Additional info
-
More details here
- +
{"This is a description"}
+
{"Additional info"}
+
{"More details here"}
+ - + - + - -