Skip to content

Commit d3024cf

Browse files
kdquistanchalalopenchiACR1209JoseLionasimpletune
authored
feat(native): Add toContainElement() (#146)
Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Carolina López <clopez@stackbuilders.com> Co-authored-by: Carolina Lopez <calopez@twilio.com> Co-authored-by: Andrés Alejandro Coronel Rodrigues <78624635+ACR1209@users.noreply.github.com> Co-authored-by: Jose Luis Leon <joseluis5000l@gmail.com> Co-authored-by: Spencer Scorcelletti <2670813+asimpletune@users.noreply.github.com> Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: Edwin Hernández <67283172+fonsiher@users.noreply.github.com> Co-authored-by: Carolina López <lopenchii@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Suany Chalan <79164262+suany0805@users.noreply.github.com> Co-authored-by: Sebastián Cruz <129121364+SbsCruz@users.noreply.github.com> Co-authored-by: Juan Diego Osorio <152449879+JDOM10@users.noreply.github.com> Co-authored-by: Sebas Cruz <sebas.cruz750@gmail.com>
1 parent 11be685 commit d3024cf

9 files changed

Lines changed: 954 additions & 116 deletions

File tree

‎packages/native/src/lib/ElementAssertion.ts‎

Lines changed: 142 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { Assertion, AssertionError } from "@assertive-ts/core";
22
import { get } from "dot-prop-immutable";
3-
import { Children } from "react";
43
import { ReactTestInstance } from "react-test-renderer";
54

6-
import { instanceToString } from "./helpers/helpers";
5+
import { isAncestorDisabled, isElementDisabled, isAncestorNotVisible, isElementVisible } from "./helpers/accesibility";
6+
import { getFlattenedStyle, styleToString } from "./helpers/styles";
7+
import { getTextContent, textMatches } from "./helpers/text";
8+
import { AssertiveStyle, TestableTextMatcher } from "./helpers/types";
9+
import { isEmpty, instanceToString, isElementContained } from "./helpers/utils";
710

811
export class ElementAssertion extends Assertion<ReactTestInstance> {
912
public constructor(actual: ReactTestInstance) {
@@ -35,7 +38,7 @@ export class ElementAssertion extends Assertion<ReactTestInstance> {
3538
});
3639

3740
return this.execute({
38-
assertWhen: this.isElementDisabled(this.actual) || this.isAncestorDisabled(this.actual),
41+
assertWhen: isElementDisabled(this.actual) || isAncestorDisabled(this.actual),
3942
error,
4043
invertedError,
4144
});
@@ -61,7 +64,7 @@ export class ElementAssertion extends Assertion<ReactTestInstance> {
6164
});
6265

6366
return this.execute({
64-
assertWhen: !this.isElementDisabled(this.actual) && !this.isAncestorDisabled(this.actual),
67+
assertWhen: !isElementDisabled(this.actual) && !isAncestorDisabled(this.actual),
6568
error,
6669
invertedError,
6770
});
@@ -88,7 +91,7 @@ export class ElementAssertion extends Assertion<ReactTestInstance> {
8891
});
8992

9093
return this.execute({
91-
assertWhen: Children.count(this.actual.props.children) === 0,
94+
assertWhen: isEmpty(this.actual.children),
9295
error,
9396
invertedError,
9497
});
@@ -115,49 +118,152 @@ export class ElementAssertion extends Assertion<ReactTestInstance> {
115118
});
116119

117120
return this.execute({
118-
assertWhen: this.isElementVisible(this.actual) && this.isAncestorVisible(this.actual),
121+
assertWhen: isElementVisible(this.actual) && !isAncestorNotVisible(this.actual),
119122
error,
120123
invertedError,
121124
});
122125
}
123126

124-
private isElementDisabled(element: ReactTestInstance): boolean {
125-
const { type } = element;
126-
const elementType = type.toString();
127-
if (elementType === "TextInput" && element?.props?.editable === false) {
128-
return true;
129-
}
130-
131-
return (
132-
get(element, "props.aria-disabled")
133-
|| get(element, "props.disabled", false)
134-
|| get(element, "props.accessibilityState.disabled", false)
135-
|| get<ReactTestInstance, string[]>(element, "props.accessibilityStates", []).includes("disabled")
136-
);
127+
/**
128+
* Check if an element is contained within another element.
129+
*
130+
* @example
131+
* ```
132+
* expect(parent).toContainElement(child);
133+
* ```
134+
*
135+
* @param element - The element to check for.
136+
* @returns the assertion instance
137+
*/
138+
public toContainElement(element: ReactTestInstance): this {
139+
const error = new AssertionError({
140+
actual: this.actual,
141+
message: `Expected element ${this.toString()} to contain element ${instanceToString(element)}.`,
142+
});
143+
const invertedError = new AssertionError({
144+
actual: this.actual,
145+
message: `Expected element ${this.toString()} NOT to contain element ${instanceToString(element)}.`,
146+
});
147+
148+
return this.execute({
149+
assertWhen: isElementContained(this.actual, element),
150+
error,
151+
invertedError,
152+
});
137153
}
138154

139-
private isAncestorDisabled(element: ReactTestInstance): boolean {
140-
const { parent } = element;
141-
return parent !== null && (this.isElementDisabled(element) || this.isAncestorDisabled(parent));
155+
/**
156+
* Check if the element has a specific property or a specific property value.
157+
*
158+
* @example
159+
* ```
160+
* expect(element).toHaveProp("propName");
161+
* expect(element).toHaveProp("propName", "propValue");
162+
* ```
163+
*
164+
* @param propName - The name of the prop to check for.
165+
* @param value - The value of the prop to check for.
166+
* @returns the assertion instance
167+
*/
168+
public toHaveProp(propName: string, value?: unknown): this {
169+
const propValue: unknown = get(this.actual, `props.${propName}`, undefined);
170+
const hasProp = propValue !== undefined;
171+
const isPropEqual = value === undefined || propValue === value;
172+
173+
const errorMessage = value === undefined
174+
? `Expected element ${this.toString()} to have prop '${propName}'.`
175+
: `Expected element ${this.toString()} to have prop '${propName}' with value '${String(value)}'.`;
176+
177+
const invertedErrorMessage = value === undefined
178+
? `Expected element ${this.toString()} NOT to have prop '${propName}'.`
179+
: `Expected element ${this.toString()} NOT to have prop '${propName}' with value '${String(value)}'.`;
180+
181+
const error = new AssertionError({ actual: this.actual, message: errorMessage });
182+
const invertedError = new AssertionError({ actual: this.actual, message: invertedErrorMessage });
183+
184+
return this.execute({
185+
assertWhen: hasProp && isPropEqual,
186+
error,
187+
invertedError,
188+
});
142189
}
143190

144-
private isElementVisible(element: ReactTestInstance): boolean {
145-
const { type } = element;
191+
/**
192+
* Asserts that a component has the specified style(s) applied.
193+
*
194+
* This method supports both single style objects and arrays of style objects.
195+
* It checks if all specified style properties match on the target element.
196+
*
197+
* @example
198+
* ```
199+
* expect(element).toHaveStyle({ backgroundColor: "red" });
200+
* expect(element).toHaveStyle([{ backgroundColor: "red" }]);
201+
* ```
202+
*
203+
* @param style - A style object to check for.
204+
* @returns the assertion instance
205+
*/
206+
public toHaveStyle(style: AssertiveStyle): this {
207+
const stylesOnElement: AssertiveStyle = get(this.actual, "props.style", {});
208+
209+
const flattenedElementStyle = getFlattenedStyle(stylesOnElement);
210+
const flattenedStyle = getFlattenedStyle(style);
211+
212+
const hasStyle = Object.keys(flattenedStyle)
213+
.every(key => flattenedElementStyle[key] === flattenedStyle[key]);
214+
215+
const error = new AssertionError({
216+
actual: this.actual,
217+
message: `Expected element ${this.toString()} to have style: \n${styleToString(flattenedStyle)}`,
218+
});
146219

147-
if (type.toString() === "Modal") {
148-
return Boolean(element.props?.visible);
149-
}
220+
const invertedError = new AssertionError({
221+
actual: this.actual,
222+
message: `Expected element ${this.toString()} NOT to have style: \n${styleToString(flattenedStyle)}`,
223+
});
150224

151-
return (
152-
get(element, "props.style.display") !== "none"
153-
&& get(element, "props.style.opacity") !== 0
154-
&& get(element, "props.accessibilityElementsHidden") !== true
155-
&& get(element, "props.importantForAccessibility") !== "no-hide-descendants"
156-
);
225+
return this.execute({
226+
assertWhen: hasStyle,
227+
error,
228+
invertedError,
229+
});
157230
}
158231

159-
private isAncestorVisible(element: ReactTestInstance): boolean {
160-
const { parent } = element;
161-
return parent === null || (this.isElementVisible(parent) && this.isAncestorVisible(parent));
232+
/**
233+
* Check if the element has text content matching the provided string,
234+
* RegExp, or function.
235+
*
236+
* @example
237+
* ```
238+
* expect(element).toHaveTextContent("Hello World");
239+
* expect(element).toHaveTextContent(/Hello/);
240+
* expect(element).toHaveTextContent(text => text.startsWith("Hello"));
241+
* ```
242+
*
243+
* @param text - The text to check for.
244+
* @returns the assertion instance
245+
*/
246+
public toHaveTextContent(text: TestableTextMatcher): this {
247+
const actualTextContent = getTextContent(this.actual);
248+
const matchesText = textMatches(actualTextContent, text);
249+
250+
const error = new AssertionError({
251+
actual: this.actual,
252+
message: `Expected element ${this.toString()} to have text content matching '` +
253+
`${text.toString()}'.`,
254+
});
255+
256+
const invertedError = new AssertionError({
257+
actual: this.actual,
258+
message:
259+
`Expected element ${this.toString()} NOT to have text content matching '` +
260+
`${text.toString()}'.`,
261+
});
262+
263+
return this.execute({
264+
assertWhen: matchesText,
265+
error,
266+
invertedError,
267+
});
162268
}
163269
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { get } from "dot-prop-immutable";
2+
import { ReactTestInstance } from "react-test-renderer";
3+
4+
export function isElementDisabled(element: ReactTestInstance): boolean {
5+
const { type } = element;
6+
const elementType = type.toString();
7+
if (elementType === "TextInput" && element?.props?.editable === false) {
8+
return true;
9+
}
10+
return (
11+
get(element, "props.aria-disabled")
12+
|| get(element, "props.disabled", false)
13+
|| get(element, "props.accessibilityState.disabled", false)
14+
|| get<ReactTestInstance, string[]>(element, "props.accessibilityStates", []).includes("disabled")
15+
);
16+
}
17+
18+
export function isAncestorDisabled(element: ReactTestInstance): boolean {
19+
const { parent } = element;
20+
return parent !== null && (isElementDisabled(element) || isAncestorDisabled(parent));
21+
}
22+
export function isElementVisible(element: ReactTestInstance): boolean {
23+
const { type } = element;
24+
const elementType = type.toString();
25+
if (elementType === "Modal" && !element?.props?.visible === true) {
26+
return false;
27+
}
28+
return (
29+
get(element, "props.style.display") !== "none"
30+
&& get(element, "props.style.opacity") !== 0
31+
&& get(element, "props.accessibilityElementsHidden") !== true
32+
&& get(element, "props.importantForAccessibility") !== "no-hide-descendants"
33+
);
34+
}
35+
export function isAncestorNotVisible(element: ReactTestInstance): boolean {
36+
const { parent } = element;
37+
return parent !== null && (!isElementVisible(element) || isAncestorNotVisible(parent));
38+
}

‎packages/native/src/lib/helpers/helpers.ts‎

Lines changed: 0 additions & 15 deletions
This file was deleted.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { StyleSheet } from "react-native";
2+
3+
import { AssertiveStyle, StyleObject } from "./types";
4+
5+
export function getFlattenedStyle(style: AssertiveStyle): StyleObject {
6+
const flattenedStyle = StyleSheet.flatten(style);
7+
return flattenedStyle ? (flattenedStyle as StyleObject) : {};
8+
}
9+
10+
export function styleToString(flattenedStyle: StyleObject): string {
11+
const styleEntries = Object.entries(flattenedStyle);
12+
return styleEntries.map(([key, value]) => `\t- ${key}: ${String(value)};`).join("\n");
13+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { ReactTestInstance } from "react-test-renderer";
2+
3+
import { TestableTextMatcher, TextContent } from "./types";
4+
5+
function collectText (element: TextContent): string[] {
6+
if (typeof element === "string") {
7+
return [element];
8+
}
9+
10+
if (Array.isArray(element)) {
11+
return element.flatMap(child => collectText(child));
12+
}
13+
14+
if (element && (typeof element === "object" && "props" in element)) {
15+
const value = element.props?.value as TextContent;
16+
if (typeof value === "string") {
17+
return [value];
18+
}
19+
20+
const children = (element.props?.children as ReactTestInstance[]) ?? element.children;
21+
if (!children) {
22+
return [];
23+
}
24+
25+
return Array.isArray(children)
26+
? children.flatMap(collectText)
27+
: collectText(children);
28+
}
29+
30+
return [];
31+
}
32+
33+
export function getTextContent(element: ReactTestInstance): string {
34+
if (!element) {
35+
return "";
36+
}
37+
if (typeof element === "string") {
38+
return element;
39+
}
40+
if (typeof element.props?.value === "string") {
41+
return element.props.value;
42+
}
43+
44+
return collectText(element).join(" ");
45+
}
46+
47+
export function textMatches(
48+
text: string,
49+
matcher: TestableTextMatcher,
50+
): boolean {
51+
if (typeof matcher === "string") {
52+
return text.includes(matcher);
53+
}
54+
55+
if (matcher instanceof RegExp) {
56+
return matcher.test(text);
57+
}
58+
59+
if (typeof matcher === "function") {
60+
return matcher(text);
61+
}
62+
63+
throw new Error("Matcher must be a string, RegExp, or function.");
64+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { ImageStyle, StyleProp, TextStyle, ViewStyle } from "react-native";
2+
import { ReactTestInstance } from "react-test-renderer";
3+
4+
type Style = TextStyle | ViewStyle | ImageStyle;
5+
6+
export type AssertiveStyle = StyleProp<Style>;
7+
8+
export type StyleObject = Record<string, unknown>;
9+
10+
export type TestableTextMatcher = string | RegExp | ((text: string) => boolean);
11+
12+
export type TextContent = string | ReactTestInstance | ReactTestInstance[];

0 commit comments

Comments
 (0)