diff --git a/.all-contributorsrc b/.all-contributorsrc
index 51bcdb2b..402900da 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -151,7 +151,8 @@
"avatar_url": "https://avatars.githubusercontent.com/u/79164262?v=4",
"profile": "https://github.com/suany0805",
"contributions": [
- "code"
+ "code",
+ "review"
]
},
{
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 119acbb5..a6613871 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,7 +15,11 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- node: [18, 19, 20, 21]
+ node:
+ - 20 # Maintenance LTS
+ - 22 # Maintenance LTS
+ - 24 # Active LTS
+ - 25 # Current
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
diff --git a/.nvmrc b/.nvmrc
index 9944ce63..5870c667 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-21.6.2
+25.2.1
diff --git a/README.md b/README.md
index b583cf42..73f8b990 100644
--- a/README.md
+++ b/README.md
@@ -172,7 +172,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
 Ikko Eltociear Ashimine 📖 |
 Edwin Hernández 💻 👀 |
 Marialejandra Contreras 💻 👀 |
-  Suany Chalan 💻 |
+  Suany Chalan 💻 👀 |
 Karla Quistanchala 👀 |
diff --git a/examples/jest/package.json b/examples/jest/package.json
index da3d4963..c7f98622 100644
--- a/examples/jest/package.json
+++ b/examples/jest/package.json
@@ -2,6 +2,7 @@
"name": "@examples/jest",
"private": true,
"scripts": {
+ "check": "yarn compile && yarn test",
"compile": "tsc",
"test": "jest"
},
@@ -9,7 +10,7 @@
"@assertive-ts/core": "workspace:^",
"@examples/symbol-plugin": "workspace:^",
"@types/jest": "^29.5.12",
- "@types/node": "^20.11.19",
+ "@types/node": "^24.10.1",
"jest": "^29.7.0",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
diff --git a/examples/mocha/package.json b/examples/mocha/package.json
index ba4f6154..778a6460 100644
--- a/examples/mocha/package.json
+++ b/examples/mocha/package.json
@@ -2,6 +2,7 @@
"name": "@examples/mocha",
"private": true,
"scripts": {
+ "check": "yarn compile && yarn test --forbid-only",
"compile": "tsc",
"test": "mocha"
},
@@ -9,7 +10,7 @@
"@assertive-ts/core": "workspace:^",
"@examples/symbol-plugin": "workspace:^",
"@types/mocha": "^10.0.6",
- "@types/node": "^20.11.19",
+ "@types/node": "^24.10.1",
"mocha": "^10.3.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.2"
diff --git a/examples/mocha/test/hooks.ts b/examples/mocha/test/hooks.ts
index 10539a2c..4ca6597a 100644
--- a/examples/mocha/test/hooks.ts
+++ b/examples/mocha/test/hooks.ts
@@ -1,9 +1,8 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { usePlugin } from "@assertive-ts/core";
import { SymbolPlugin } from "@examples/symbol-plugin";
-import { RootHookObject } from "mocha";
-export function mochaHooks(): RootHookObject {
+export function mochaHooks(): Mocha.RootHookObject {
return {
beforeAll() {
usePlugin(SymbolPlugin);
diff --git a/examples/symbolPlugin/package.json b/examples/symbolPlugin/package.json
index a68bd971..0a96b92c 100644
--- a/examples/symbolPlugin/package.json
+++ b/examples/symbolPlugin/package.json
@@ -4,6 +4,7 @@
"main": "./dist/main.js",
"types": "./dist/main.d.ts",
"scripts": {
+ "check": "yarn compile",
"build": "tsc -p tsconfig.prod.json",
"compile": "tsc"
},
diff --git a/package.json b/package.json
index 28f391c3..062b1dcd 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,7 @@
"author": "Stack Builders ",
"license": "MIT",
"engines": {
- "node": ">=18"
+ "node": ">=20"
},
"packageManager": "yarn@4.1.0",
"workspaces": [
@@ -15,6 +15,7 @@
],
"scripts": {
"check": "turbo check && yarn lint",
+ "clean": "rimraf **/.turbo/ **/build/ **/dist/ **/node_modules/",
"build": "turbo run build",
"compile": "turbo run compile",
"docs": "turbo docs",
@@ -33,6 +34,7 @@
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-sonarjs": "^0.24.0",
+ "rimraf": "^6.1.2",
"turbo": "^1.12.4",
"typescript": "^5.4.2"
}
diff --git a/packages/core/package.json b/packages/core/package.json
index 3d92d89c..3339f602 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -37,7 +37,7 @@
},
"devDependencies": {
"@types/mocha": "^10.0.6",
- "@types/node": "^20.11.19",
+ "@types/node": "^24.10.1",
"@types/sinon": "^17.0.3",
"all-contributors-cli": "^6.26.1",
"mocha": "^10.3.0",
diff --git a/packages/dom/package.json b/packages/dom/package.json
index 407cac0b..db420e84 100644
--- a/packages/dom/package.json
+++ b/packages/dom/package.json
@@ -44,7 +44,7 @@
"@testing-library/react": "^16.0.0",
"@types/jsdom-global": "^3",
"@types/mocha": "^10.0.6",
- "@types/node": "^20.11.19",
+ "@types/node": "^24.10.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-test-renderer": "^18",
diff --git a/packages/dom/src/lib/ElementAssertion.ts b/packages/dom/src/lib/ElementAssertion.ts
index 537d8084..c8aae1b9 100644
--- a/packages/dom/src/lib/ElementAssertion.ts
+++ b/packages/dom/src/lib/ElementAssertion.ts
@@ -1,4 +1,9 @@
import { Assertion, AssertionError } from "@assertive-ts/core";
+import equal from "fast-deep-equal";
+
+import { getAccessibleDescription } from "./helpers/accessibility";
+import { isElementEmpty } from "./helpers/dom";
+import { getExpectedAndReceivedStyles } from "./helpers/styles";
export class ElementAssertion extends Assertion {
@@ -142,8 +147,216 @@ export class ElementAssertion extends Assertion {
);
}
- private getClassList(): string[] {
- return this.actual.className.split(/\s+/).filter(Boolean);
+ /**
+ * Check if the provided element is currently focused in the document.
+ *
+ * @example
+ * const userNameInput = document.querySelector('#username');
+ * userNameInput.focus();
+ * expect(userNameInput).toHaveFocus(); // passes
+ * expect(userNameInput).not.toHaveFocus(); // fails
+ *
+ * @returns The assertion instance.
+ */
+ public toHaveFocus(): this {
+
+ const hasFocus = this.actual === document.activeElement;
+
+ const error = new AssertionError({
+ actual: this.actual,
+ expected: document.activeElement,
+ message: "Expected the element to be focused",
+ });
+
+ const invertedError = new AssertionError({
+ actual: this.actual,
+ expected: document.activeElement,
+ message: "Expected the element NOT to be focused",
+ });
+
+ return this.execute({
+ assertWhen: hasFocus,
+ error,
+ invertedError,
+ });
+ }
+
+ /**
+ * Asserts that the element has the specified CSS styles.
+ *
+ * @example
+ * ```
+ * expect(component).toHaveStyle({ color: 'green', display: 'block' });
+ * ```
+ *
+ * @param expected the expected CSS styles.
+ * @returns the assertion instance.
+ */
+
+ public toHaveStyle(expected: Partial): this {
+
+ const [expectedStyle, receivedStyle] = getExpectedAndReceivedStyles(this.actual, expected);
+
+ if (!expectedStyle || !receivedStyle) {
+ throw new Error("Currently there are no available styles.");
+ }
+
+ const error = new AssertionError({
+ actual: this.actual,
+ expected: expectedStyle,
+ message: `Expected the element to match the following style:\n${JSON.stringify(expectedStyle, null, 2)}`,
+ });
+ const invertedError = new AssertionError({
+ actual: this.actual,
+ message: `Expected the element to NOT match the following style:\n${JSON.stringify(expectedStyle, null, 2)}`,
+ });
+
+ return this.execute({
+ assertWhen: equal(expectedStyle, receivedStyle),
+ error,
+ invertedError,
+ });
+ }
+
+ /**
+ * Asserts that the element has one or more of the specified CSS styles.
+ *
+ * @example
+ * ```
+ * expect(component).toHaveSomeStyle({ color: 'green', display: 'block' });
+ * ```
+ *
+ * @param expected the expected CSS style/s.
+ * @returns the assertion instance.
+ */
+
+ public toHaveSomeStyle(expected: Partial): this {
+
+ const [expectedStyle, elementProcessedStyle] = getExpectedAndReceivedStyles(this.actual, expected);
+
+ if (!expectedStyle || !elementProcessedStyle) {
+ throw new Error("No available styles.");
+ }
+
+ const hasSomeStyle = Object.entries(expectedStyle).some(([expectedProp, expectedValue]) => {
+ return Object.entries(elementProcessedStyle).some(([receivedProp, receivedValue]) => {
+ return equal(expectedProp, receivedProp) && equal(expectedValue, receivedValue);
+ });
+ });
+
+ const error = new AssertionError({
+ actual: this.actual,
+ message: `Expected the element to match some of the following styles:\n${JSON.stringify(expectedStyle, null, 2)}`,
+ });
+
+ const invertedError = new AssertionError({
+ actual: this.actual,
+ // eslint-disable-next-line max-len
+ message: `Expected the element NOT to match some of the following styles:\n${JSON.stringify(expectedStyle, null, 2)}`,
+ });
+
+ return this.execute({
+ assertWhen: hasSomeStyle,
+ error,
+ invertedError,
+ });
+ }
+
+ /**
+ * Asserts that the element does not contain child nodes, excluding comments.
+ *
+ * @example
+ * ```
+ * expect(component).toBeEmpty();
+ * ```
+ *
+ * @returns the assertion instance.
+ */
+
+ public toBeEmpty(): this {
+
+ const isEmpty = isElementEmpty(this.actual);
+
+ const error = new AssertionError({
+ actual: this.actual,
+ message: "Expected the element to be empty.",
+ });
+
+ const invertedError = new AssertionError({
+ actual: this.actual,
+ message: "Expected the element NOT to be empty.",
+ });
+
+ return this.execute({
+ assertWhen: isEmpty,
+ error,
+ invertedError,
+ });
+ }
+
+ /**
+ * 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,
+ });
}
/**
@@ -181,4 +394,8 @@ export class ElementAssertion extends Assertion {
invertedError,
});
}
+
+ private getClassList(): string[] {
+ return this.actual.className.split(/\s+/).filter(Boolean);
+ }
}
diff --git a/packages/dom/src/lib/helpers/accessibility.ts b/packages/dom/src/lib/helpers/accessibility.ts
new file mode 100644
index 00000000..355409ac
--- /dev/null
+++ b/packages/dom/src/lib/helpers/accessibility.ts
@@ -0,0 +1,30 @@
+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): string | null => {
+ 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);
+}
diff --git a/packages/dom/src/lib/helpers/dom.ts b/packages/dom/src/lib/helpers/dom.ts
new file mode 100644
index 00000000..57f57e85
--- /dev/null
+++ b/packages/dom/src/lib/helpers/dom.ts
@@ -0,0 +1,6 @@
+const COMMENT_NODE_TYPE = 8;
+
+export function isElementEmpty (element: Element): boolean {
+ const nonCommentChildNodes = [...element.childNodes].filter(child => child.nodeType !== COMMENT_NODE_TYPE);
+ return nonCommentChildNodes.length === 0;
+}
diff --git a/packages/dom/src/lib/helpers/styles.ts b/packages/dom/src/lib/helpers/styles.ts
new file mode 100644
index 00000000..d178a8da
--- /dev/null
+++ b/packages/dom/src/lib/helpers/styles.ts
@@ -0,0 +1,75 @@
+interface StyleDeclaration extends Record {
+ property: string;
+ value: string;
+}
+
+function normalizeStyles(css: Partial): StyleDeclaration {
+ const normalizer = document.createElement("div");
+ document.body.appendChild(normalizer);
+
+ const { expectedStyle } = Object.entries(css).reduce(
+ (acc, [property, value]) => {
+
+ if (typeof value !== "string") {
+ return acc;
+ }
+
+ normalizer.style.setProperty(property, value);
+
+ const normalizedValue = window
+ .getComputedStyle(normalizer)
+ .getPropertyValue(property)
+ .trim();
+
+ return {
+ expectedStyle: {
+ ...acc.expectedStyle,
+ [property]: normalizedValue,
+ },
+ };
+ },
+ { expectedStyle: {} as StyleDeclaration },
+ );
+
+ document.body.removeChild(normalizer);
+
+ return expectedStyle;
+}
+
+function getReceivedStyle (props: string[], received: CSSStyleDeclaration): StyleDeclaration {
+
+ return props.reduce((acc, prop) => {
+
+ const actualStyle = received.getPropertyValue(prop).trim();
+
+ return actualStyle
+ ? { ...acc, [prop]: actualStyle }
+ : acc;
+
+ }, {} as StyleDeclaration);
+}
+
+export function getExpectedAndReceivedStyles
+(actual: Element, expected: Partial): StyleDeclaration[] {
+ if (!actual.ownerDocument.defaultView) {
+ throw new Error("The element is not attached to a document with a default view.");
+ }
+ if (!(actual instanceof HTMLElement)) {
+ throw new Error("The element is not an HTMLElement.");
+ }
+
+ const window = actual.ownerDocument.defaultView;
+
+ const rawElementStyles = window.getComputedStyle(actual);
+
+ const expectedStyle = normalizeStyles(expected);
+
+ const styleKeys = Object.keys(expectedStyle);
+
+ const elementProcessedStyle = getReceivedStyle(styleKeys, rawElementStyles);
+
+ return [
+ expectedStyle,
+ elementProcessedStyle,
+ ];
+}
diff --git a/packages/dom/test/unit/lib/ElementAssertion.test.tsx b/packages/dom/test/unit/lib/ElementAssertion.test.tsx
index 47bb1674..22c2c796 100644
--- a/packages/dom/test/unit/lib/ElementAssertion.test.tsx
+++ b/packages/dom/test/unit/lib/ElementAssertion.test.tsx
@@ -3,6 +3,8 @@ 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";
import { SimpleTestComponent } from "./fixtures/simpleTestComponent";
@@ -257,12 +259,330 @@ describe("[Unit] ElementAssertion.test.ts", () => {
const test = new ElementAssertion(divTest);
expect(() => test.toHaveAllClasses("foo", "bar", "baz"))
- .toThrowError(AssertionError)
- .toHaveMessage('Expected the element to have all of these classes: "foo bar baz"');
+ .toThrowError(AssertionError)
+ .toHaveMessage('Expected the element to have all of these classes: "foo bar baz"');
expect(test.not.toHaveAllClasses("foo", "bar", "baz")).toBeEqual(test);
});
});
});
+ describe(".toHaveFocus", () => {
+ context("when the element has focus", () => {
+ it("returns the assertion instance", () => {
+ const { getByTestId } = render();
+ const input1 = getByTestId("input1");
+ input1.focus();
+ const test = new ElementAssertion(input1);
+
+ expect(test.toHaveFocus()).toBeEqual(test);
+
+ expect(() => test.not.toHaveFocus())
+ .toThrowError(AssertionError)
+ .toHaveMessage("Expected the element NOT to be focused");
+ });
+ });
+
+ context("when the element does not have focus", () => {
+ it("throws an assertion error", () => {
+ const { getByTestId } = render();
+ const input1 = getByTestId("input1");
+ const test = new ElementAssertion(input1);
+
+ expect(() => test.toHaveFocus())
+ .toThrowError(AssertionError)
+ .toHaveMessage("Expected the element to be focused");
+
+ expect(test.not.toHaveFocus()).toBeEqual(test);
+ });
+ });
+ });
+ describe(".toHaveStyle", () => {
+ context("when the element has the expected style", () => {
+ it("returns the assertion instance", () => {
+ const { getByTestId } = render(
+ );
+ const divTest = getByTestId("test-div");
+ const test = new ElementAssertion(divTest);
+
+ expect(test.toHaveStyle({ border: "1px solid black", color: "red", display: "flex" })).toBeEqual(test);
+
+ expect(() => test.not.toHaveStyle({ border: "1px solid black", color: "red", display: "flex" }))
+ .toThrowError(AssertionError)
+ .toHaveMessage(
+ // eslint-disable-next-line max-len
+ 'Expected the element to NOT match the following style:\n{\n "border": "1px solid black",\n "color": "rgb(255, 0, 0)",\n "display": "flex"\n}',
+ );
+ });
+ });
+
+ context("when the element does not have the expected style", () => {
+ it("throws an assertion error", () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ const divTest = getByTestId("test-div");
+ const test = new ElementAssertion(divTest);
+
+ expect(() => test.toHaveStyle(({ border: "1px solid black", color: "red", display: "flex" })))
+ .toThrowError(AssertionError)
+ .toHaveMessage(
+ // eslint-disable-next-line max-len
+ 'Expected the element to match the following style:\n{\n "border": "1px solid black",\n "color": "rgb(255, 0, 0)",\n "display": "flex"\n}',
+ );
+
+ expect(test.not.toHaveStyle({ border: "1px solid black", color: "red", display: "flex" })).toBeEqual(test);
+
+ });
+ });
+ context("when the element partially match the style", () => {
+ it("throws an assertion error", () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ const divTest = getByTestId("test-div");
+ const test = new ElementAssertion(divTest);
+
+ expect(() => test.toHaveStyle(({ color: "red", display: "flex" })))
+ .toThrowError(AssertionError)
+ .toHaveMessage(
+ // eslint-disable-next-line max-len
+ 'Expected the element to match the following style:\n{\n "color": "rgb(255, 0, 0)",\n "display": "flex"\n}',
+ );
+
+ expect(test.not.toHaveStyle({ border: "1px solid black", color: "red", display: "flex" })).toBeEqual(test);
+
+ });
+ });
+ });
+
+ describe(".toHaveSomeStyle", () => {
+ context("when the element contains one or more expected styles", () => {
+ it("returns the assertion instance", () => {
+ const { getByTestId } = render(
+ ,
+ );
+ const divTest = getByTestId("test-div");
+ const test = new ElementAssertion(divTest);
+
+ expect(test.toHaveSomeStyle({ color: "red", display: "flex", height: "3rem", width: "2rem" })).toBeEqual(test);
+
+ expect(() => test.not.toHaveSomeStyle({ color: "blue" }))
+ .toThrowError(AssertionError)
+ // eslint-disable-next-line max-len
+ .toHaveMessage("Expected the element NOT to match some of the following styles:\n{\n \"color\": \"rgb(0, 0, 255)\"\n}");
+ });
+ });
+
+ context("when the element does not contain any of the expected styles", () => {
+ it("throws an assertion error", () => {
+ const { getByTestId } = render(
+ ,
+ );
+ const divTest = getByTestId("test-div");
+ const test = new ElementAssertion(divTest);
+
+ expect(() => test.toHaveSomeStyle({ color: "red", display: "flex" }))
+ .toThrowError(AssertionError)
+ // eslint-disable-next-line max-len
+ .toHaveMessage("Expected the element to match some of the following styles:\n{\n \"color\": \"rgb(255, 0, 0)\",\n \"display\": \"flex\"\n}");
+
+ expect(test.not.toHaveSomeStyle({ border: "1px solid blue", color: "red", display: "flex" })).toBeEqual(test);
+ });
+ });
+ });
+
+ describe(".toBeEmpty", () => {
+ context("when the element does not contain any child node", () => {
+ it("returns the assertion instance", () => {
+ const { getByTestId } = render();
+ const divTest = getByTestId("test-div");
+ const test = new ElementAssertion(divTest);
+
+ expect(test.toBeEmpty()).toBeEqual(test);
+
+ expect(() => test.not.toBeEmpty())
+ .toThrowError(AssertionError)
+ .toHaveMessage("Expected the element NOT to be empty.");
+
+ });
+ });
+
+ context("when the element contains a comment node", () => {
+ it("returns the assertion instance", () => {
+ const { getByTestId } = render();
+ const divTest = getByTestId("test-div");
+ const comment = document.createComment("test comment");
+ divTest.appendChild(comment);
+ const test = new ElementAssertion(divTest);
+
+ expect(test.toBeEmpty()).toBeEqual(test);
+
+ expect(() => test.not.toBeEmpty())
+ .toThrowError(AssertionError)
+ .toHaveMessage("Expected the element NOT to be empty.");
+
+ });
+ });
+
+ context("when the element contains a child node", () => {
+ it("throws an assertion error", () => {
+ const { getByTestId } = render();
+ const divTest = getByTestId("test-div");
+
+ const emptyDiv = document.createElement("div");
+ divTest.appendChild(emptyDiv);
+
+ const test = new ElementAssertion(divTest);
+
+ expect(() => test.toBeEmpty())
+ .toThrowError(AssertionError)
+ .toHaveMessage("Expected the element to be empty.");
+
+ expect(test.not.toBeEmpty()).toBeEqual(test);
+
+ });
+ });
+ });
+
+ describe(".toHaveDescription", () => {
+ context("when checking for any description", () => {
+ context("when the element has a description", () => {
+ it("returns the assertion instance", () => {
+ const { getByTestId } = render();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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);
+ });
+ });
+ });
+ });
});
diff --git a/packages/dom/test/unit/lib/fixtures/descriptionTestComponent.tsx b/packages/dom/test/unit/lib/fixtures/descriptionTestComponent.tsx
new file mode 100644
index 00000000..c37685d5
--- /dev/null
+++ b/packages/dom/test/unit/lib/fixtures/descriptionTestComponent.tsx
@@ -0,0 +1,29 @@
+import { ReactElement } from "react";
+
+export function DescriptionTestComponent(): ReactElement {
+ return (
+
+
{"This is a description"}
+
{"Additional info"}
+
{"More details here"}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/dom/test/unit/lib/fixtures/focusTestComponent.tsx b/packages/dom/test/unit/lib/fixtures/focusTestComponent.tsx
new file mode 100644
index 00000000..91dd421a
--- /dev/null
+++ b/packages/dom/test/unit/lib/fixtures/focusTestComponent.tsx
@@ -0,0 +1,10 @@
+import { ReactElement } from "react";
+
+export function FocusTestComponent(): ReactElement {
+ return (
+
+
+
+
+ );
+}
diff --git a/packages/native/package.json b/packages/native/package.json
index cae4d308..9d68014d 100644
--- a/packages/native/package.json
+++ b/packages/native/package.json
@@ -42,7 +42,7 @@
"@assertive-ts/core": "workspace:^",
"@testing-library/react-native": "^12.9.0",
"@types/mocha": "^10.0.6",
- "@types/node": "^20.11.19",
+ "@types/node": "^24.10.1",
"@types/react": "^18.2.70",
"@types/react-test-renderer": "^18.0.7",
"@types/sinon": "^17.0.3",
diff --git a/packages/native/src/lib/ElementAssertion.ts b/packages/native/src/lib/ElementAssertion.ts
index bc804fc8..f322b50d 100644
--- a/packages/native/src/lib/ElementAssertion.ts
+++ b/packages/native/src/lib/ElementAssertion.ts
@@ -1,9 +1,12 @@
import { Assertion, AssertionError } from "@assertive-ts/core";
import { get } from "dot-prop-immutable";
-import { Children } from "react";
import { ReactTestInstance } from "react-test-renderer";
-import { instanceToString } from "./helpers/helpers";
+import { isAncestorDisabled, isElementDisabled, isAncestorNotVisible, isElementVisible } from "./helpers/accesibility";
+import { getFlattenedStyle, styleToString } from "./helpers/styles";
+import { getTextContent, textMatches } from "./helpers/text";
+import { AssertiveStyle, TestableTextMatcher } from "./helpers/types";
+import { isEmpty, instanceToString, isElementContained } from "./helpers/utils";
export class ElementAssertion extends Assertion {
public constructor(actual: ReactTestInstance) {
@@ -35,7 +38,7 @@ export class ElementAssertion extends Assertion {
});
return this.execute({
- assertWhen: this.isElementDisabled(this.actual) || this.isAncestorDisabled(this.actual),
+ assertWhen: isElementDisabled(this.actual) || isAncestorDisabled(this.actual),
error,
invertedError,
});
@@ -61,7 +64,7 @@ export class ElementAssertion extends Assertion {
});
return this.execute({
- assertWhen: !this.isElementDisabled(this.actual) && !this.isAncestorDisabled(this.actual),
+ assertWhen: !isElementDisabled(this.actual) && !isAncestorDisabled(this.actual),
error,
invertedError,
});
@@ -88,7 +91,7 @@ export class ElementAssertion extends Assertion {
});
return this.execute({
- assertWhen: Children.count(this.actual.props.children) === 0,
+ assertWhen: isEmpty(this.actual.children),
error,
invertedError,
});
@@ -115,7 +118,7 @@ export class ElementAssertion extends Assertion {
});
return this.execute({
- assertWhen: this.isElementVisible(this.actual) && this.isAncestorVisible(this.actual),
+ assertWhen: isElementVisible(this.actual) && !isAncestorNotVisible(this.actual),
error,
invertedError,
});
@@ -143,62 +146,124 @@ export class ElementAssertion extends Assertion {
});
return this.execute({
- assertWhen: this.isElementContained(this.actual, element),
+ assertWhen: isElementContained(this.actual, element),
error,
invertedError,
});
}
- private isElementContained(parentElement: ReactTestInstance, childElement: ReactTestInstance): boolean {
- if (parentElement === null || childElement === null) {
- return false;
- }
-
- return (
- parentElement.findAll(
- node =>
- node.type === childElement.type && node.props === childElement.props,
- ).length > 0
- );
- }
+ /**
+ * Check if the element has a specific property or a specific property value.
+ *
+ * @example
+ * ```
+ * expect(element).toHaveProp("propName");
+ * expect(element).toHaveProp("propName", "propValue");
+ * ```
+ *
+ * @param propName - The name of the prop to check for.
+ * @param value - The value of the prop to check for.
+ * @returns the assertion instance
+ */
+ public toHaveProp(propName: string, value?: unknown): this {
+ const propValue: unknown = get(this.actual, `props.${propName}`, undefined);
+ const hasProp = propValue !== undefined;
+ const isPropEqual = value === undefined || propValue === value;
- private isElementDisabled(element: ReactTestInstance): boolean {
- const { type } = element;
- const elementType = type.toString();
- if (elementType === "TextInput" && element?.props?.editable === false) {
- return true;
- }
-
- return (
- get(element, "props.aria-disabled")
- || get(element, "props.disabled", false)
- || get(element, "props.accessibilityState.disabled", false)
- || get(element, "props.accessibilityStates", []).includes("disabled")
- );
- }
+ const errorMessage = value === undefined
+ ? `Expected element ${this.toString()} to have prop '${propName}'.`
+ : `Expected element ${this.toString()} to have prop '${propName}' with value '${String(value)}'.`;
+
+ const invertedErrorMessage = value === undefined
+ ? `Expected element ${this.toString()} NOT to have prop '${propName}'.`
+ : `Expected element ${this.toString()} NOT to have prop '${propName}' with value '${String(value)}'.`;
+
+ const error = new AssertionError({ actual: this.actual, message: errorMessage });
+ const invertedError = new AssertionError({ actual: this.actual, message: invertedErrorMessage });
- private isAncestorDisabled(element: ReactTestInstance): boolean {
- const { parent } = element;
- return parent !== null && (this.isElementDisabled(element) || this.isAncestorDisabled(parent));
+ return this.execute({
+ assertWhen: hasProp && isPropEqual,
+ error,
+ invertedError,
+ });
}
- private isElementVisible(element: ReactTestInstance): boolean {
- const { type } = element;
+ /**
+ * Asserts that a component has the specified style(s) applied.
+ *
+ * This method supports both single style objects and arrays of style objects.
+ * It checks if all specified style properties match on the target element.
+ *
+ * @example
+ * ```
+ * expect(element).toHaveStyle({ backgroundColor: "red" });
+ * expect(element).toHaveStyle([{ backgroundColor: "red" }]);
+ * ```
+ *
+ * @param style - A style object to check for.
+ * @returns the assertion instance
+ */
+ public toHaveStyle(style: AssertiveStyle): this {
+ const stylesOnElement: AssertiveStyle = get(this.actual, "props.style", {});
+
+ const flattenedElementStyle = getFlattenedStyle(stylesOnElement);
+ const flattenedStyle = getFlattenedStyle(style);
+
+ const hasStyle = Object.keys(flattenedStyle)
+ .every(key => flattenedElementStyle[key] === flattenedStyle[key]);
- if (type.toString() === "Modal") {
- return Boolean(element.props?.visible);
- }
+ const error = new AssertionError({
+ actual: this.actual,
+ message: `Expected element ${this.toString()} to have style: \n${styleToString(flattenedStyle)}`,
+ });
- return (
- get(element, "props.style.display") !== "none"
- && get(element, "props.style.opacity") !== 0
- && get(element, "props.accessibilityElementsHidden") !== true
- && get(element, "props.importantForAccessibility") !== "no-hide-descendants"
- );
+ const invertedError = new AssertionError({
+ actual: this.actual,
+ message: `Expected element ${this.toString()} NOT to have style: \n${styleToString(flattenedStyle)}`,
+ });
+
+ return this.execute({
+ assertWhen: hasStyle,
+ error,
+ invertedError,
+ });
}
- private isAncestorVisible(element: ReactTestInstance): boolean {
- const { parent } = element;
- return parent === null || (this.isElementVisible(parent) && this.isAncestorVisible(parent));
+ /**
+ * Check if the element has text content matching the provided string,
+ * RegExp, or function.
+ *
+ * @example
+ * ```
+ * expect(element).toHaveTextContent("Hello World");
+ * expect(element).toHaveTextContent(/Hello/);
+ * expect(element).toHaveTextContent(text => text.startsWith("Hello"));
+ * ```
+ *
+ * @param text - The text to check for.
+ * @returns the assertion instance
+ */
+ public toHaveTextContent(text: TestableTextMatcher): this {
+ const actualTextContent = getTextContent(this.actual);
+ const matchesText = textMatches(actualTextContent, text);
+
+ const error = new AssertionError({
+ actual: this.actual,
+ message: `Expected element ${this.toString()} to have text content matching '` +
+ `${text.toString()}'.`,
+ });
+
+ const invertedError = new AssertionError({
+ actual: this.actual,
+ message:
+ `Expected element ${this.toString()} NOT to have text content matching '` +
+ `${text.toString()}'.`,
+ });
+
+ return this.execute({
+ assertWhen: matchesText,
+ error,
+ invertedError,
+ });
}
}
diff --git a/packages/native/src/lib/helpers/accesibility.ts b/packages/native/src/lib/helpers/accesibility.ts
new file mode 100644
index 00000000..8855c711
--- /dev/null
+++ b/packages/native/src/lib/helpers/accesibility.ts
@@ -0,0 +1,38 @@
+import { get } from "dot-prop-immutable";
+import { ReactTestInstance } from "react-test-renderer";
+
+export function isElementDisabled(element: ReactTestInstance): boolean {
+ const { type } = element;
+ const elementType = type.toString();
+ if (elementType === "TextInput" && element?.props?.editable === false) {
+ return true;
+ }
+ return (
+ get(element, "props.aria-disabled")
+ || get(element, "props.disabled", false)
+ || get(element, "props.accessibilityState.disabled", false)
+ || get(element, "props.accessibilityStates", []).includes("disabled")
+ );
+}
+
+export function isAncestorDisabled(element: ReactTestInstance): boolean {
+ const { parent } = element;
+ return parent !== null && (isElementDisabled(element) || isAncestorDisabled(parent));
+}
+export function isElementVisible(element: ReactTestInstance): boolean {
+ const { type } = element;
+ const elementType = type.toString();
+ if (elementType === "Modal" && !element?.props?.visible === true) {
+ return false;
+ }
+ return (
+ get(element, "props.style.display") !== "none"
+ && get(element, "props.style.opacity") !== 0
+ && get(element, "props.accessibilityElementsHidden") !== true
+ && get(element, "props.importantForAccessibility") !== "no-hide-descendants"
+ );
+}
+export function isAncestorNotVisible(element: ReactTestInstance): boolean {
+ const { parent } = element;
+ return parent !== null && (!isElementVisible(element) || isAncestorNotVisible(parent));
+}
diff --git a/packages/native/src/lib/helpers/styles.ts b/packages/native/src/lib/helpers/styles.ts
new file mode 100644
index 00000000..3b14ef7d
--- /dev/null
+++ b/packages/native/src/lib/helpers/styles.ts
@@ -0,0 +1,13 @@
+import { StyleSheet } from "react-native";
+
+import { AssertiveStyle, StyleObject } from "./types";
+
+export function getFlattenedStyle(style: AssertiveStyle): StyleObject {
+ const flattenedStyle = StyleSheet.flatten(style);
+ return flattenedStyle ? (flattenedStyle as StyleObject) : {};
+}
+
+export function styleToString(flattenedStyle: StyleObject): string {
+ const styleEntries = Object.entries(flattenedStyle);
+ return styleEntries.map(([key, value]) => `\t- ${key}: ${String(value)};`).join("\n");
+}
diff --git a/packages/native/src/lib/helpers/text.ts b/packages/native/src/lib/helpers/text.ts
new file mode 100644
index 00000000..6bccbbff
--- /dev/null
+++ b/packages/native/src/lib/helpers/text.ts
@@ -0,0 +1,64 @@
+import { ReactTestInstance } from "react-test-renderer";
+
+import { TestableTextMatcher, TextContent } from "./types";
+
+function collectText (element: TextContent): string[] {
+ if (typeof element === "string") {
+ return [element];
+ }
+
+ if (Array.isArray(element)) {
+ return element.flatMap(child => collectText(child));
+ }
+
+ if (element && (typeof element === "object" && "props" in element)) {
+ const value = element.props?.value as TextContent;
+ if (typeof value === "string") {
+ return [value];
+ }
+
+ const children = (element.props?.children as ReactTestInstance[]) ?? element.children;
+ if (!children) {
+ return [];
+ }
+
+ return Array.isArray(children)
+ ? children.flatMap(collectText)
+ : collectText(children);
+ }
+
+ return [];
+}
+
+export function getTextContent(element: ReactTestInstance): string {
+ if (!element) {
+ return "";
+ }
+ if (typeof element === "string") {
+ return element;
+ }
+ if (typeof element.props?.value === "string") {
+ return element.props.value;
+ }
+
+ return collectText(element).join(" ");
+}
+
+export function textMatches(
+ text: string,
+ matcher: TestableTextMatcher,
+): boolean {
+ if (typeof matcher === "string") {
+ return text.includes(matcher);
+ }
+
+ if (matcher instanceof RegExp) {
+ return matcher.test(text);
+ }
+
+ if (typeof matcher === "function") {
+ return matcher(text);
+ }
+
+ throw new Error("Matcher must be a string, RegExp, or function.");
+}
diff --git a/packages/native/src/lib/helpers/types.ts b/packages/native/src/lib/helpers/types.ts
new file mode 100644
index 00000000..69fad07c
--- /dev/null
+++ b/packages/native/src/lib/helpers/types.ts
@@ -0,0 +1,12 @@
+import { ImageStyle, StyleProp, TextStyle, ViewStyle } from "react-native";
+import { ReactTestInstance } from "react-test-renderer";
+
+type Style = TextStyle | ViewStyle | ImageStyle;
+
+export type AssertiveStyle = StyleProp