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
Ikko Eltociear Ashimine

📖 Edwin Hernández
Edwin Hernández

💻 👀 Marialejandra Contreras
Marialejandra Contreras

💻 👀 - Suany Chalan
Suany Chalan

💻 + Suany Chalan
Suany Chalan

💻 👀 Karla Quistanchala
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