diff --git a/packages/dom/package.json b/packages/dom/package.json index aa4aa61..b7da61a 100644 --- a/packages/dom/package.json +++ b/packages/dom/package.json @@ -35,6 +35,7 @@ "test": "NODE_ENV=test mocha" }, "dependencies": { + "dom-accessibility-api": "^0.5.16", "fast-deep-equal": "^3.1.3", "tslib": "^2.8.1" }, diff --git a/packages/dom/src/lib/ElementAssertion.ts b/packages/dom/src/lib/ElementAssertion.ts index 21b1faf..6134e1a 100644 --- a/packages/dom/src/lib/ElementAssertion.ts +++ b/packages/dom/src/lib/ElementAssertion.ts @@ -1,8 +1,9 @@ import { Assertion, AssertionError } from "@assertive-ts/core"; +import { computeAccessibleDescription } from "dom-accessibility-api"; import equal from "fast-deep-equal"; -import { getAccessibleDescription, isValidAriaPressed } from "./helpers/accessibility"; -import { isButtonElement, isElementEmpty } from "./helpers/dom"; +import { isValidAriaPressed } from "./helpers/accessibility"; +import { isButtonElement, isElementEmpty, normalizeHtml } from "./helpers/dom"; import { getExpectedAndReceivedStyles } from "./helpers/styles"; export class ElementAssertion extends Assertion { @@ -293,29 +294,29 @@ 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 following the + * [accname](https://www.w3.org/TR/accname/) specification, taking + * into account `aria-describedby`, `aria-description`, and `title`, + * among others. * * @example * ``` * // Check if element has any description - * expect(element).toHaveDescription(); + * expect(element).toHaveAccessibleDescription(); * * // Check if element has specific description text - * expect(element).toHaveDescription('Expected description text'); + * expect(element).toHaveAccessibleDescription("Expected description text"); * * // Check if element description matches a regex pattern - * expect(element).toHaveDescription(/description pattern/i); + * expect(element).toHaveAccessibleDescription(/description pattern/i); * ``` * * @param expectedDescription * - Optional expected description (string or RegExp). * @returns the assertion instance. */ - - public toHaveDescription(expectedDescription?: RegExp | string): this { - const description = getAccessibleDescription(this.actual); + public toHaveAccessibleDescription(expectedDescription?: RegExp | string): this { + const description = computeAccessibleDescription(this.actual); const hasExpectedValue = expectedDescription !== undefined; const matchesExpectation = (desc: string): boolean => { @@ -327,25 +328,24 @@ export class ElementAssertion extends Assertion { : desc === expectedDescription; }; - const formatExpectation = (isRegExp: boolean): string => - isRegExp ? `matching ${expectedDescription}` : `"${expectedDescription}"`; + const expectation = expectedDescription instanceof RegExp + ? `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", + ? `Expected the element to have accessible description ${expectation}, but received "${description}"` + : "Expected the element to have an accessible 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}"`, + ? `Expected the element NOT to have accessible description ${expectation}, but received "${description}"` + : `Expected the element NOT to have an accessible description, but received "${description}"`, }); return this.execute({ @@ -434,6 +434,51 @@ export class ElementAssertion extends Assertion { }); } + /** + * Asserts that the element contains the specified HTML. + * + * The expected HTML is normalized through a detached element before + * comparison, so differences in attribute quoting (single vs double quotes), + * tag case, and whitespace between attributes are ignored. Attribute ordering + * and whitespace within text content are still significant. + * + * @example + * ``` + * expect(container).toContainHTML('Hello'); + * expect(container).toContainHTML("
Bar
"); + * ``` + * + * @param htmlText The HTML text that should be contained in the element + * @returns the assertion instance. + */ + public toContainHTML(htmlText: string): this { + if (typeof htmlText !== "string") { + throw new Error(`.toContainHTML() expects a string value, got ${typeof htmlText}`); + } + + if (htmlText === "") { + throw new Error(".toContainHTML() expects a non-empty string"); + } + + const error = new AssertionError({ + actual: this.actual, + expected: htmlText, + message: `Expected the element to contain HTML: ${htmlText}`, + }); + + const invertedError = new AssertionError({ + actual: this.actual, + expected: htmlText, + message: `Expected the element NOT to contain HTML: ${htmlText}`, + }); + + return this.execute({ + assertWhen: this.actual.outerHTML.includes(normalizeHtml(htmlText, this.actual.ownerDocument)), + error, + invertedError, + }); + } + /** * Helper method to assert the presence or absence of class names. * diff --git a/packages/dom/src/lib/helpers/accessibility.ts b/packages/dom/src/lib/helpers/accessibility.ts index b3e3f7a..601d9af 100644 --- a/packages/dom/src/lib/helpers/accessibility.ts +++ b/packages/dom/src/lib/helpers/accessibility.ts @@ -1,34 +1,3 @@ -function normalizeText(text: string): string { - return text.replace(/\s+/g, " ").trim(); -} - -export function getAccessibleDescription(actual: Element): string { - const ariaDescribedBy = actual.getAttribute("aria-describedby"); - - if (!ariaDescribedBy) { - return ""; - } - - const descriptionIds = ariaDescribedBy.split(/\s+/).filter(Boolean); - - const getElementText = (id: string): null | string => { - const element = actual.ownerDocument.getElementById(id); - - if (!element || !element.textContent) { - return null; - } - - return element.textContent; - }; - - const combinedText = descriptionIds - .map(getElementText) - .filter((text): text is string => text !== null) - .join(" "); - - return normalizeText(combinedText); -} - export function isValidAriaPressed(element: Element): boolean { const pressedAttribute = element.getAttribute("aria-pressed"); return pressedAttribute !== null && ["true", "false", "mixed"].includes(pressedAttribute); diff --git a/packages/dom/src/lib/helpers/dom.ts b/packages/dom/src/lib/helpers/dom.ts index 3e1fb90..7850bc8 100644 --- a/packages/dom/src/lib/helpers/dom.ts +++ b/packages/dom/src/lib/helpers/dom.ts @@ -5,6 +5,13 @@ export function isElementEmpty(element: Element): boolean { return nonCommentChildNodes.length === 0; } +export function normalizeHtml(htmlText: string, ownerDocument: Document): string { + const div = ownerDocument.createElement("div"); + div.innerHTML = htmlText; + + return div.innerHTML; +} + export function isButtonElement(element: Element): boolean { const roles = (element.getAttribute("role") || "") .split(" ") diff --git a/packages/dom/test/unit/lib/ElementAssertion.test.tsx b/packages/dom/test/unit/lib/ElementAssertion.test.tsx index ec746b2..56ba294 100644 --- a/packages/dom/test/unit/lib/ElementAssertion.test.tsx +++ b/packages/dom/test/unit/lib/ElementAssertion.test.tsx @@ -8,6 +8,7 @@ import { NestedElementsTest } from "./fixtures/NestedElementsTest"; import { PressedTestComponent } from "./fixtures/PressedTestComponent"; import { SimpleTest } from "./fixtures/SimpleTest"; import { WithAttributesTest } from "./fixtures/WithAttributesTest"; +import { ContainHtmlTestComponent } from "./fixtures/containHtmlTestComponent"; import { DescriptionTestComponent } from "./fixtures/descriptionTestComponent"; import { FocusTestComponent } from "./fixtures/focusTestComponent"; @@ -468,7 +469,7 @@ describe("[Unit] ElementAssertion.test.ts", () => { }); }); - describe(".toHaveDescription", () => { + describe(".toHaveAccessibleDescription", () => { context("when checking for any description", () => { context("when the element has a description", () => { it("returns the assertion instance", () => { @@ -476,11 +477,14 @@ describe("[Unit] ElementAssertion.test.ts", () => { const button = getByTestId("button-single"); const test = new ElementAssertion(button); - expect(test.toHaveDescription()).toBeEqual(test); + expect(test.toHaveAccessibleDescription()).toBeEqual(test); - expect(() => test.not.toHaveDescription()) + expect(() => test.not.toHaveAccessibleDescription()) .toThrowError(AssertionError) - .toHaveMessage('Expected the element NOT to have a description, but received "This is a description"'); + .toHaveMessage( + "Expected the element NOT to have an accessible description, " + + 'but received "This is a description"', + ); }); }); @@ -490,11 +494,11 @@ describe("[Unit] ElementAssertion.test.ts", () => { const button = getByTestId("button-no-description"); const test = new ElementAssertion(button); - expect(() => test.toHaveDescription()) + expect(() => test.toHaveAccessibleDescription()) .toThrowError(AssertionError) - .toHaveMessage("Expected the element to have a description"); + .toHaveMessage("Expected the element to have an accessible description"); - expect(test.not.toHaveDescription()).toBeEqual(test); + expect(test.not.toHaveAccessibleDescription()).toBeEqual(test); }); }); }); @@ -506,12 +510,12 @@ describe("[Unit] ElementAssertion.test.ts", () => { const button = getByTestId("button-single"); const test = new ElementAssertion(button); - expect(test.toHaveDescription("This is a description")).toBeEqual(test); + expect(test.toHaveAccessibleDescription("This is a description")).toBeEqual(test); - expect(() => test.not.toHaveDescription("This is a description")) + expect(() => test.not.toHaveAccessibleDescription("This is a description")) .toThrowError(AssertionError) .toHaveMessage( - 'Expected the element NOT to have description "This is a description", ' + 'Expected the element NOT to have accessible description "This is a description", ' + 'but received "This is a description"', ); }); @@ -523,12 +527,12 @@ describe("[Unit] ElementAssertion.test.ts", () => { const button = getByTestId("button-multiple"); const test = new ElementAssertion(button); - expect(test.toHaveDescription("This is a description Additional info")).toBeEqual(test); + expect(test.toHaveAccessibleDescription("This is a description Additional info")).toBeEqual(test); - expect(() => test.not.toHaveDescription("This is a description Additional info")) + expect(() => test.not.toHaveAccessibleDescription("This is a description Additional info")) .toThrowError(AssertionError) .toHaveMessage( - 'Expected the element NOT to have description "This is a description Additional info", ' + 'Expected the element NOT to have accessible description "This is a description Additional info", ' + 'but received "This is a description Additional info"', ); }); @@ -540,13 +544,14 @@ describe("[Unit] ElementAssertion.test.ts", () => { const button = getByTestId("button-single"); const test = new ElementAssertion(button); - expect(() => test.toHaveDescription("Wrong description")) + expect(() => test.toHaveAccessibleDescription("Wrong description")) .toThrowError(AssertionError) .toHaveMessage( - 'Expected the element to have description "Wrong description", but received "This is a description"', + 'Expected the element to have accessible description "Wrong description", ' + + 'but received "This is a description"', ); - expect(test.not.toHaveDescription("Wrong description")).toBeEqual(test); + expect(test.not.toHaveAccessibleDescription("Wrong description")).toBeEqual(test); }); }); }); @@ -558,12 +563,12 @@ describe("[Unit] ElementAssertion.test.ts", () => { const button = getByTestId("button-single"); const test = new ElementAssertion(button); - expect(test.toHaveDescription(/description/i)).toBeEqual(test); + expect(test.toHaveAccessibleDescription(/description/i)).toBeEqual(test); - expect(() => test.not.toHaveDescription(/description/i)) + expect(() => test.not.toHaveAccessibleDescription(/description/i)) .toThrowError(AssertionError) .toHaveMessage( - "Expected the element NOT to have description matching /description/i, " + "Expected the element NOT to have accessible description matching /description/i, " + 'but received "This is a description"', ); }); @@ -575,14 +580,14 @@ describe("[Unit] ElementAssertion.test.ts", () => { const button = getByTestId("button-single"); const test = new ElementAssertion(button); - expect(() => test.toHaveDescription(/wrong pattern/)) + expect(() => test.toHaveAccessibleDescription(/wrong pattern/)) .toThrowError(AssertionError) .toHaveMessage( - "Expected the element to have description matching /wrong pattern/, " + "Expected the element to have accessible description matching /wrong pattern/, " + 'but received "This is a description"', ); - expect(test.not.toHaveDescription(/wrong pattern/)).toBeEqual(test); + expect(test.not.toHaveAccessibleDescription(/wrong pattern/)).toBeEqual(test); }); }); }); @@ -823,4 +828,84 @@ describe("[Unit] ElementAssertion.test.ts", () => { }); }); }); + + describe(".toContainHTML", () => { + context("when the element contains the expected HTML", () => { + it("returns the assertion instance", () => { + const { getByTestId } = render(); + const container = getByTestId("container"); + const test = new ElementAssertion(container); + + expect(test.toContainHTML('Hello World')).toBeEqual(test); + + expect(() => test.not.toContainHTML('Hello World')) + .toThrowError(AssertionError) + .toHaveMessage('Expected the element NOT to contain HTML: Hello World'); + }); + }); + + context("when the element contains nested HTML", () => { + it("returns the assertion instance", () => { + const { getByTestId } = render(); + const container = getByTestId("container"); + const test = new ElementAssertion(container); + + expect(test.toContainHTML("

Nested content

")).toBeEqual(test); + + expect(() => test.not.toContainHTML("

Nested content

")) + .toThrowError(AssertionError) + .toHaveMessage("Expected the element NOT to contain HTML:

Nested content

"); + }); + }); + + context("when the expected HTML differs only in formatting", () => { + it("normalizes quotes and tag case before comparing", () => { + const { getByTestId } = render(); + const container = getByTestId("container"); + const test = new ElementAssertion(container); + + expect(test.toContainHTML("Hello World")).toBeEqual(test); + expect(test.toContainHTML('Hello World')).toBeEqual(test); + expect(test.toContainHTML("

Nested content

")).toBeEqual(test); + }); + }); + + context("when the element does not contain the expected HTML", () => { + it("throws an assertion error", () => { + const { getByTestId } = render(); + const container = getByTestId("container"); + const test = new ElementAssertion(container); + + expect(() => test.toContainHTML("
Not present
")) + .toThrowError(AssertionError) + .toHaveMessage("Expected the element to contain HTML:
Not present
"); + + expect(test.not.toContainHTML("
Not present
")).toBeEqual(test); + }); + }); + + context("when a non-string value is passed", () => { + it("throws an error", () => { + const { getByTestId } = render(); + const container = getByTestId("container"); + const test = new ElementAssertion(container); + + expect(() => test.toContainHTML(123 as unknown as string)) + .toThrowError(Error) + .toHaveMessage(".toContainHTML() expects a string value, got number"); + }); + }); + + context("when an empty string is passed", () => { + it("throws an error", () => { + const { getByTestId } = render(); + const container = getByTestId("container"); + const test = new ElementAssertion(container); + + expect(() => test.toContainHTML("")) + .toThrowError(Error) + .toHaveMessage(".toContainHTML() expects a non-empty string"); + }); + }); + }); }); diff --git a/packages/dom/test/unit/lib/fixtures/containHtmlTestComponent.tsx b/packages/dom/test/unit/lib/fixtures/containHtmlTestComponent.tsx new file mode 100644 index 0000000..6e07da3 --- /dev/null +++ b/packages/dom/test/unit/lib/fixtures/containHtmlTestComponent.tsx @@ -0,0 +1,12 @@ +import type { ReactElement } from "react"; + +export function ContainHtmlTestComponent(): ReactElement { + return ( +
+ {"Hello World"} +
+

{"Nested content"}

+
+
+ ); +} diff --git a/yarn.lock b/yarn.lock index ec47186..dc94837 100644 --- a/yarn.lock +++ b/yarn.lock @@ -51,6 +51,7 @@ __metadata: "@types/react": "npm:^18.3.3" "@types/react-dom": "npm:^18.3.0" "@types/react-test-renderer": "npm:^18" + dom-accessibility-api: "npm:^0.5.16" fast-deep-equal: "npm:^3.1.3" jsdom: "npm:^24.0.0" jsdom-global: "npm:^3.0.2" @@ -5655,7 +5656,7 @@ __metadata: languageName: node linkType: hard -"dom-accessibility-api@npm:^0.5.9": +"dom-accessibility-api@npm:^0.5.16, dom-accessibility-api@npm:^0.5.9": version: 0.5.16 resolution: "dom-accessibility-api@npm:0.5.16" checksum: 10/377b4a7f9eae0a5d72e1068c369c99e0e4ca17fdfd5219f3abd32a73a590749a267475a59d7b03a891f9b673c27429133a818c44b2e47e32fec024b34274e2ca