Skip to content
Open
1 change: 1 addition & 0 deletions packages/dom/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
96 changes: 67 additions & 29 deletions packages/dom/src/lib/ElementAssertion.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
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 {
describeAccessibleExpectation,
isValidAriaPressed,
matchesAccessibleExpectation,
} from "./helpers/accessibility";
import { isButtonElement, isElementEmpty, normalizeHtml } from "./helpers/dom";
import { getExpectedAndReceivedStyles } from "./helpers/styles";

export class ElementAssertion<T extends Element> extends Assertion<T> {
Expand Down Expand Up @@ -293,63 +298,51 @@ export class ElementAssertion<T extends Element> extends Assertion<T> {
/**
* 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 => {
if (!hasExpectedValue) {
return Boolean(desc);
}
return expectedDescription instanceof RegExp
? expectedDescription.test(desc)
: desc === expectedDescription;
};

const formatExpectation = (isRegExp: boolean): string =>
isRegExp ? `matching ${expectedDescription}` : `"${expectedDescription}"`;
const expectation = describeAccessibleExpectation(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({
assertWhen: matchesExpectation(description),
assertWhen: matchesAccessibleExpectation(description, expectedDescription),
error,
invertedError,
});
Expand Down Expand Up @@ -434,6 +427,51 @@ export class ElementAssertion<T extends Element> extends Assertion<T> {
});
}

/**
* 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('<span>Hello</span>');
* expect(container).toContainHTML("<div class='foo'>Bar</div>");
* ```
*
* @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.
*
Expand Down
45 changes: 17 additions & 28 deletions packages/dom/src/lib/helpers/accessibility.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,24 @@
function normalizeText(text: string): string {
return text.replace(/\s+/g, " ").trim();
export function isValidAriaPressed(element: Element): boolean {
const pressedAttribute = element.getAttribute("aria-pressed");
return pressedAttribute !== null && ["true", "false", "mixed"].includes(pressedAttribute);
}

export function getAccessibleDescription(actual: Element): string {
const ariaDescribedBy = actual.getAttribute("aria-describedby");

if (!ariaDescribedBy) {
return "";
export function matchesAccessibleExpectation(actual: string, expected?: RegExp | string): boolean {
if (expected === undefined) {
return Boolean(actual);
}

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);
return expected instanceof RegExp
? expected.test(actual)
: actual === expected;
}

export function isValidAriaPressed(element: Element): boolean {
const pressedAttribute = element.getAttribute("aria-pressed");
return pressedAttribute !== null && ["true", "false", "mixed"].includes(pressedAttribute);
export function describeAccessibleExpectation(expected?: RegExp | string): string {
if (expected === undefined) {
return "";
}

return expected instanceof RegExp
? `matching ${expected}`
: `"${expected}"`;
}
7 changes: 7 additions & 0 deletions packages/dom/src/lib/helpers/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(" ")
Expand Down
Loading
Loading