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).
* @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
39 changes: 39 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,42 @@ export const getExpectedAndReceivedStyles =
elementProcessedStyle,
];
};

/**
* Normalizes text by collapsing whitespace and trimming.
*
* @param text - The text to normalize.
* @returns The normalized text.
*/
export function normalizeText(text: string): string {
return text.replace(/\s+/g, " ").trim();
}

/**
* Gets the accessible description of an element based on aria-describedby.
*
* @param actual - The element to get the description from.
* @returns The normalized description text.
*/
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>
);
}