Skip to content

Commit ebe04f9

Browse files
authored
feat(dom): DOM - toHaveDescription (#163)
1 parent 23f854f commit ebe04f9

File tree

4 files changed

+246
-1
lines changed

4 files changed

+246
-1
lines changed

packages/dom/src/lib/ElementAssertion.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Assertion, AssertionError } from "@assertive-ts/core";
22
import equal from "fast-deep-equal";
33

4-
import { getExpectedAndReceivedStyles, isElementEmpty } from "./helpers/helpers";
4+
import { getExpectedAndReceivedStyles, getAccessibleDescription, isElementEmpty } from "./helpers/helpers";
55

66
export class ElementAssertion<T extends Element> extends Assertion<T> {
77

@@ -292,6 +292,71 @@ export class ElementAssertion<T extends Element> extends Assertion<T> {
292292
});
293293
}
294294

295+
/**
296+
* Asserts that the element has an accessible description.
297+
*
298+
* The accessible description is computed from the `aria-describedby`
299+
* attribute, which references one or more elements by ID. The text
300+
* content of those elements is combined to form the description.
301+
*
302+
* @example
303+
* ```
304+
* // Check if element has any description
305+
* expect(element).toHaveDescription();
306+
*
307+
* // Check if element has specific description text
308+
* expect(element).toHaveDescription('Expected description text');
309+
*
310+
* // Check if element description matches a regex pattern
311+
* expect(element).toHaveDescription(/description pattern/i);
312+
* ```
313+
*
314+
* @param expectedDescription
315+
* - Optional expected description (string or RegExp).
316+
* @returns the assertion instance.
317+
*/
318+
319+
public toHaveDescription(expectedDescription?: string | RegExp): this {
320+
const description = getAccessibleDescription(this.actual);
321+
const hasExpectedValue = expectedDescription !== undefined;
322+
323+
const matchesExpectation = (desc: string): boolean => {
324+
if (!hasExpectedValue) {
325+
return Boolean(desc);
326+
}
327+
return expectedDescription instanceof RegExp
328+
? expectedDescription.test(desc)
329+
: desc === expectedDescription;
330+
};
331+
332+
const formatExpectation = (isRegExp: boolean): string =>
333+
isRegExp ? `matching ${expectedDescription}` : `"${expectedDescription}"`;
334+
335+
const error = new AssertionError({
336+
actual: description,
337+
expected: expectedDescription,
338+
message: hasExpectedValue
339+
? `Expected the element to have description ${formatExpectation(expectedDescription instanceof RegExp)}, ` +
340+
`but received "${description}"`
341+
: "Expected the element to have a description",
342+
});
343+
344+
const invertedError = new AssertionError({
345+
actual: description,
346+
expected: expectedDescription,
347+
message: hasExpectedValue
348+
? `Expected the element NOT to have description ${formatExpectation(expectedDescription instanceof RegExp)}, ` +
349+
`but received "${description}"`
350+
: `Expected the element NOT to have a description, but received "${description}"`,
351+
});
352+
353+
return this.execute({
354+
assertWhen: matchesExpectation(description),
355+
error,
356+
invertedError,
357+
});
358+
}
359+
295360
/**
296361
* Helper method to assert the presence or absence of class names.
297362
*

packages/dom/src/lib/helpers/helpers.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,34 @@ export function isElementEmpty (element: Element): boolean {
8080
const nonCommentChildNodes = [...element.childNodes].filter(child => child.nodeType !== COMMENT_NODE_TYPE);
8181
return nonCommentChildNodes.length === 0;
8282
}
83+
84+
function normalizeText(text: string): string {
85+
return text.replace(/\s+/g, " ").trim();
86+
}
87+
88+
export function getAccessibleDescription(actual: Element): string {
89+
const ariaDescribedBy = actual.getAttribute("aria-describedby");
90+
91+
if (!ariaDescribedBy) {
92+
return "";
93+
}
94+
95+
const descriptionIds = ariaDescribedBy.split(/\s+/).filter(Boolean);
96+
97+
const getElementText = (id: string): string | null => {
98+
const element = actual.ownerDocument.getElementById(id);
99+
100+
if (!element || !element.textContent) {
101+
return null;
102+
}
103+
104+
return element.textContent;
105+
};
106+
107+
const combinedText = descriptionIds
108+
.map(getElementText)
109+
.filter((text): text is string => text !== null)
110+
.join(" ");
111+
112+
return normalizeText(combinedText);
113+
}

packages/dom/test/unit/lib/ElementAssertion.test.tsx

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { render } from "@testing-library/react";
33

44
import { ElementAssertion } from "../../../src/lib/ElementAssertion";
55

6+
import { DescriptionTestComponent } from "./fixtures/descriptionTestComponent";
67
import { FocusTestComponent } from "./fixtures/focusTestComponent";
78
import { HaveClassTestComponent } from "./fixtures/haveClassTestComponent";
89
import { NestedElementsTestComponent } from "./fixtures/nestedElementsTestComponent";
@@ -465,4 +466,123 @@ describe("[Unit] ElementAssertion.test.ts", () => {
465466
});
466467
});
467468

469+
describe(".toHaveDescription", () => {
470+
context("when checking for any description", () => {
471+
context("when the element has a description", () => {
472+
it("returns the assertion instance", () => {
473+
const { getByTestId } = render(<DescriptionTestComponent />);
474+
const button = getByTestId("button-single");
475+
const test = new ElementAssertion(button);
476+
477+
expect(test.toHaveDescription()).toBeEqual(test);
478+
479+
expect(() => test.not.toHaveDescription())
480+
.toThrowError(AssertionError)
481+
.toHaveMessage('Expected the element NOT to have a description, but received "This is a description"');
482+
});
483+
});
484+
485+
context("when the element does not have a description", () => {
486+
it("throws an assertion error", () => {
487+
const { getByTestId } = render(<DescriptionTestComponent />);
488+
const button = getByTestId("button-no-description");
489+
const test = new ElementAssertion(button);
490+
491+
expect(() => test.toHaveDescription())
492+
.toThrowError(AssertionError)
493+
.toHaveMessage("Expected the element to have a description");
494+
495+
expect(test.not.toHaveDescription()).toBeEqual(test);
496+
});
497+
});
498+
});
499+
500+
context("when checking for specific description text", () => {
501+
context("when the element has the expected description", () => {
502+
it("returns the assertion instance", () => {
503+
const { getByTestId } = render(<DescriptionTestComponent />);
504+
const button = getByTestId("button-single");
505+
const test = new ElementAssertion(button);
506+
507+
expect(test.toHaveDescription("This is a description")).toBeEqual(test);
508+
509+
expect(() => test.not.toHaveDescription("This is a description"))
510+
.toThrowError(AssertionError)
511+
.toHaveMessage(
512+
'Expected the element NOT to have description "This is a description", ' +
513+
'but received "This is a description"',
514+
);
515+
});
516+
});
517+
518+
context("when the element has multiple descriptions combined", () => {
519+
it("returns the assertion instance", () => {
520+
const { getByTestId } = render(<DescriptionTestComponent />);
521+
const button = getByTestId("button-multiple");
522+
const test = new ElementAssertion(button);
523+
524+
expect(test.toHaveDescription("This is a description Additional info")).toBeEqual(test);
525+
526+
expect(() => test.not.toHaveDescription("This is a description Additional info"))
527+
.toThrowError(AssertionError)
528+
.toHaveMessage(
529+
'Expected the element NOT to have description "This is a description Additional info", ' +
530+
'but received "This is a description Additional info"',
531+
);
532+
});
533+
});
534+
535+
context("when the element does not have the expected description", () => {
536+
it("throws an assertion error", () => {
537+
const { getByTestId } = render(<DescriptionTestComponent />);
538+
const button = getByTestId("button-single");
539+
const test = new ElementAssertion(button);
540+
541+
expect(() => test.toHaveDescription("Wrong description"))
542+
.toThrowError(AssertionError)
543+
.toHaveMessage(
544+
'Expected the element to have description "Wrong description", but received "This is a description"',
545+
);
546+
547+
expect(test.not.toHaveDescription("Wrong description")).toBeEqual(test);
548+
});
549+
});
550+
});
551+
552+
context("when checking with a RegExp pattern", () => {
553+
context("when the description matches the pattern", () => {
554+
it("returns the assertion instance", () => {
555+
const { getByTestId } = render(<DescriptionTestComponent />);
556+
const button = getByTestId("button-single");
557+
const test = new ElementAssertion(button);
558+
559+
expect(test.toHaveDescription(/description/i)).toBeEqual(test);
560+
561+
expect(() => test.not.toHaveDescription(/description/i))
562+
.toThrowError(AssertionError)
563+
.toHaveMessage(
564+
"Expected the element NOT to have description matching /description/i, " +
565+
'but received "This is a description"',
566+
);
567+
});
568+
});
569+
570+
context("when the description does not match the pattern", () => {
571+
it("throws an assertion error", () => {
572+
const { getByTestId } = render(<DescriptionTestComponent />);
573+
const button = getByTestId("button-single");
574+
const test = new ElementAssertion(button);
575+
576+
expect(() => test.toHaveDescription(/wrong pattern/))
577+
.toThrowError(AssertionError)
578+
.toHaveMessage(
579+
"Expected the element to have description matching /wrong pattern/, " +
580+
'but received "This is a description"',
581+
);
582+
583+
expect(test.not.toHaveDescription(/wrong pattern/)).toBeEqual(test);
584+
});
585+
});
586+
});
587+
});
468588
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { ReactElement } from "react";
2+
3+
export function DescriptionTestComponent(): ReactElement {
4+
return (
5+
<div>
6+
<div id="description-1">{"This is a description"}</div>
7+
<div id="description-2">{"Additional info"}</div>
8+
<div id="description-3">{"More details here"}</div>
9+
10+
<button aria-describedby="description-1" data-testid="button-single">
11+
{"Button with single description"}
12+
</button>
13+
14+
<button aria-describedby="description-1 description-2" data-testid="button-multiple">
15+
{"Button with multiple descriptions"}
16+
</button>
17+
18+
<button data-testid="button-no-description">
19+
{"Button without description"}
20+
</button>
21+
22+
<input
23+
type="text"
24+
aria-describedby="description-3"
25+
data-testid="input-with-description"
26+
/>
27+
</div>
28+
);
29+
}

0 commit comments

Comments
 (0)