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
67 changes: 66 additions & 1 deletion packages/dom/src/lib/ElementAssertion.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Assertion, AssertionError } from "@assertive-ts/core";
import equal from "fast-deep-equal";

import { getExpectedAndReceivedStyles } from "./helpers/helpers";
import { getExpectedAndReceivedStyles, getAccessibleDescription } from "./helpers/helpers";

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

Expand Down Expand Up @@ -260,6 +260,71 @@ 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.
*
* @example
* ```
* // Check if element has any description
* expect(element).toHaveDescription();
*
* // Check if element has specific description text
* expect(element).toHaveDescription('Expected description text');
*
* // Check if element description matches a regex pattern
* expect(element).toHaveDescription(/description pattern/i);
* ```
*
* @param expectedDescription
* - Optional expected description (string or RegExp).
Comment on lines +282 to +283
Copy link
Contributor

@SbsCruz SbsCruz Jan 22, 2026

Choose a reason for hiding this comment

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

could you put this in one line please so it has same structure as the other matchers please

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I have to put like that because of the lenght of the line due to linter configuration!

* @returns the assertion instance.
*/

public toHaveDescription(expectedDescription?: string | RegExp): this {
const description = getAccessibleDescription(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 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",
});

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}"`,
});

return this.execute({
assertWhen: matchesExpectation(description),
error,
invertedError,
});
}

/**
* Helper method to assert the presence or absence of class names.
*
Expand Down
27 changes: 27 additions & 0 deletions packages/dom/src/lib/helpers/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,30 @@ export const getExpectedAndReceivedStyles =
elementProcessedStyle,
];
};

function normalizeText(text: string): string {
return text.replace(/\s+/g, " ").trim();
}

export function getAccessibleDescription(actual: Element): string {
const ariaDescribedBy = actual.getAttribute("aria-describedby") || "";
const descriptionIDs = ariaDescribedBy
.split(/\s+/)
.filter(Boolean);

if (descriptionIDs.length === 0) {
return "";
}

const getElementText = (id: string): string | null => {
const element = actual.ownerDocument.getElementById(id);
return element?.textContent || null;
};

return normalizeText(
descriptionIDs
.map(getElementText)
.filter((text): text is string => text !== null)
.join(" "),
);
}
121 changes: 121 additions & 0 deletions packages/dom/test/unit/lib/ElementAssertion.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render } from "@testing-library/react";

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

import { DescriptionTestComponent } from "./fixtures/descriptionTestComponent";
import { FocusTestComponent } from "./fixtures/focusTestComponent";
import { HaveClassTestComponent } from "./fixtures/haveClassTestComponent";
import { NestedElementsTestComponent } from "./fixtures/nestedElementsTestComponent";
Expand Down Expand Up @@ -411,4 +412,124 @@ describe("[Unit] ElementAssertion.test.ts", () => {
});
});
});

describe(".toHaveDescription", () => {
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.not.toHaveDescription())
.toThrowError(AssertionError)
.toHaveMessage('Expected the element NOT to have a description, but received "This is a description"');
});
});

context("when the element does not have a description", () => {
it("throws an assertion error", () => {
const { getByTestId } = render(<DescriptionTestComponent />);
const button = getByTestId("button-no-description");
const test = new ElementAssertion(button);

expect(() => test.toHaveDescription())
.toThrowError(AssertionError)
.toHaveMessage("Expected the element to have a description");

expect(test.not.toHaveDescription()).toBeEqual(test);
});
});
});

context("when checking for specific description text", () => {
context("when the element has the expected description", () => {
it("returns the assertion instance", () => {
const { getByTestId } = render(<DescriptionTestComponent />);
const button = getByTestId("button-single");
const test = new ElementAssertion(button);

expect(test.toHaveDescription("This is a description")).toBeEqual(test);

expect(() => test.not.toHaveDescription("This is a description"))
.toThrowError(AssertionError)
.toHaveMessage(
'Expected the element NOT to have description "This is a description", ' +
'but received "This is a description"',
);
});
});

context("when the element has multiple descriptions combined", () => {
it("returns the assertion instance", () => {
const { getByTestId } = render(<DescriptionTestComponent />);
const button = getByTestId("button-multiple");
const test = new ElementAssertion(button);

expect(test.toHaveDescription("This is a description Additional info")).toBeEqual(test);

expect(() => test.not.toHaveDescription("This is a description Additional info"))
.toThrowError(AssertionError)
.toHaveMessage(
'Expected the element NOT to have description "This is a description Additional info", ' +
'but received "This is a description Additional info"',
);
});
});

context("when the element does not have the expected description", () => {
it("throws an assertion error", () => {
const { getByTestId } = render(<DescriptionTestComponent />);
const button = getByTestId("button-single");
const test = new ElementAssertion(button);

expect(() => test.toHaveDescription("Wrong description"))
.toThrowError(AssertionError)
.toHaveMessage(
'Expected the element to have description "Wrong description", but received "This is a description"',
);

expect(test.not.toHaveDescription("Wrong description")).toBeEqual(test);
});
});
});

context("when checking with a RegExp pattern", () => {
context("when the description matches the pattern", () => {
it("returns the assertion instance", () => {
const { getByTestId } = render(<DescriptionTestComponent />);
const button = getByTestId("button-single");
const test = new ElementAssertion(button);

expect(test.toHaveDescription(/description/i)).toBeEqual(test);

expect(() => test.not.toHaveDescription(/description/i))
.toThrowError(AssertionError)
.toHaveMessage(
"Expected the element NOT to have description matching /description/i, " +
'but received "This is a description"',
);
});
});

context("when the description does not match the pattern", () => {
it("throws an assertion error", () => {
const { getByTestId } = render(<DescriptionTestComponent />);
const button = getByTestId("button-single");
const test = new ElementAssertion(button);

expect(() => test.toHaveDescription(/wrong pattern/))
.toThrowError(AssertionError)
.toHaveMessage(
"Expected the element to have description matching /wrong pattern/, " +
'but received "This is a description"',
);

expect(test.not.toHaveDescription(/wrong pattern/)).toBeEqual(test);
});
});
});
});
});
29 changes: 29 additions & 0 deletions packages/dom/test/unit/lib/fixtures/descriptionTestComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ReactElement } from "react";

export function DescriptionTestComponent(): ReactElement {
return (
<div>
<div id="description-1">{"This is a description"}</div>
<div id="description-2">{"Additional info"}</div>
<div id="description-3">{"More details here"}</div>

<button aria-describedby="description-1" data-testid="button-single">
{"Button with single description"}
</button>

<button aria-describedby="description-1 description-2" data-testid="button-multiple">
{"Button with multiple descriptions"}
</button>

<button data-testid="button-no-description">
{"Button without description"}
</button>

<input
type="text"
aria-describedby="description-3"
data-testid="input-with-description"
/>
</div>
);
}