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
49 changes: 21 additions & 28 deletions packages/dom/src/lib/ElementAssertion.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
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 {
describeAccessibleExpectation,
isValidAriaPressed,
matchesAccessibleExpectation,
} from "./helpers/accessibility";
import { isButtonElement, isElementEmpty, normalizeHtml } from "./helpers/dom";
import { getExpectedAndReceivedStyles } from "./helpers/styles";

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what do you think about adding an alias to toHaveDescription

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll leave it as is. toHaveDescription is ambiguous: it reads like it's checking a description attribute or raw text content rather than the ARIA-computed description. This ticket is also moving toward covering the ARIA-related assertions, so the explicit name fits better.

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
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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch on cleanning up this helper 🚀

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we returning this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because expected is RegExp | string — the check picks the right comparison at runtime and narrows the type so .test() type-checks.

? 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}"`;
}
2 changes: 2 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,8 @@ export function isElementEmpty(element: Element): boolean {
return nonCommentChildNodes.length === 0;
}

// Callers are internal: the public assertions already reject non-string
// and empty `htmlText` values before reaching here.
export function normalizeHtml(htmlText: string, ownerDocument: Document): string {
const div = ownerDocument.createElement("div");
div.innerHTML = htmlText;
Expand Down
48 changes: 26 additions & 22 deletions packages/dom/test/unit/lib/ElementAssertion.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -469,19 +469,22 @@ 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", () => {
const { getByTestId } = render(<DescriptionTestComponent />);
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"',
);
});
});

Expand All @@ -491,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);
});
});
});
Expand All @@ -507,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"',
);
});
Expand All @@ -524,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"',
);
});
Expand All @@ -541,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);
});
});
});
Expand All @@ -559,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"',
);
});
Expand All @@ -576,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);
});
});
});
Expand Down
3 changes: 2 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
Loading