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();
+
+ expect(screen.toJSON()).toMatchInlineSnapshot(`
+
+ `);
+});
+
+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();
+
+ 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(
+ <>
+
+ >,
+ );
+
+ 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(
+ <>
+
+
+ >,
+ );
+
+ 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) => {