From 58ac577c2aa1045307c99b7642158e706b530465 Mon Sep 17 00:00:00 2001 From: Thiago Brezinski Date: Mon, 21 Aug 2023 09:55:44 +0100 Subject: [PATCH 1/9] feat: add toBeVisible matcher --- .../__snapshots__/to-be-visible.test.tsx.snap | 24 ++ src/matchers/__tests__/to-be-visible.test.tsx | 206 ++++++++++++++++++ src/matchers/extend-expect.d.ts | 1 + src/matchers/extend-expect.ts | 2 + src/matchers/index.tsx | 1 + src/matchers/to-be-visible.tsx | 64 ++++++ 6 files changed, 298 insertions(+) create mode 100644 src/matchers/__tests__/__snapshots__/to-be-visible.test.tsx.snap create mode 100644 src/matchers/__tests__/to-be-visible.test.tsx create mode 100644 src/matchers/to-be-visible.tsx diff --git a/src/matchers/__tests__/__snapshots__/to-be-visible.test.tsx.snap b/src/matchers/__tests__/__snapshots__/to-be-visible.test.tsx.snap new file mode 100644 index 000000000..4d4108e4e --- /dev/null +++ b/src/matchers/__tests__/__snapshots__/to-be-visible.test.tsx.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`toBeVisible() throws an error when expectation is not matched 1`] = ` +"expect(element).not.toBeVisible() + +Received element is visible: + " +`; + +exports[`toBeVisible() throws an error when expectation is not matched 2`] = ` +"expect(element).toBeVisible() + +Received element is not visible: + " +`; diff --git a/src/matchers/__tests__/to-be-visible.test.tsx b/src/matchers/__tests__/to-be-visible.test.tsx new file mode 100644 index 000000000..25ceec927 --- /dev/null +++ b/src/matchers/__tests__/to-be-visible.test.tsx @@ -0,0 +1,206 @@ +import * as React from 'react'; +import { View, Modal, Pressable } from 'react-native'; +import { render } from '../..'; +import '../extend-expect'; + +test('toBeVisible() on empty view', () => { + const { getByTestId } = render(); + expect(getByTestId('test')).toBeVisible(); +}); + +test('toBeVisible() on view with opacity', () => { + const { getByTestId } = render( + + ); + expect(getByTestId('test')).toBeVisible(); +}); + +test('toBeVisible() on view with 0 opacity', () => { + const { getByTestId } = render(); + expect(getByTestId('test')).not.toBeVisible(); +}); + +test('toBeVisible() on view with display "none"', () => { + const { getByTestId } = render( + + ); + expect( + getByTestId('test', { includeHiddenElements: true }) + ).not.toBeVisible(); +}); + +test('toBeVisible() on ancestor view with 0 opacity', () => { + const { getByTestId } = render( + + + + + + ); + expect( + getByTestId('test', { includeHiddenElements: true }) + ).not.toBeVisible(); +}); + +test('toBeVisible() on ancestor view with display "none"', () => { + const { getByTestId } = render( + + + + + + ); + expect( + getByTestId('test', { includeHiddenElements: true }) + ).not.toBeVisible(); +}); + +test('toBeVisible() on empty Modal', () => { + const { getByTestId } = render(); + expect(getByTestId('test')).toBeVisible(); +}); + +test('toBeVisible() on view within modal', () => { + const { getByTestId } = render( + + + + + + ); + expect(getByTestId('view-within-modal')).toBeVisible(); +}); + +test('toBeVisible() on view within not visible modal', () => { + const { getByTestId, queryByTestId } = render( + + + + + + ); + + expect(getByTestId('test')).not.toBeVisible(); + // Children elements of not visible modals are not rendered. + expect(() => + expect(getByTestId('view-within-modal')).not.toBeVisible() + ).toThrow(); + expect(queryByTestId('view-within-modal')).toBeNull(); +}); + +test('toBeVisible() on not visible modal', () => { + const { getByTestId } = render(); + expect( + getByTestId('test', { includeHiddenElements: true }) + ).not.toBeVisible(); +}); + +test('toBeVisible() on inaccessible view', () => { + const { getByTestId, update } = render( + + ); + expect( + getByTestId('test', { includeHiddenElements: true }) + ).not.toBeVisible(); + + update(); + expect(getByTestId('test')).toBeVisible(); +}); + +test('toBeVisible() on view within inaccessible view', () => { + const { getByTestId } = render( + + + + + + ); + expect( + getByTestId('test', { includeHiddenElements: true }) + ).not.toBeVisible(); +}); + +test('toBeVisible() on inaccessible view (iOS)', () => { + const { getByTestId, update } = render( + + ); + expect( + getByTestId('test', { includeHiddenElements: true }) + ).not.toBeVisible(); + + update(); + expect(getByTestId('test')).toBeVisible(); +}); + +test('toBeVisible() on view within inaccessible view (iOS)', () => { + const { getByTestId } = render( + + + + + + ); + expect( + getByTestId('test', { includeHiddenElements: true }) + ).not.toBeVisible(); +}); + +test('toBeVisible() on inaccessible view (Android)', () => { + const { getByTestId, update } = render( + + ); + expect( + getByTestId('test', { includeHiddenElements: true }) + ).not.toBeVisible(); + + update(); + expect(getByTestId('test')).toBeVisible(); +}); + +test('toBeVisible() on view within inaccessible view (Android)', () => { + const { getByTestId } = render( + + + + + + ); + expect( + getByTestId('test', { includeHiddenElements: true }) + ).not.toBeVisible(); +}); + +test('toBeVisible() on null elements', () => { + expect(() => expect(null).toBeVisible()).toThrowErrorMatchingInlineSnapshot(` + "expect(received).toBeVisible() + + received value must be a host element. + Received has value: null" + `); +}); + +test('toBeVisible() on non-React elements', () => { + expect(() => + expect({ name: 'Non-React element' }).not.toBeVisible() + ).toThrow(); + expect(() => expect(true).not.toBeVisible()).toThrow(); +}); + +test('toBeVisible() throws an error when expectation is not matched', () => { + const { getByTestId, update } = render(); + expect(() => + expect(getByTestId('test')).not.toBeVisible() + ).toThrowErrorMatchingSnapshot(); + + update(); + expect(() => + expect(getByTestId('test')).toBeVisible() + ).toThrowErrorMatchingSnapshot(); +}); + +test('toBeVisible() on Pressable with function style prop', () => { + const { getByTestId } = render( + ({ backgroundColor: 'blue' })} /> + ); + expect(getByTestId('test')).toBeVisible(); +}); diff --git a/src/matchers/extend-expect.d.ts b/src/matchers/extend-expect.d.ts index d10520755..7072b020e 100644 --- a/src/matchers/extend-expect.d.ts +++ b/src/matchers/extend-expect.d.ts @@ -3,6 +3,7 @@ import type { TextMatch, TextMatchOptions } from '../matches'; export interface JestNativeMatchers { toBeOnTheScreen(): R; toBeEmptyElement(): R; + toBeVisible(): R; toHaveDisplayValue(expectedValue: TextMatch, options?: TextMatchOptions): R; toHaveTextContent(expectedText: TextMatch, options?: TextMatchOptions): R; } diff --git a/src/matchers/extend-expect.ts b/src/matchers/extend-expect.ts index 188deaa6b..327302c57 100644 --- a/src/matchers/extend-expect.ts +++ b/src/matchers/extend-expect.ts @@ -2,12 +2,14 @@ import { toBeOnTheScreen } from './to-be-on-the-screen'; import { toBeEmptyElement } from './to-be-empty-element'; +import { toBeVisible } from './to-be-visible'; import { toHaveDisplayValue } from './to-have-display-value'; import { toHaveTextContent } from './to-have-text-content'; expect.extend({ toBeOnTheScreen, toBeEmptyElement, + toBeVisible, toHaveDisplayValue, toHaveTextContent, }); diff --git a/src/matchers/index.tsx b/src/matchers/index.tsx index 34adad661..ffe850f05 100644 --- a/src/matchers/index.tsx +++ b/src/matchers/index.tsx @@ -1,2 +1,3 @@ export { toBeOnTheScreen } from './to-be-on-the-screen'; export { toBeEmptyElement } from './to-be-empty-element'; +export { toBeVisible } from './to-be-visible'; diff --git a/src/matchers/to-be-visible.tsx b/src/matchers/to-be-visible.tsx new file mode 100644 index 000000000..4ae45c75a --- /dev/null +++ b/src/matchers/to-be-visible.tsx @@ -0,0 +1,64 @@ +import type { ReactTestInstance } from 'react-test-renderer'; +import { matcherHint } from 'jest-matcher-utils'; +import { StyleSheet } from 'react-native'; +import { getHostParent } from '../helpers/component-tree'; +import { checkHostElement, formatElement } from './utils'; + +function isVisibleForStyles(element: ReactTestInstance) { + const style = element.props.style || {}; + const { display, opacity } = StyleSheet.flatten(style); + return display !== 'none' && opacity !== 0; +} + +function isVisibleForAccessibility(element: ReactTestInstance) { + return ( + !element.props.accessibilityElementsHidden && + element.props.importantForAccessibility !== 'no-hide-descendants' && + !element.props['aria-hidden'] + ); +} + +function isModalVisible(element: ReactTestInstance) { + return element.type.toString() !== 'Modal' || element.props.visible !== false; +} + +function isElementVisible(element: ReactTestInstance): boolean { + let current: ReactTestInstance | null = element; + while (current) { + if ( + !isVisibleForStyles(current) || + !isVisibleForAccessibility(current) || + !isModalVisible(current) + ) { + return false; + } + + current = getHostParent(current); + } + + return true; +} + +export function toBeVisible( + this: jest.MatcherContext, + element: ReactTestInstance +) { + if (element !== null || !this.isNot) { + checkHostElement(element, toBeVisible, this); + } + + const isVisible = isElementVisible(element); + + return { + pass: isVisible, + message: () => { + const is = isVisible ? 'is' : 'is not'; + return [ + matcherHint(`${this.isNot ? '.not' : ''}.toBeVisible`, 'element', ''), + '', + `Received element ${is} visible:`, + formatElement(element), + ].join('\n'); + }, + }; +} From 5a634db65d192560297e7497300e9d03e6ed2f8d Mon Sep 17 00:00:00 2001 From: Thiago Brezinski Date: Thu, 24 Aug 2023 18:24:16 +0100 Subject: [PATCH 2/9] test(toBeVisible): improve snapshot matching --- .../__snapshots__/to-be-visible.test.tsx.snap | 24 ------ src/matchers/__tests__/to-be-visible.test.tsx | 80 +++++++++++++------ src/matchers/to-be-visible.tsx | 2 +- 3 files changed, 56 insertions(+), 50 deletions(-) delete mode 100644 src/matchers/__tests__/__snapshots__/to-be-visible.test.tsx.snap diff --git a/src/matchers/__tests__/__snapshots__/to-be-visible.test.tsx.snap b/src/matchers/__tests__/__snapshots__/to-be-visible.test.tsx.snap deleted file mode 100644 index 4d4108e4e..000000000 --- a/src/matchers/__tests__/__snapshots__/to-be-visible.test.tsx.snap +++ /dev/null @@ -1,24 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`toBeVisible() throws an error when expectation is not matched 1`] = ` -"expect(element).not.toBeVisible() - -Received element is visible: - " -`; - -exports[`toBeVisible() throws an error when expectation is not matched 2`] = ` -"expect(element).toBeVisible() - -Received element is not visible: - " -`; diff --git a/src/matchers/__tests__/to-be-visible.test.tsx b/src/matchers/__tests__/to-be-visible.test.tsx index 25ceec927..d9df8361f 100644 --- a/src/matchers/__tests__/to-be-visible.test.tsx +++ b/src/matchers/__tests__/to-be-visible.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, Modal, Pressable } from 'react-native'; +import { View, Modal } from 'react-native'; import { render } from '../..'; import '../extend-expect'; @@ -60,9 +60,9 @@ test('toBeVisible() on empty Modal', () => { expect(getByTestId('test')).toBeVisible(); }); -test('toBeVisible() on view within modal', () => { +test('toBeVisible() on view within Modal', () => { const { getByTestId } = render( - + @@ -71,7 +71,7 @@ test('toBeVisible() on view within modal', () => { expect(getByTestId('view-within-modal')).toBeVisible(); }); -test('toBeVisible() on view within not visible modal', () => { +test('toBeVisible() on view within not visible Modal', () => { const { getByTestId, queryByTestId } = render( @@ -81,15 +81,22 @@ test('toBeVisible() on view within not visible modal', () => { ); expect(getByTestId('test')).not.toBeVisible(); + // Children elements of not visible modals are not rendered. - expect(() => - expect(getByTestId('view-within-modal')).not.toBeVisible() - ).toThrow(); + expect(() => expect(getByTestId('view-within-modal')).not.toBeVisible()) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with testID: view-within-modal + + " + `); expect(queryByTestId('view-within-modal')).toBeNull(); }); -test('toBeVisible() on not visible modal', () => { +test('toBeVisible() on not visible Modal', () => { const { getByTestId } = render(); + expect( getByTestId('test', { includeHiddenElements: true }) ).not.toBeVisible(); @@ -99,6 +106,7 @@ test('toBeVisible() on inaccessible view', () => { const { getByTestId, update } = render( ); + expect( getByTestId('test', { includeHiddenElements: true }) ).not.toBeVisible(); @@ -180,27 +188,49 @@ test('toBeVisible() on null elements', () => { }); test('toBeVisible() on non-React elements', () => { - expect(() => - expect({ name: 'Non-React element' }).not.toBeVisible() - ).toThrow(); - expect(() => expect(true).not.toBeVisible()).toThrow(); + expect(() => expect({ name: 'Non-React element' }).not.toBeVisible()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(received).not.toBeVisible() + + received value must be a host element. + Received has type: object + Received has value: {"name": "Non-React element"}" + `); + expect(() => expect(true).not.toBeVisible()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(received).not.toBeVisible() + + received value must be a host element. + Received has type: boolean + Received has value: true" + `); }); test('toBeVisible() throws an error when expectation is not matched', () => { const { getByTestId, update } = render(); - expect(() => - expect(getByTestId('test')).not.toBeVisible() - ).toThrowErrorMatchingSnapshot(); + expect(() => expect(getByTestId('test')).not.toBeVisible()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).not.toBeVisible() - update(); - expect(() => - expect(getByTestId('test')).toBeVisible() - ).toThrowErrorMatchingSnapshot(); -}); + Received element is visible: + " + `); -test('toBeVisible() on Pressable with function style prop', () => { - const { getByTestId } = render( - ({ backgroundColor: 'blue' })} /> - ); - expect(getByTestId('test')).toBeVisible(); + update(); + expect(() => expect(getByTestId('test')).toBeVisible()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).toBeVisible() + + Received element is not visible: + " + `); }); diff --git a/src/matchers/to-be-visible.tsx b/src/matchers/to-be-visible.tsx index 4ae45c75a..55d47c050 100644 --- a/src/matchers/to-be-visible.tsx +++ b/src/matchers/to-be-visible.tsx @@ -5,7 +5,7 @@ import { getHostParent } from '../helpers/component-tree'; import { checkHostElement, formatElement } from './utils'; function isVisibleForStyles(element: ReactTestInstance) { - const style = element.props.style || {}; + const style = element.props.style ?? {}; const { display, opacity } = StyleSheet.flatten(style); return display !== 'none' && opacity !== 0; } From 2e2fdff09ecc5bcefc4ddcbc8e4cf5502ae8f22f Mon Sep 17 00:00:00 2001 From: Thiago Brezinski Date: Thu, 24 Aug 2023 18:38:25 +0100 Subject: [PATCH 3/9] chore: detect Modal as host component --- src/config.ts | 1 + src/helpers/host-component-names.tsx | 14 +++++++++++++- src/matchers/to-be-visible.tsx | 3 ++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index 5788f4fba..15522e310 100644 --- a/src/config.ts +++ b/src/config.ts @@ -24,6 +24,7 @@ export type HostComponentNames = { text: string; textInput: string; switch: string; + modal: string; }; export type InternalConfig = Config & { diff --git a/src/helpers/host-component-names.tsx b/src/helpers/host-component-names.tsx index d378424c3..02a984cbd 100644 --- a/src/helpers/host-component-names.tsx +++ b/src/helpers/host-component-names.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { ReactTestInstance } from 'react-test-renderer'; -import { Switch, Text, TextInput, View } from 'react-native'; +import { Modal, Switch, Text, TextInput, View } from 'react-native'; import { configureInternal, getConfig, HostComponentNames } from '../config'; import { renderWithAct } from '../render-act'; import { HostTestInstance } from './component-tree'; @@ -35,6 +35,7 @@ function detectHostComponentNames(): HostComponentNames { Hello + ); @@ -42,6 +43,7 @@ function detectHostComponentNames(): HostComponentNames { text: getByTestId(renderer.root, 'text').type as string, textInput: getByTestId(renderer.root, 'textInput').type as string, switch: getByTestId(renderer.root, 'switch').type as string, + modal: getByTestId(renderer.root, 'modal').type as string, }; } catch (error) { const errorMessage = @@ -86,3 +88,13 @@ export function isHostTextInput( ): element is HostTestInstance { return element?.type === getHostComponentNames().textInput; } + +/** + * Checks if the given element is a host Modal. + * @param element The element to check. + */ +export function isHostModal( + element?: ReactTestInstance | null +): element is HostTestInstance { + return element?.type === getHostComponentNames().modal; +} diff --git a/src/matchers/to-be-visible.tsx b/src/matchers/to-be-visible.tsx index 55d47c050..b12ffff70 100644 --- a/src/matchers/to-be-visible.tsx +++ b/src/matchers/to-be-visible.tsx @@ -2,6 +2,7 @@ import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint } from 'jest-matcher-utils'; import { StyleSheet } from 'react-native'; import { getHostParent } from '../helpers/component-tree'; +import { isHostModal } from '../helpers/host-component-names'; import { checkHostElement, formatElement } from './utils'; function isVisibleForStyles(element: ReactTestInstance) { @@ -19,7 +20,7 @@ function isVisibleForAccessibility(element: ReactTestInstance) { } function isModalVisible(element: ReactTestInstance) { - return element.type.toString() !== 'Modal' || element.props.visible !== false; + return !isHostModal(element) || element.props.visible !== false; } function isElementVisible(element: ReactTestInstance): boolean { From dae1a388283ae66c387b55b3d50e55830cc86f91 Mon Sep 17 00:00:00 2001 From: Thiago Brezinski Date: Thu, 24 Aug 2023 18:54:05 +0100 Subject: [PATCH 4/9] chore: fix tests to host component names --- src/__tests__/config.test.ts | 3 ++- src/__tests__/host-component-names.test.tsx | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 9475fb3f6..a9532d681 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -35,12 +35,13 @@ test('resetToDefaults() resets config to defaults', () => { test('resetToDefaults() resets internal config to defaults', () => { configureInternal({ - hostComponentNames: { text: 'A', textInput: 'A', switch: 'A' }, + hostComponentNames: { text: 'A', textInput: 'A', switch: 'A', modal: 'A' }, }); expect(getConfig().hostComponentNames).toEqual({ text: 'A', textInput: 'A', switch: 'A', + modal: 'A', }); resetToDefaults(); diff --git a/src/__tests__/host-component-names.test.tsx b/src/__tests__/host-component-names.test.tsx index 434fdc36c..881663d87 100644 --- a/src/__tests__/host-component-names.test.tsx +++ b/src/__tests__/host-component-names.test.tsx @@ -21,6 +21,7 @@ describe('getHostComponentNames', () => { text: 'banana', textInput: 'banana', switch: 'banana', + modal: 'banana', }, }); @@ -28,6 +29,7 @@ describe('getHostComponentNames', () => { text: 'banana', textInput: 'banana', switch: 'banana', + modal: 'banana', }); }); @@ -40,6 +42,7 @@ describe('getHostComponentNames', () => { text: 'Text', textInput: 'TextInput', switch: 'RCTSwitch', + modal: 'Modal', }); expect(getConfig().hostComponentNames).toBe(hostComponentNames); }); @@ -68,6 +71,7 @@ describe('configureHostComponentNamesIfNeeded', () => { text: 'Text', textInput: 'TextInput', switch: 'RCTSwitch', + modal: 'Modal', }); }); @@ -77,6 +81,7 @@ describe('configureHostComponentNamesIfNeeded', () => { text: 'banana', textInput: 'banana', switch: 'banana', + modal: 'banana', }, }); @@ -86,6 +91,7 @@ describe('configureHostComponentNamesIfNeeded', () => { text: 'banana', textInput: 'banana', switch: 'banana', + modal: 'banana', }); }); From 660479c743733ec118671e13a3de63ffd7661798 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Fri, 25 Aug 2023 13:10:23 +0200 Subject: [PATCH 5/9] refactor: merge accessibility related code with existing utils --- src/helpers/__tests__/accessiblity.test.tsx | 26 ++++++++ src/helpers/accessiblity.ts | 4 ++ src/matchers/to-be-visible.tsx | 74 ++++++++++----------- 3 files changed, 65 insertions(+), 39 deletions(-) diff --git a/src/helpers/__tests__/accessiblity.test.tsx b/src/helpers/__tests__/accessiblity.test.tsx index d47ebd6a2..07bcbde94 100644 --- a/src/helpers/__tests__/accessiblity.test.tsx +++ b/src/helpers/__tests__/accessiblity.test.tsx @@ -41,6 +41,32 @@ describe('isHiddenFromAccessibility', () => { expect(isHiddenFromAccessibility(null)).toBe(true); }); + test('detects elements with aria-hidden prop', () => { + const view = render(); + expect( + isHiddenFromAccessibility( + view.getByTestId('subject', { + includeHiddenElements: true, + }) + ) + ).toBe(true); + }); + + test('detects nested elements with aria-hidden prop', () => { + const view = render( + + + + ); + expect( + isHiddenFromAccessibility( + view.getByTestId('subject', { + includeHiddenElements: true, + }) + ) + ).toBe(true); + }); + test('detects elements with accessibilityElementsHidden prop', () => { const view = render(); expect( diff --git a/src/helpers/accessiblity.ts b/src/helpers/accessiblity.ts index 99a4af60e..273d84d3b 100644 --- a/src/helpers/accessiblity.ts +++ b/src/helpers/accessiblity.ts @@ -62,6 +62,10 @@ function isSubtreeInaccessible(element: ReactTestInstance): boolean { return false; } + if (element.props['aria-hidden']) { + return true; + } + // iOS: accessibilityElementsHidden // See: https://reactnative.dev/docs/accessibility#accessibilityelementshidden-ios if (element.props.accessibilityElementsHidden) { diff --git a/src/matchers/to-be-visible.tsx b/src/matchers/to-be-visible.tsx index b12ffff70..989db6f2c 100644 --- a/src/matchers/to-be-visible.tsx +++ b/src/matchers/to-be-visible.tsx @@ -1,45 +1,11 @@ import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint } from 'jest-matcher-utils'; import { StyleSheet } from 'react-native'; +import { isHiddenFromAccessibility } from '../helpers/accessiblity'; import { getHostParent } from '../helpers/component-tree'; import { isHostModal } from '../helpers/host-component-names'; import { checkHostElement, formatElement } from './utils'; -function isVisibleForStyles(element: ReactTestInstance) { - const style = element.props.style ?? {}; - const { display, opacity } = StyleSheet.flatten(style); - return display !== 'none' && opacity !== 0; -} - -function isVisibleForAccessibility(element: ReactTestInstance) { - return ( - !element.props.accessibilityElementsHidden && - element.props.importantForAccessibility !== 'no-hide-descendants' && - !element.props['aria-hidden'] - ); -} - -function isModalVisible(element: ReactTestInstance) { - return !isHostModal(element) || element.props.visible !== false; -} - -function isElementVisible(element: ReactTestInstance): boolean { - let current: ReactTestInstance | null = element; - while (current) { - if ( - !isVisibleForStyles(current) || - !isVisibleForAccessibility(current) || - !isModalVisible(current) - ) { - return false; - } - - current = getHostParent(current); - } - - return true; -} - export function toBeVisible( this: jest.MatcherContext, element: ReactTestInstance @@ -48,12 +14,10 @@ export function toBeVisible( checkHostElement(element, toBeVisible, this); } - const isVisible = isElementVisible(element); - return { - pass: isVisible, + pass: isElementVisible(element), message: () => { - const is = isVisible ? 'is' : 'is not'; + const is = this.isNot ? 'is' : 'is not'; return [ matcherHint(`${this.isNot ? '.not' : ''}.toBeVisible`, 'element', ''), '', @@ -63,3 +27,35 @@ export function toBeVisible( }, }; } + +function isElementVisible( + element: ReactTestInstance, + accessibilityCache?: WeakMap +): boolean { + // Use cache to speed up repeated searches by `isHiddenFromAccessibility`. + const cache = accessibilityCache ?? new WeakMap(); + if (isHiddenFromAccessibility(element, { cache })) { + return false; + } + + if (isHiddenForStyles(element)) { + return false; + } + + if (isHostModal(element) && element.props.visible === false) { + return false; + } + + const hostParent = getHostParent(element); + if (hostParent === null) { + return true; + } + + return isElementVisible(hostParent, cache); +} + +function isHiddenForStyles(element: ReactTestInstance) { + const style = element.props.style ?? {}; + const { display, opacity } = StyleSheet.flatten(style); + return display === 'none' || opacity === 0; +} From 2ca7c93221fe35da81c57f67419415d017005847 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Fri, 25 Aug 2023 13:11:10 +0200 Subject: [PATCH 6/9] chore: add comments --- src/matchers/to-be-visible.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/matchers/to-be-visible.tsx b/src/matchers/to-be-visible.tsx index 989db6f2c..f26eda530 100644 --- a/src/matchers/to-be-visible.tsx +++ b/src/matchers/to-be-visible.tsx @@ -42,6 +42,8 @@ function isElementVisible( return false; } + // Note: this seems to be a bug in React Native. + // PR with fix: https://github.com/facebook/react-native/pull/39157 if (isHostModal(element) && element.props.visible === false) { return false; } From ed320ddde56e6c760a6704cae7a18fb41530c088 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Fri, 25 Aug 2023 13:24:54 +0200 Subject: [PATCH 7/9] refactor: tweak unit test --- src/matchers/__tests__/to-be-visible.test.tsx | 253 ++++++++++-------- 1 file changed, 147 insertions(+), 106 deletions(-) diff --git a/src/matchers/__tests__/to-be-visible.test.tsx b/src/matchers/__tests__/to-be-visible.test.tsx index d9df8361f..36898c585 100644 --- a/src/matchers/__tests__/to-be-visible.test.tsx +++ b/src/matchers/__tests__/to-be-visible.test.tsx @@ -1,78 +1,152 @@ import * as React from 'react'; import { View, Modal } from 'react-native'; -import { render } from '../..'; +import { render, screen } from '../..'; import '../extend-expect'; test('toBeVisible() on empty view', () => { - const { getByTestId } = render(); - expect(getByTestId('test')).toBeVisible(); + render(); + + const view = screen.getByTestId('view'); + expect(view).toBeVisible(); + expect(() => expect(view).not.toBeVisible()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).not.toBeVisible() + + Received element is visible: + " + `); }); test('toBeVisible() on view with opacity', () => { - const { getByTestId } = render( - - ); - expect(getByTestId('test')).toBeVisible(); + render(); + + const view = screen.getByTestId('view'); + expect(view).toBeVisible(); + expect(() => expect(view).not.toBeVisible()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).not.toBeVisible() + + Received element is visible: + " + `); }); test('toBeVisible() on view with 0 opacity', () => { - const { getByTestId } = render(); - expect(getByTestId('test')).not.toBeVisible(); + render(); + + const view = screen.getByTestId('view'); + expect(view).not.toBeVisible(); + expect(() => expect(view).toBeVisible()).toThrowErrorMatchingInlineSnapshot(` + "expect(element).toBeVisible() + + Received element is not visible: + " + `); }); test('toBeVisible() on view with display "none"', () => { - const { getByTestId } = render( - - ); - expect( - getByTestId('test', { includeHiddenElements: true }) - ).not.toBeVisible(); + render(); + + const view = screen.getByTestId('view', { includeHiddenElements: true }); + expect(view).not.toBeVisible(); + expect(() => expect(view).toBeVisible()).toThrowErrorMatchingInlineSnapshot(` + "expect(element).toBeVisible() + + Received element is not visible: + " + `); }); test('toBeVisible() on ancestor view with 0 opacity', () => { - const { getByTestId } = render( + render( - + ); - expect( - getByTestId('test', { includeHiddenElements: true }) - ).not.toBeVisible(); + + const view = screen.getByTestId('view'); + expect(view).not.toBeVisible(); + expect(() => expect(view).toBeVisible()).toThrowErrorMatchingInlineSnapshot(` + "expect(element).toBeVisible() + + Received element is not visible: + " + `); }); test('toBeVisible() on ancestor view with display "none"', () => { - const { getByTestId } = render( + render( - + ); - expect( - getByTestId('test', { includeHiddenElements: true }) - ).not.toBeVisible(); + + const view = screen.getByTestId('view', { includeHiddenElements: true }); + expect(view).not.toBeVisible(); + expect(() => expect(view).toBeVisible()).toThrowErrorMatchingInlineSnapshot(` + "expect(element).toBeVisible() + + Received element is not visible: + " + `); }); test('toBeVisible() on empty Modal', () => { - const { getByTestId } = render(); - expect(getByTestId('test')).toBeVisible(); + render(); + + const modal = screen.getByTestId('modal'); + expect(modal).toBeVisible(); + expect(() => + expect(modal).not.toBeVisible() + ).toThrowErrorMatchingInlineSnapshot( + `"expect(...).not.oBeVisible is not a function"` + ); }); test('toBeVisible() on view within Modal', () => { - const { getByTestId } = render( + render( ); - expect(getByTestId('view-within-modal')).toBeVisible(); + expect(screen.getByTestId('view-within-modal')).toBeVisible(); }); test('toBeVisible() on view within not visible Modal', () => { - const { getByTestId, queryByTestId } = render( + render( @@ -80,68 +154,62 @@ test('toBeVisible() on view within not visible Modal', () => { ); - expect(getByTestId('test')).not.toBeVisible(); + expect(screen.getByTestId('test')).not.toBeVisible(); // Children elements of not visible modals are not rendered. - expect(() => expect(getByTestId('view-within-modal')).not.toBeVisible()) - .toThrowErrorMatchingInlineSnapshot(` + expect(screen.getByTestId('test')).not.toBeVisible(); + expect(() => + expect(screen.getByTestId('view-within-modal')).not.toBeVisible() + ).toThrowErrorMatchingInlineSnapshot(` "Unable to find an element with testID: view-within-modal " `); - expect(queryByTestId('view-within-modal')).toBeNull(); }); test('toBeVisible() on not visible Modal', () => { - const { getByTestId } = render(); + render(); - expect( - getByTestId('test', { includeHiddenElements: true }) - ).not.toBeVisible(); + expect(screen.getByTestId('test')).not.toBeVisible(); }); test('toBeVisible() on inaccessible view', () => { - const { getByTestId, update } = render( - - ); + render(); - expect( - getByTestId('test', { includeHiddenElements: true }) - ).not.toBeVisible(); + const test = screen.getByTestId('test', { includeHiddenElements: true }); + expect(test).not.toBeVisible(); - update(); - expect(getByTestId('test')).toBeVisible(); + screen.update(); + expect(test).toBeVisible(); }); test('toBeVisible() on view within inaccessible view', () => { - const { getByTestId } = render( - + render( + ); expect( - getByTestId('test', { includeHiddenElements: true }) + screen.getByTestId('test', { includeHiddenElements: true }) ).not.toBeVisible(); }); test('toBeVisible() on inaccessible view (iOS)', () => { - const { getByTestId, update } = render( - - ); - expect( - getByTestId('test', { includeHiddenElements: true }) - ).not.toBeVisible(); + render(); + + const test = screen.getByTestId('test', { includeHiddenElements: true }); + expect(test).not.toBeVisible(); - update(); - expect(getByTestId('test')).toBeVisible(); + screen.update(); + expect(test).toBeVisible(); }); test('toBeVisible() on view within inaccessible view (iOS)', () => { - const { getByTestId } = render( + render( @@ -149,24 +217,24 @@ test('toBeVisible() on view within inaccessible view (iOS)', () => { ); expect( - getByTestId('test', { includeHiddenElements: true }) + screen.getByTestId('test', { includeHiddenElements: true }) ).not.toBeVisible(); }); test('toBeVisible() on inaccessible view (Android)', () => { - const { getByTestId, update } = render( + render( ); - expect( - getByTestId('test', { includeHiddenElements: true }) - ).not.toBeVisible(); - update(); - expect(getByTestId('test')).toBeVisible(); + const test = screen.getByTestId('test', { includeHiddenElements: true }); + expect(test).not.toBeVisible(); + + screen.update(); + expect(test).toBeVisible(); }); test('toBeVisible() on view within inaccessible view (Android)', () => { - const { getByTestId } = render( + render( @@ -174,11 +242,12 @@ test('toBeVisible() on view within inaccessible view (Android)', () => { ); expect( - getByTestId('test', { includeHiddenElements: true }) + screen.getByTestId('test', { includeHiddenElements: true }) ).not.toBeVisible(); }); test('toBeVisible() on null elements', () => { + expect(null).not.toBeVisible(); expect(() => expect(null).toBeVisible()).toThrowErrorMatchingInlineSnapshot(` "expect(received).toBeVisible() @@ -190,47 +259,19 @@ test('toBeVisible() on null elements', () => { test('toBeVisible() on non-React elements', () => { expect(() => expect({ name: 'Non-React element' }).not.toBeVisible()) .toThrowErrorMatchingInlineSnapshot(` - "expect(received).not.toBeVisible() - - received value must be a host element. - Received has type: object - Received has value: {"name": "Non-React element"}" - `); - expect(() => expect(true).not.toBeVisible()) - .toThrowErrorMatchingInlineSnapshot(` - "expect(received).not.toBeVisible() - - received value must be a host element. - Received has type: boolean - Received has value: true" - `); -}); - -test('toBeVisible() throws an error when expectation is not matched', () => { - const { getByTestId, update } = render(); - expect(() => expect(getByTestId('test')).not.toBeVisible()) - .toThrowErrorMatchingInlineSnapshot(` - "expect(element).not.toBeVisible() + "expect(received).not.toBeVisible() - Received element is visible: - " - `); + received value must be a host element. + Received has type: object + Received has value: {"name": "Non-React element"}" + `); - update(); - expect(() => expect(getByTestId('test')).toBeVisible()) + expect(() => expect(true).not.toBeVisible()) .toThrowErrorMatchingInlineSnapshot(` - "expect(element).toBeVisible() + "expect(received).not.toBeVisible() - Received element is not visible: - " - `); + received value must be a host element. + Received has type: boolean + Received has value: true" + `); }); From d79ffa047a2e37a2e45f34bd9ddfad9e01ec1e12 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Fri, 25 Aug 2023 13:28:58 +0200 Subject: [PATCH 8/9] chore: fix test --- src/matchers/__tests__/to-be-visible.test.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/matchers/__tests__/to-be-visible.test.tsx b/src/matchers/__tests__/to-be-visible.test.tsx index 36898c585..4839e06f6 100644 --- a/src/matchers/__tests__/to-be-visible.test.tsx +++ b/src/matchers/__tests__/to-be-visible.test.tsx @@ -127,11 +127,17 @@ test('toBeVisible() on empty Modal', () => { const modal = screen.getByTestId('modal'); expect(modal).toBeVisible(); - expect(() => - expect(modal).not.toBeVisible() - ).toThrowErrorMatchingInlineSnapshot( - `"expect(...).not.oBeVisible is not a function"` - ); + expect(() => expect(modal).not.toBeVisible()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).not.toBeVisible() + + Received element is visible: + " + `); }); test('toBeVisible() on view within Modal', () => { From f86c6c75606b89b44696acdce50c6fd433b853b5 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Fri, 25 Aug 2023 13:39:26 +0200 Subject: [PATCH 9/9] refactor: tweaks --- src/matchers/__tests__/to-be-visible.test.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/matchers/__tests__/to-be-visible.test.tsx b/src/matchers/__tests__/to-be-visible.test.tsx index 4839e06f6..ddcb439c5 100644 --- a/src/matchers/__tests__/to-be-visible.test.tsx +++ b/src/matchers/__tests__/to-be-visible.test.tsx @@ -163,10 +163,9 @@ test('toBeVisible() on view within not visible Modal', () => { expect(screen.getByTestId('test')).not.toBeVisible(); // Children elements of not visible modals are not rendered. - expect(screen.getByTestId('test')).not.toBeVisible(); - expect(() => - expect(screen.getByTestId('view-within-modal')).not.toBeVisible() - ).toThrowErrorMatchingInlineSnapshot(` + expect(screen.queryByTestId('view-within-modal')).not.toBeVisible(); + expect(() => expect(screen.getByTestId('view-within-modal')).toBeVisible()) + .toThrowErrorMatchingInlineSnapshot(` "Unable to find an element with testID: view-within-modal