Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
83 changes: 64 additions & 19 deletions packages/dom/src/lib/ElementAssertion.ts
Original file line number Diff line number Diff line change
@@ -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<T extends Element> extends Assertion<T> {
Expand Down Expand Up @@ -293,29 +294,29 @@ 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 => {
Expand All @@ -327,25 +328,24 @@ export class ElementAssertion<T extends Element> extends Assertion<T> {
: 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({
Expand Down Expand Up @@ -434,6 +434,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
31 changes: 0 additions & 31 deletions packages/dom/src/lib/helpers/accessibility.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
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