diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 47cdc6627..aca62f304 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -33,6 +33,7 @@ test('resetToDefaults() resets internal config to defaults', () => { hostComponentNames: { text: 'A', textInput: 'A', + image: 'A', switch: 'A', scrollView: 'A', modal: 'A', @@ -41,6 +42,7 @@ test('resetToDefaults() resets internal config to defaults', () => { expect(getConfig().hostComponentNames).toEqual({ text: 'A', textInput: 'A', + image: 'A', switch: 'A', scrollView: 'A', modal: 'A', diff --git a/src/__tests__/host-component-names.test.tsx b/src/__tests__/host-component-names.test.tsx index ca526742d..0e55f1a82 100644 --- a/src/__tests__/host-component-names.test.tsx +++ b/src/__tests__/host-component-names.test.tsx @@ -14,6 +14,7 @@ describe('getHostComponentNames', () => { hostComponentNames: { text: 'banana', textInput: 'banana', + image: 'banana', switch: 'banana', scrollView: 'banana', modal: 'banana', @@ -23,6 +24,7 @@ describe('getHostComponentNames', () => { expect(getHostComponentNames()).toEqual({ text: 'banana', textInput: 'banana', + image: 'banana', switch: 'banana', scrollView: 'banana', modal: 'banana', @@ -37,6 +39,7 @@ describe('getHostComponentNames', () => { expect(hostComponentNames).toEqual({ text: 'Text', textInput: 'TextInput', + image: 'Image', switch: 'RCTSwitch', scrollView: 'RCTScrollView', modal: 'Modal', @@ -67,6 +70,7 @@ describe('configureHostComponentNamesIfNeeded', () => { expect(getConfig().hostComponentNames).toEqual({ text: 'Text', textInput: 'TextInput', + image: 'Image', switch: 'RCTSwitch', scrollView: 'RCTScrollView', modal: 'Modal', @@ -78,6 +82,7 @@ describe('configureHostComponentNamesIfNeeded', () => { hostComponentNames: { text: 'banana', textInput: 'banana', + image: 'banana', switch: 'banana', scrollView: 'banana', modal: 'banana', @@ -89,13 +94,14 @@ describe('configureHostComponentNamesIfNeeded', () => { expect(getConfig().hostComponentNames).toEqual({ text: 'banana', textInput: 'banana', + image: 'banana', switch: 'banana', scrollView: 'banana', modal: 'banana', }); }); - test('throw an error when autodetection fails', () => { + test('throw an error when auto-detection fails', () => { const mockCreate = jest.spyOn(TestRenderer, 'create') as jest.Mock; const renderer = TestRenderer.create(); diff --git a/src/__tests__/react-native-api.test.tsx b/src/__tests__/react-native-api.test.tsx index 7393fd4b1..3ce5fc5f2 100644 --- a/src/__tests__/react-native-api.test.tsx +++ b/src/__tests__/react-native-api.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { FlatList, ScrollView, Switch, Text, TextInput, View } from 'react-native'; +import { FlatList, Image, Modal, ScrollView, Switch, Text, TextInput, View } from 'react-native'; import { render, screen } from '..'; /** @@ -7,7 +7,7 @@ import { render, screen } from '..'; * changed in a way that may impact our code like queries or event handling. */ -test('React Native API assumption: renders single host element', () => { +test('React Native API assumption: renders a single host element', () => { render(); expect(screen.toJSON()).toMatchInlineSnapshot(` @@ -17,7 +17,7 @@ test('React Native API assumption: renders single host element', () => { `); }); -test('React Native API assumption: renders single host element', () => { +test('React Native API assumption: renders a single host element', () => { render(Hello); expect(screen.toJSON()).toMatchInlineSnapshot(` @@ -29,7 +29,7 @@ test('React Native API assumption: renders single host element', () => { `); }); -test('React Native API assumption: nested renders single host element', () => { +test('React Native API assumption: nested renders a single host element', () => { render( Before @@ -63,7 +63,7 @@ test('React Native API assumption: nested renders single host element', ( `); }); -test('React Native API assumption: renders single host element', () => { +test('React Native API assumption: renders a single host element', () => { render( with nested Text renders single h `); }); -test('React Native API assumption: renders single host element', () => { +test('React Native API assumption: renders a single host element', () => { render(); expect(screen.toJSON()).toMatchInlineSnapshot(` @@ -123,7 +123,117 @@ test('React Native API assumption: renders single host element', () => `); }); -test('React Native API assumption: aria-* props render on host View', () => { +test('React Native API assumption: renders a single host element', () => { + render(Alt text); + + expect(screen.toJSON()).toMatchInlineSnapshot(` + Alt text + `); +}); + +test('React Native API assumption: renders a single host element', () => { + render( + + + , + ); + + expect(screen.toJSON()).toMatchInlineSnapshot(` + + + + + + `); +}); + +test('React Native API assumption: renders a single host element', () => { + render( + {item}} />, + ); + + expect(screen.toJSON()).toMatchInlineSnapshot(` + + + + + 1 + + + + + 2 + + + + + `); +}); + +test('React Native API assumption: renders a single host element', () => { + render( + + Modal Content + , + ); + + expect(screen.toJSON()).toMatchInlineSnapshot(` + + + Modal Content + + + `); +}); + +test('React Native API assumption: aria-* props render directly on host View', () => { render( { `); }); -test('React Native API assumption: aria-* props render on host Text', () => { +test('React Native API assumption: aria-* props render directly on host Text', () => { render( { `); }); -test('React Native API assumption: aria-* props render on host TextInput', () => { +test('React Native API assumption: aria-* props render directly on host TextInput', () => { render( /> `); }); - -test('ScrollView renders correctly', () => { - render( - - - , - ); - - expect(screen.toJSON()).toMatchInlineSnapshot(` - - - - - - `); -}); - -test('FlatList renders correctly', () => { - render( - {item}} />, - ); - - expect(screen.toJSON()).toMatchInlineSnapshot(` - - - - - 1 - - - - - 2 - - - - - `); -}); diff --git a/src/config.ts b/src/config.ts index fd867a895..c343a3e15 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,6 +23,7 @@ export type ConfigAliasOptions = { export type HostComponentNames = { text: string; textInput: string; + image: string; switch: string; scrollView: string; modal: string; diff --git a/src/helpers/accessibility.ts b/src/helpers/accessibility.ts index 227a47a65..5eee9401a 100644 --- a/src/helpers/accessibility.ts +++ b/src/helpers/accessibility.ts @@ -9,6 +9,7 @@ import { ReactTestInstance } from 'react-test-renderer'; import { getHostSiblings, getUnsafeRootElement } from './component-tree'; import { getHostComponentNames, + isHostImage, isHostSwitch, isHostText, isHostTextInput, @@ -102,6 +103,11 @@ export function isAccessibilityElement(element: ReactTestInstance | null): boole return false; } + // https://github.com/facebook/react-native/blob/8dabed60f456e76a9e53273b601446f34de41fb5/packages/react-native/Libraries/Image/Image.ios.js#L172 + if (isHostImage(element) && element.props.alt !== undefined) { + return true; + } + if (element.props.accessible !== undefined) { return element.props.accessible; } @@ -130,22 +136,50 @@ export function isAccessibilityElement(element: ReactTestInstance | null): boole export function getRole(element: ReactTestInstance): Role | AccessibilityRole { const explicitRole = element.props.role ?? element.props.accessibilityRole; if (explicitRole) { - return explicitRole; + return normalizeRole(explicitRole); } if (isHostText(element)) { return 'text'; } + // Note: host Image elements report "image" role in screen reader only on Android, but not on iOS. + // It's better to require explicit role for Image elements. + return 'none'; } +/** + * There are some duplications between (ARIA) `Role` and `AccessibilityRole` types. + * Resolve them by using ARIA `Role` type where possible. + * + * @param role Role to normalize + * @returns Normalized role + */ +export function normalizeRole(role: string): Role | AccessibilityRole { + if (role === 'image') { + return 'img'; + } + + return role as Role | AccessibilityRole; +} + export function computeAriaModal(element: ReactTestInstance): boolean | undefined { return element.props['aria-modal'] ?? element.props.accessibilityViewIsModal; } export function computeAriaLabel(element: ReactTestInstance): string | undefined { - return element.props['aria-label'] ?? element.props.accessibilityLabel; + const explicitLabel = element.props['aria-label'] ?? element.props.accessibilityLabel; + if (explicitLabel) { + return explicitLabel; + } + + //https://github.com/facebook/react-native/blob/8dabed60f456e76a9e53273b601446f34de41fb5/packages/react-native/Libraries/Image/Image.ios.js#L173 + if (isHostImage(element) && element.props.alt) { + return element.props.alt; + } + + return undefined; } export function computeAriaLabelledBy(element: ReactTestInstance): string | undefined { diff --git a/src/helpers/format-default.ts b/src/helpers/format-default.ts index 223140753..7ee42b5b1 100644 --- a/src/helpers/format-default.ts +++ b/src/helpers/format-default.ts @@ -9,6 +9,7 @@ const propsToDisplay = [ 'accessibilityLabelledBy', 'accessibilityRole', 'accessibilityViewIsModal', + 'alt', 'aria-busy', 'aria-checked', 'aria-disabled', diff --git a/src/helpers/host-component-names.tsx b/src/helpers/host-component-names.tsx index cd4b52239..b450c930b 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 { Modal, ScrollView, Switch, Text, TextInput, View } from 'react-native'; +import { Image, Modal, ScrollView, Switch, Text, TextInput, View } from 'react-native'; import { configureInternal, getConfig, HostComponentNames } from '../config'; import { renderWithAct } from '../render-act'; import { HostTestInstance } from './component-tree'; @@ -34,6 +34,7 @@ function detectHostComponentNames(): HostComponentNames { Hello + @@ -43,6 +44,7 @@ function detectHostComponentNames(): HostComponentNames { return { text: getByTestId(renderer.root, 'text').type as string, textInput: getByTestId(renderer.root, 'textInput').type as string, + image: getByTestId(renderer.root, 'image').type as string, switch: getByTestId(renderer.root, 'switch').type as string, scrollView: getByTestId(renderer.root, 'scrollView').type as string, modal: getByTestId(renderer.root, 'modal').type as string, @@ -85,6 +87,14 @@ export function isHostTextInput(element?: ReactTestInstance | null): element is return element?.type === getHostComponentNames().textInput; } +/** + * Checks if the given element is a host Image element. + * @param element The element to check. + */ +export function isHostImage(element?: ReactTestInstance | null): element is HostTestInstance { + return element?.type === getHostComponentNames().image; +} + /** * Checks if the given element is a host Switch element. * @param element The element to check. diff --git a/src/matchers/__tests__/to-have-accessible-name.test.tsx b/src/matchers/__tests__/to-have-accessible-name.test.tsx index f4ef96128..4bb8f92cc 100644 --- a/src/matchers/__tests__/to-have-accessible-name.test.tsx +++ b/src/matchers/__tests__/to-have-accessible-name.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, Text, TextInput } from 'react-native'; +import { View, Text, TextInput, Image } from 'react-native'; import { render, screen } from '../..'; import '../extend-expect'; @@ -72,17 +72,26 @@ test('toHaveAccessibleName() handles view with "aria-labelledby" prop', () => { expect(element).not.toHaveAccessibleName('Other label'); }); -test('toHaveAccessibleName() handles view with implicit accessible name', () => { +test('toHaveAccessibleName() handles Text with text content', () => { render(Text); + const element = screen.getByTestId('view'); expect(element).toHaveAccessibleName('Text'); expect(element).not.toHaveAccessibleName('Other text'); }); +test('toHaveAccessibleName() handles Image with "alt" prop', () => { + render(Test image); + + const element = screen.getByTestId('image'); + expect(element).toHaveAccessibleName('Test image'); + expect(element).not.toHaveAccessibleName('Other text'); +}); + test('toHaveAccessibleName() supports calling without expected name', () => { render(); - const element = screen.getByTestId('view'); + const element = screen.getByTestId('view'); expect(element).toHaveAccessibleName(); expect(() => expect(element).not.toHaveAccessibleName()).toThrowErrorMatchingInlineSnapshot(` "expect(element).not.toHaveAccessibleName() diff --git a/src/queries/__tests__/hint-text.test.tsx b/src/queries/__tests__/hint-text.test.tsx index 1292bb7b3..2ff9f8419 100644 --- a/src/queries/__tests__/hint-text.test.tsx +++ b/src/queries/__tests__/hint-text.test.tsx @@ -8,11 +8,11 @@ const TEXT_HINT = 'static text'; const NO_MATCHES_TEXT: any = 'not-existent-element'; const getMultipleInstancesFoundMessage = (value: string) => { - return `Found multiple elements with accessibilityHint: ${value}`; + return `Found multiple elements with accessibility hint: ${value}`; }; const getNoInstancesFoundMessage = (value: string) => { - return `Unable to find an element with accessibilityHint: ${value}`; + return `Unable to find an element with accessibility hint: ${value}`; }; const Typography = ({ children, ...rest }: any) => { @@ -114,7 +114,7 @@ test('byHintText queries support hidden option', () => { expect(screen.queryByHintText('hidden', { includeHiddenElements: false })).toBeFalsy(); expect(() => screen.getByHintText('hidden', { includeHiddenElements: false })) .toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with accessibilityHint: hidden + "Unable to find an element with accessibility hint: hidden ); expect(() => screen.getByHintText('FOO')).toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with accessibilityHint: FOO + "Unable to find an element with accessibility hint: FOO screen.getAllByHintText('FOO')).toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with accessibilityHint: FOO + "Unable to find an element with accessibility hint: FOO { expect(screen.getByLabelText(/nested text label/i)).toBe(screen.getByTestId('text-input')); }); +test('getByLabelText supports "Image"" with "alt" prop', () => { + render( + <> + Image Label + , + ); + + const expectedElement = screen.getByTestId('image'); + expect(screen.getByLabelText('Image Label')).toBe(expectedElement); + expect(screen.getByLabelText(/image label/i)).toBe(expectedElement); +}); + test('error message renders the element tree, preserving only helpful props', async () => { render(); diff --git a/src/queries/__tests__/role-value.test.tsx b/src/queries/__tests__/role-value.test.tsx index 56a6e38cd..4b56a8dee 100644 --- a/src/queries/__tests__/role-value.test.tsx +++ b/src/queries/__tests__/role-value.test.tsx @@ -77,7 +77,7 @@ describe('accessibility value', () => { expect(() => screen.getByRole('adjustable', { name: 'Hello', value: { min: 5 } })) .toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "adjustable", name: "Hello", min value: 5 + "Unable to find an element with role: adjustable, name: Hello, min value: 5 { `); expect(() => screen.getByRole('adjustable', { name: 'World', value: { min: 10 } })) .toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "adjustable", name: "World", min value: 10 + "Unable to find an element with role: adjustable, name: World, min value: 10 { `); expect(() => screen.getByRole('adjustable', { name: 'Hello', value: { min: 5 } })) .toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "adjustable", name: "Hello", min value: 5 + "Unable to find an element with role: adjustable, name: Hello, min value: 5 { `); expect(() => screen.getByRole('adjustable', { selected: true, value: { min: 10 } })) .toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "adjustable", selected state: true, min value: 10 + "Unable to find an element with role: adjustable, selected state: true, min value: 10 { - return `Found multiple elements with role: "${value}"`; + return `Found multiple elements with role: ${value}`; }; const getNoInstancesFoundMessage = (value: string) => { - return `Unable to find an element with role: "${value}"`; + return `Unable to find an element with role: ${value}`; }; const Typography = ({ children, ...rest }: any) => { @@ -224,6 +225,25 @@ describe('supports name option', () => { expect(screen.getByRole('header', { name: 'About' })).toBe(screen.getByTestId('target-header')); expect(screen.getByRole('header', { name: 'About' }).props.testID).toBe('target-header'); }); + + test('supports host Image element with "alt" prop', () => { + render( + <> + an elephant + a tiger + , + ); + + const expectedElement1 = screen.getByTestId('image1'); + expect(screen.getByRole('img', { name: 'an elephant' })).toBe(expectedElement1); + expect(screen.getByRole('image', { name: 'an elephant' })).toBe(expectedElement1); + expect(screen.getByRole(/img/, { name: /elephant/ })).toBe(expectedElement1); + + const expectedElement2 = screen.getByTestId('image2'); + expect(screen.getByRole('img', { name: 'a tiger' })).toBe(expectedElement2); + expect(screen.getByRole('image', { name: 'a tiger' })).toBe(expectedElement2); + expect(screen.getByRole(/img/, { name: /tiger/ })).toBe(expectedElement2); + }); }); describe('supports accessibility states', () => { @@ -768,7 +788,7 @@ describe('error messages', () => { render(); expect(() => screen.getByRole('button')).toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "button" + "Unable to find an element with role: button " `); @@ -778,7 +798,7 @@ describe('error messages', () => { render(); expect(() => screen.getByRole('button', { name: 'Save' })).toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "button", name: "Save" + "Unable to find an element with role: button, name: Save " `); @@ -789,7 +809,7 @@ describe('error messages', () => { expect(() => screen.getByRole('button', { name: 'Save', disabled: true })) .toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "button", name: "Save", disabled state: true + "Unable to find an element with role: button, name: Save, disabled state: true " `); @@ -800,7 +820,7 @@ describe('error messages', () => { expect(() => screen.getByRole('button', { name: 'Save', disabled: true, selected: true })) .toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "button", name: "Save", disabled state: true, selected state: true + "Unable to find an element with role: button, name: Save, disabled state: true, selected state: true " `); @@ -811,7 +831,7 @@ describe('error messages', () => { expect(() => screen.getByRole('button', { disabled: true })) .toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "button", disabled state: true + "Unable to find an element with role: button, disabled state: true " `); @@ -822,7 +842,7 @@ describe('error messages', () => { expect(() => screen.getByRole('adjustable', { value: { min: 1 } })) .toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "adjustable", min value: 1 + "Unable to find an element with role: adjustable, min value: 1 " `); @@ -832,7 +852,7 @@ describe('error messages', () => { value: { min: 1, max: 2, now: 1, text: /hello/ }, }), ).toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "adjustable", min value: 1, max value: 2, now value: 1, text value: /hello/ + "Unable to find an element with role: adjustable, min value: 1, max value: 2, now value: 1, text value: /hello/ " `); @@ -852,7 +872,7 @@ test('byRole queries support hidden option', () => { expect(screen.queryByRole('button', { includeHiddenElements: false })).toBeFalsy(); expect(() => screen.getByRole('button', { includeHiddenElements: false })) .toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "button" + "Unable to find an element with role: button { expect(screen.queryByRole('button', { name: 'Action' })).toBeFalsy(); }); - test('ignores elements with accessible={undefined} and that are implicitely not accessible', () => { + test('ignores elements with accessible={undefined} and that are implicitly not accessible', () => { render( Action @@ -903,7 +923,7 @@ test('error message renders the element tree, preserving only helpful props', as render(); expect(() => screen.getByRole('link')).toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "link" + "Unable to find an element with role: link screen.getAllByRole('link')).toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "link" + "Unable to find an element with role: link - `Found multiple elements with accessibilityHint: ${String(hint)} `; + `Found multiple elements with accessibility hint: ${String(hint)} `; const getMissingError = (hint: TextMatch) => - `Unable to find an element with accessibilityHint: ${String(hint)}`; + `Unable to find an element with accessibility hint: ${String(hint)}`; const { getBy, getAllBy, queryBy, queryAllBy, findBy, findAllBy } = makeQueries( queryAllByHintText, diff --git a/src/queries/role.ts b/src/queries/role.ts index a806bf056..9b30a4af9 100644 --- a/src/queries/role.ts +++ b/src/queries/role.ts @@ -5,6 +5,7 @@ import { accessibilityValueKeys, getRole, isAccessibilityElement, + normalizeRole, } from '../helpers/accessibility'; import { findAll } from '../helpers/find-all'; import { @@ -60,12 +61,13 @@ const queryAllByRole = ( instance: ReactTestInstance, ): QueryAllByQuery => function queryAllByRoleFn(role, options) { + const normalizedRole = typeof role === 'string' ? normalizeRole(role) : role; return findAll( instance, (node) => // run the cheapest checks first, and early exit to avoid unneeded computations isAccessibilityElement(node) && - matchStringProp(getRole(node), role) && + matchStringProp(getRole(node), normalizedRole) && matchAccessibleStateIfNeeded(node, options) && matchAccessibilityValueIfNeeded(node, options?.value) && matchAccessibleNameIfNeeded(node, options?.name), @@ -74,10 +76,10 @@ const queryAllByRole = ( }; const formatQueryParams = (role: TextMatch, options: ByRoleOptions = {}) => { - const params = [`role: "${String(role)}"`]; + const params = [`role: ${String(role)}`]; if (options.name) { - params.push(`name: "${String(options.name)}"`); + params.push(`name: ${String(options.name)}`); } accessibilityStateKeys.forEach((stateKey) => {