diff --git a/packages/native/src/lib/ElementAssertion.ts b/packages/native/src/lib/ElementAssertion.ts index d1350b95..7ffa2707 100644 --- a/packages/native/src/lib/ElementAssertion.ts +++ b/packages/native/src/lib/ElementAssertion.ts @@ -2,17 +2,22 @@ import { Assertion, AssertionError } from "@assertive-ts/core"; import { get } from "dot-prop-immutable"; import { ReactTestInstance } from "react-test-renderer"; +import { + instanceToString, + isEmpty, + getFlattenedStyle, + styleToString, + textMatches, +} from "./helpers/helpers"; +import { AssertiveStyle, TestableTextMatcher, TextContent } from "./helpers/types"; + export class ElementAssertion extends Assertion { public constructor(actual: ReactTestInstance) { super(actual); } public override toString = (): string => { - if (this.actual === null) { - return "null"; - } - - return `<${this.actual.type.toString()} ... />`; + return instanceToString(this.actual); }; /** @@ -32,7 +37,7 @@ export class ElementAssertion extends Assertion { }); const invertedError = new AssertionError({ actual: this.actual, - message: `Expected element ${this.toString()} to NOT be disabled.`, + message: `Expected element ${this.toString()} NOT to be disabled.`, }); return this.execute({ @@ -43,7 +48,7 @@ export class ElementAssertion extends Assertion { } /** - * Check if the component is enabled. + * Check if the component is enabled and has not been disabled by an ancestor. * * @example * ``` @@ -58,7 +63,7 @@ export class ElementAssertion extends Assertion { }); const invertedError = new AssertionError({ actual: this.actual, - message: `Expected element ${this.toString()} to NOT be enabled.`, + message: `Expected element ${this.toString()} NOT to be enabled.`, }); return this.execute({ @@ -68,6 +73,263 @@ export class ElementAssertion extends Assertion { }); } + /** + * Check if the element is empty. + * + * @example + * ``` + * expect(element).toBeEmpty(); + * ``` + * + * @returns the assertion instance + */ + public toBeEmpty(): this { + const error = new AssertionError({ + actual: this.actual, + message: `Expected element ${this.toString()} to be empty.`, + }); + const invertedError = new AssertionError({ + actual: this.actual, + message: `Expected element ${this.toString()} NOT to be empty.`, + }); + + return this.execute({ + assertWhen: isEmpty(this.actual.children), + error, + invertedError, + }); + } + + /** + * Check if the element is visible and has not been hidden by an ancestor. + * + * @example + * ``` + * expect(element).toBeVisible(); + * ``` + * + * @returns the assertion instance + */ + public toBeVisible(): this { + const error = new AssertionError({ + actual: this.actual, + message: `Expected element ${this.toString()} to be visible.`, + }); + const invertedError = new AssertionError({ + actual: this.actual, + message: `Expected element ${this.toString()} NOT to be visible.`, + }); + + return this.execute({ + assertWhen: this.isElementVisible(this.actual) && !this.isAncestorNotVisible(this.actual), + error, + invertedError, + }); + } + + /** + * Check if an element is contained within another element. + * + * @example + * ``` + * expect(parent).toContainElement(child); + * ``` + * + * @param element - The element to check for. + * @returns the assertion instance + */ + public toContainElement(element: ReactTestInstance): this { + const error = new AssertionError({ + actual: this.actual, + message: `Expected element ${this.toString()} to contain element ${instanceToString(element)}.`, + }); + const invertedError = new AssertionError({ + actual: this.actual, + message: `Expected element ${this.toString()} NOT to contain element ${instanceToString(element)}.`, + }); + + const 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 + ); + }; + + return this.execute({ + assertWhen: isElementContained(this.actual, element), + error, + invertedError, + }); + } + + /** + * 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; + + 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 }); + + return this.execute({ + assertWhen: hasProp && isPropEqual, + error, + invertedError, + }); + } + + /** + * 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]); + + const error = new AssertionError({ + actual: this.actual, + message: `Expected element ${this.toString()} to have style: \n${styleToString(flattenedStyle)}`, + }); + + 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, + }); + } + + /** + * 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 = this.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, + }); + } + + private getTextContent(element: ReactTestInstance): string { + if (!element) { + return ""; + } + + if (typeof element === "string") { + return element; + } + + if (typeof element.props?.value === "string") { + return element.props.value; + } + + return this.collectText(element).join(" "); + } + + private collectText = (element: TextContent): string[] => { + if (typeof element === "string") { + return [element]; + } + + if (Array.isArray(element)) { + return element.flatMap(child => this.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(this.collectText) + : this.collectText(children); + } + + return []; + }; + private isElementDisabled(element: ReactTestInstance): boolean { const { type } = element; const elementType = type.toString(); @@ -76,10 +338,10 @@ export class ElementAssertion extends Assertion { } return ( - get(element, "props.aria-disabled") - || get(element, "props.disabled", false) - || get(element, "props.accessibilityState.disabled", false) - || get(element, "props.accessibilityStates", []).includes("disabled") + get(element, "props.aria-disabled") + || get(element, "props.disabled", false) + || get(element, "props.accessibilityState.disabled", false) + || get(element, "props.accessibilityStates", []).includes("disabled") ); } @@ -87,4 +349,24 @@ export class ElementAssertion extends Assertion { const { parent } = element; return parent !== null && (this.isElementDisabled(element) || this.isAncestorDisabled(parent)); } + + private 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" + ); + } + + private isAncestorNotVisible(element: ReactTestInstance): boolean { + const { parent } = element; + return parent !== null && (!this.isElementVisible(element) || this.isAncestorNotVisible(parent)); + } } diff --git a/packages/native/src/lib/helpers/helpers.ts b/packages/native/src/lib/helpers/helpers.ts new file mode 100644 index 00000000..dc603a8b --- /dev/null +++ b/packages/native/src/lib/helpers/helpers.ts @@ -0,0 +1,82 @@ +import { StyleSheet } from "react-native"; +import { ReactTestInstance } from "react-test-renderer"; + +import { AssertiveStyle, StyleObject, TestableTextMatcher } from "./types"; + +/** + * Checks if a value is empty. + * + * @param value - The value to check. + * @returns `true` if the value is empty, `false` otherwise. + */ +export function isEmpty(value: unknown): boolean { + if (!value) { + return true; + } + + if (Array.isArray(value)) { + return value.length === 0; + } + + return false; +} + +/** + * Converts a ReactTestInstance to a string representation. + * + * @param instance - The ReactTestInstance to convert. + * @returns A string representation of the instance. + */ +export function instanceToString(instance: ReactTestInstance | null): string { + if (instance === null) { + return "null"; + } + + return `<${instance.type.toString()} ... />`; +} + +/** + * Checks if a text matches a given matcher. + * + * @param text - The text to check. + * @param matcher - The matcher to use for comparison. + * @returns `true` if the text matches the matcher, `false` otherwise. + * @throws Error if the matcher is not a string, RegExp, or function. + * @example + * ```ts + * textMatches("Hello World", "Hello World"); // true + * textMatches("Hello World", /Hello/); // true + * textMatches("Hello World", (text) => text.startsWith("Hello")); // true + * textMatches("Hello World", "Goodbye"); // false + * textMatches("Hello World", /Goodbye/); // false + * textMatches("Hello World", (text) => text.startsWith("Goodbye")); // false + * ``` + */ +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."); +} + +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/types.ts b/packages/native/src/lib/helpers/types.ts new file mode 100644 index 00000000..93cc9f99 --- /dev/null +++ b/packages/native/src/lib/helpers/types.ts @@ -0,0 +1,34 @@ +import { ImageStyle, StyleProp, TextStyle, ViewStyle } from "react-native"; +import { ReactTestInstance } from "react-test-renderer"; + +/** + * Type representing a style that can be applied to a React Native component. + * It can be a style for text, view, or image components. + */ +export type Style = TextStyle | ViewStyle | ImageStyle; + +/** + * Type for a style prop that can be applied to a React Native component. + * It can be a single style or an array of styles. + */ +export type AssertiveStyle = StyleProp