From 87237591ad3a436295f3a2de2cbf3d2f3488e1b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Mon, 26 Sep 2022 16:46:43 +0200 Subject: [PATCH 01/11] Query by role and accessibilityState and improve ByRole query errors --- src/queries/__tests__/role.test.tsx | 348 +++++++++++++++++++++++++++- src/queries/makeQueries.ts | 15 +- src/queries/role.ts | 72 +++++- 3 files changed, 418 insertions(+), 17 deletions(-) diff --git a/src/queries/__tests__/role.test.tsx b/src/queries/__tests__/role.test.tsx index ea7b98de9..9b0d74c4a 100644 --- a/src/queries/__tests__/role.test.tsx +++ b/src/queries/__tests__/role.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { TouchableOpacity, Text } from 'react-native'; +import { TouchableOpacity, Text, View } from 'react-native'; import { render } from '../..'; const TEXT_LABEL = 'cool text'; @@ -181,3 +181,349 @@ describe('supports name option', () => { ); }); }); + +describe('supports accessibility states', () => { + describe('disabled', () => { + test('returns a disabled element when required', () => { + const { getByRole } = render( + + ); + + expect(getByRole('button', { disabled: true })).toBeTruthy(); + }); + + test('returns the correct element when only one matches all the requirements', () => { + const { getByRole } = render( + <> + + Save + + + Save + + + ); + + expect( + getByRole('button', { name: 'Save', disabled: true }).props.testID + ).toBe('correct'); + }); + + test('returns an implicitly enabled element', () => { + const { getByRole } = render( + + ); + + expect(getByRole('button', { disabled: false })).toBeTruthy(); + }); + + test('returns an explicitly enabled element', () => { + const { getByRole } = render( + + ); + + expect(getByRole('button', { disabled: false })).toBeTruthy(); + }); + }); + + describe('selected', () => { + test('returns a selected element when required', () => { + const { getByRole } = render( + + ); + + expect(getByRole('tab', { selected: true })).toBeTruthy(); + }); + + test('returns the correct element when only one matches all the requirements', () => { + const { getByRole } = render( + <> + + Save + + + Save + + + ); + + expect( + getByRole('tab', { name: 'Save', selected: true }).props.testID + ).toBe('correct'); + }); + + test('returns an implicitly non selected element', () => { + const { getByRole } = render( + + ); + + expect(getByRole('tab', { selected: false })).toBeTruthy(); + }); + + test('returns an explicitly non selected element', () => { + const { getByRole } = render( + + ); + + expect(getByRole('tab', { selected: false })).toBeTruthy(); + }); + }); + + describe('checked', () => { + test('returns a checked element when required', () => { + const { getByRole } = render( + + ); + + expect(getByRole('checkbox', { checked: true })).toBeTruthy(); + }); + + it('returns `mixed` checkboxes', () => { + const { getByRole } = render( + + ); + + expect(getByRole('checkbox', { checked: 'mixed' })).toBeTruthy(); + }); + + test('returns the correct element when only one matches all the requirements', () => { + const { getByRole } = render( + <> + + Save + + + Save + + + ); + + expect( + getByRole('checkbox', { name: 'Save', checked: true }).props.testID + ).toBe('correct'); + }); + + test('returns an implicitly non checked element', () => { + const { getByRole } = render( + + ); + + expect(getByRole('checkbox', { checked: false })).toBeTruthy(); + }); + + test('returns an explicitly non checked element', () => { + const { getByRole } = render( + + ); + + expect(getByRole('checkbox', { checked: false })).toBeTruthy(); + }); + }); + + describe('busy', () => { + test('returns a busy element when required', () => { + const { getByRole } = render( + + ); + + expect(getByRole('button', { busy: true })).toBeTruthy(); + }); + + test('returns the correct element when only one matches all the requirements', () => { + const { getByRole } = render( + <> + + Save + + + Save + + + ); + + expect( + getByRole('button', { name: 'Save', busy: true }).props.testID + ).toBe('correct'); + }); + + test('returns an implicitly non busy element', () => { + const { getByRole } = render( + + ); + + expect(getByRole('button', { busy: false })).toBeTruthy(); + }); + + test('returns an explicitly non busy element', () => { + const { getByRole } = render( + + ); + + expect(getByRole('button', { busy: false })).toBeTruthy(); + }); + }); + + describe('expanded', () => { + test('returns a expanded element when required', () => { + const { getByRole } = render( + + ); + + expect(getByRole('button', { expanded: true })).toBeTruthy(); + }); + + test('returns the correct element when only one matches all the requirements', () => { + const { getByRole } = render( + <> + + Save + + + Save + + + ); + + expect( + getByRole('button', { name: 'Save', expanded: true }).props.testID + ).toBe('correct'); + }); + + test('returns an implicitly non expanded element', () => { + const { getByRole } = render( + + ); + + expect(getByRole('button', { expanded: false })).toBeTruthy(); + }); + + test('returns an explicitly non expanded element', () => { + const { getByRole } = render( + + ); + + expect(getByRole('button', { expanded: false })).toBeTruthy(); + }); + }); + + test('matches an element combining all the options', () => { + const { getByRole } = render( + + Save + + ); + + expect( + getByRole('button', { + name: 'Save', + disabled: true, + selected: true, + checked: true, + busy: true, + expanded: true, + }) + ).toBeTruthy(); + }); +}); + +describe('error messages', () => { + test('gives a descriptive error message when querying with a role', () => { + const { getByRole } = render(); + + expect(() => getByRole('button')).toThrowErrorMatchingInlineSnapshot( + `"Unable to find an element with accessibilityRole: button"` + ); + }); + + test('gives a descriptive error message when querying with a role and a name', () => { + const { getByRole } = render(); + + expect(() => + getByRole('button', { name: 'Save' }) + ).toThrowErrorMatchingInlineSnapshot( + `"Unable to find an element with accessibilityRole: button, name: Save"` + ); + }); + + test('gives a descriptive error message when querying with a role, a name and accessibility state', () => { + const { getByRole } = render(); + + expect(() => + getByRole('button', { name: 'Save', disabled: true }) + ).toThrowErrorMatchingInlineSnapshot( + `"Unable to find an element with accessibilityRole: button, name: Save, accessibilityStates: disabled:true"` + ); + }); + + test('gives a descriptive error message when querying with a role and an accessibility state', () => { + const { getByRole } = render(); + + expect(() => + getByRole('button', { disabled: true }) + ).toThrowErrorMatchingInlineSnapshot( + `"Unable to find an element with accessibilityRole: button, accessibilityStates: disabled:true"` + ); + }); +}); diff --git a/src/queries/makeQueries.ts b/src/queries/makeQueries.ts index 68fff5299..5e02bcb88 100644 --- a/src/queries/makeQueries.ts +++ b/src/queries/makeQueries.ts @@ -48,15 +48,15 @@ export type UnboundQueries = { export function makeQueries( queryAllByQuery: UnboundQuery>, - getMissingError: (predicate: Predicate) => string, - getMultipleError: (predicate: Predicate) => string + getMissingError: (predicate: Predicate, options?: Options) => string, + getMultipleError: (predicate: Predicate, options?: Options) => string ): UnboundQueries { function getAllByQuery(instance: ReactTestInstance) { return function getAllFn(predicate: Predicate, options?: Options) { const results = queryAllByQuery(instance)(predicate, options); if (results.length === 0) { - throw new ErrorWithStack(getMissingError(predicate), getAllFn); + throw new ErrorWithStack(getMissingError(predicate, options), getAllFn); } return results; @@ -68,7 +68,10 @@ export function makeQueries( const results = queryAllByQuery(instance)(predicate, options); if (results.length > 1) { - throw new ErrorWithStack(getMultipleError(predicate), singleQueryFn); + throw new ErrorWithStack( + getMultipleError(predicate, options), + singleQueryFn + ); } if (results.length === 0) { @@ -84,11 +87,11 @@ export function makeQueries( const results = queryAllByQuery(instance)(predicate, options); if (results.length > 1) { - throw new ErrorWithStack(getMultipleError(predicate), getFn); + throw new ErrorWithStack(getMultipleError(predicate, options), getFn); } if (results.length === 0) { - throw new ErrorWithStack(getMissingError(predicate), getFn); + throw new ErrorWithStack(getMissingError(predicate, options), getFn); } return results[0]; diff --git a/src/queries/role.ts b/src/queries/role.ts index 02d3aa223..590d8fa2e 100644 --- a/src/queries/role.ts +++ b/src/queries/role.ts @@ -1,3 +1,4 @@ +import { type AccessibilityState } from 'react-native'; import type { ReactTestInstance } from 'react-test-renderer'; import { matchStringProp } from '../helpers/matchers/matchStringProp'; import { TextMatch } from '../matches'; @@ -14,7 +15,7 @@ import type { type ByRoleOptions = { name?: TextMatch; -}; +} & AccessibilityState; const matchAccessibleNameIfNeeded = ( node: ReactTestInstance, @@ -28,22 +29,73 @@ const matchAccessibleNameIfNeeded = ( ); }; +const accessibilityStates = [ + 'disabled', + 'selected', + 'checked', + 'busy', + 'expanded', +] as const; + const queryAllByRole = ( instance: ReactTestInstance ): ((role: TextMatch, options?: ByRoleOptions) => Array) => function queryAllByRoleFn(role, options) { - return instance.findAll( - (node) => + return instance.findAll((node) => { + const matchRole = typeof node.type === 'string' && - matchStringProp(node.props.accessibilityRole, role) && - matchAccessibleNameIfNeeded(node, options?.name) - ); + matchStringProp(node.props.accessibilityRole, role); + + if (!matchRole) return false; + + if (options?.name) { + if (!matchAccessibleNameIfNeeded(node, options.name)) { + return false; + } + } + + return accessibilityStates.every((accessibilityState) => { + const queriedState = options?.[accessibilityState]; + + // test for true instead of `undefined`, because `{disabled: false} should match + // a button without a disable state` + if (queriedState === true) { + return ( + queriedState === node.props.accessibilityState?.[accessibilityState] + ); + } else { + return true; + } + }); + }); }; -const getMultipleError = (role: TextMatch) => - `Found multiple elements with accessibilityRole: ${String(role)} `; -const getMissingError = (role: TextMatch) => - `Unable to find an element with accessibilityRole: ${String(role)}`; +const computeErrorMessage = (role: TextMatch, options: ByRoleOptions = {}) => { + let errorMessage = `accessibilityRole: ${String(role)}`; + + if (options.name) { + errorMessage += `, name: ${String(options.name)}`; + } + + if ( + accessibilityStates.some( + (accessibilityState) => typeof options[accessibilityState] !== 'undefined' + ) + ) { + errorMessage += ', accessibilityStates:'; + accessibilityStates.forEach((accessibilityState) => { + if (options[accessibilityState]) { + errorMessage += ` ${accessibilityState}:${options[accessibilityState]}`; + } + }); + } + + return errorMessage; +}; +const getMultipleError = (role: TextMatch, options?: ByRoleOptions) => + `Found multiple elements with ${computeErrorMessage(role, options)}`; +const getMissingError = (role: TextMatch, options?: ByRoleOptions) => + `Unable to find an element with ${computeErrorMessage(role, options)}`; const { getBy, getAllBy, queryBy, queryAllBy, findBy, findAllBy } = makeQueries( queryAllByRole, From 9a8b64107d3b75bd72a90b64e48453a71c7eb84f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Thu, 6 Oct 2022 22:45:17 -0400 Subject: [PATCH 02/11] Fix flow types --- typings/index.flow.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/typings/index.flow.js b/typings/index.flow.js index 2c82adfdc..2c8c35710 100644 --- a/typings/index.flow.js +++ b/typings/index.flow.js @@ -203,9 +203,10 @@ interface UnsafeByPropsQueries { | []; } -interface ByRoleOptions { - name?: string; -} +type ByRoleOptions = { + ...A11yState, + name?: string, +}; interface A11yAPI { // Label From 6495c63793d7b25d0d57afa3d528fea8a673d749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Thu, 6 Oct 2022 22:45:44 -0400 Subject: [PATCH 03/11] Fix buggy implementation --- src/queries/__tests__/role.test.tsx | 66 +++++++++++++++++++++++++++++ src/queries/role.ts | 8 ++-- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/queries/__tests__/role.test.tsx b/src/queries/__tests__/role.test.tsx index 9b0d74c4a..bc1ed96b1 100644 --- a/src/queries/__tests__/role.test.tsx +++ b/src/queries/__tests__/role.test.tsx @@ -234,6 +234,17 @@ describe('supports accessibility states', () => { expect(getByRole('button', { disabled: false })).toBeTruthy(); }); + + test('does not return disabled elements when querying for non disabled', () => { + const { queryByRole } = render( + + ); + + expect(queryByRole('button', { disabled: false })).toBe(null); + }); }); describe('selected', () => { @@ -287,6 +298,17 @@ describe('supports accessibility states', () => { expect(getByRole('tab', { selected: false })).toBeTruthy(); }); + + test('does not return selected elements when querying for non selected', () => { + const { queryByRole } = render( + + ); + + expect(queryByRole('tab', { selected: false })).toBe(null); + }); }); describe('checked', () => { @@ -312,6 +334,17 @@ describe('supports accessibility states', () => { expect(getByRole('checkbox', { checked: 'mixed' })).toBeTruthy(); }); + it('does not return mixed checkboxes when querying for checked: true', () => { + const { queryByRole } = render( + + ); + + expect(queryByRole('checkbox', { checked: false })).toBe(null); + }); + test('returns the correct element when only one matches all the requirements', () => { const { getByRole } = render( <> @@ -351,6 +384,17 @@ describe('supports accessibility states', () => { expect(getByRole('checkbox', { checked: false })).toBeTruthy(); }); + + test('does not return checked elements when querying for non checked', () => { + const { queryByRole } = render( + + ); + + expect(queryByRole('checkbox', { checked: false })).toBe(null); + }); }); describe('busy', () => { @@ -404,6 +448,17 @@ describe('supports accessibility states', () => { expect(getByRole('button', { busy: false })).toBeTruthy(); }); + + test('does not return busy elements when querying for non busy', () => { + const { queryByRole } = render( + + ); + + expect(queryByRole('button', { selected: false })).toBe(null); + }); }); describe('expanded', () => { @@ -457,6 +512,17 @@ describe('supports accessibility states', () => { expect(getByRole('button', { expanded: false })).toBeTruthy(); }); + + test('does not return expanded elements when querying for non expanded', () => { + const { queryByRole } = render( + + ); + + expect(queryByRole('button', { expanded: false })).toBe(null); + }); }); test('matches an element combining all the options', () => { diff --git a/src/queries/role.ts b/src/queries/role.ts index 590d8fa2e..b42702948 100644 --- a/src/queries/role.ts +++ b/src/queries/role.ts @@ -57,11 +57,11 @@ const queryAllByRole = ( return accessibilityStates.every((accessibilityState) => { const queriedState = options?.[accessibilityState]; - // test for true instead of `undefined`, because `{disabled: false} should match - // a button without a disable state` - if (queriedState === true) { + if (typeof queriedState !== 'undefined') { return ( - queriedState === node.props.accessibilityState?.[accessibilityState] + queriedState === + // default to false because disabled:undefined is equivalent to disabled:false + (node.props.accessibilityState?.[accessibilityState] ?? false) ); } else { return true; From 567a87fb1e93d2e57cb7d33a4d7a5e1704ef1e6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Fri, 7 Oct 2022 18:53:37 -0400 Subject: [PATCH 04/11] Increase test coverage and run expensive checks last --- src/queries/__tests__/role.test.tsx | 64 ++++++++++++++++++++++++++++- src/queries/role.ts | 35 ++++++++-------- 2 files changed, 82 insertions(+), 17 deletions(-) diff --git a/src/queries/__tests__/role.test.tsx b/src/queries/__tests__/role.test.tsx index bc1ed96b1..f69eeabe7 100644 --- a/src/queries/__tests__/role.test.tsx +++ b/src/queries/__tests__/role.test.tsx @@ -1,5 +1,12 @@ import * as React from 'react'; -import { TouchableOpacity, Text, View } from 'react-native'; +import { + TouchableOpacity, + TouchableWithoutFeedback, + Text, + View, + Pressable, + Button as RNButton, +} from 'react-native'; import { render } from '../..'; const TEXT_LABEL = 'cool text'; @@ -245,6 +252,39 @@ describe('supports accessibility states', () => { expect(queryByRole('button', { disabled: false })).toBe(null); }); + + test('returns elements using the built-in disabled prop', () => { + const { debug, getByRole } = render( + <> + + Pressable + + + + + TouchableWithoutFeedback + + + {}} title="RNButton" /> + + ); + + expect( + getByRole('button', { name: 'Pressable', disabled: true }) + ).toBeTruthy(); + + expect( + getByRole('button', { + name: 'TouchableWithoutFeedback', + disabled: true, + }) + ).toBeTruthy(); + debug(); + + expect( + getByRole('button', { name: 'RNButton', disabled: true }) + ).toBeTruthy(); + }); }); describe('selected', () => { @@ -525,6 +565,28 @@ describe('supports accessibility states', () => { }); }); + test('ignores non queried accessibilityState', () => { + const { getByRole } = render( + + Save + + ); + + expect( + getByRole('button', { + name: 'Save', + disabled: true, + }) + ).toBeTruthy(); + }); + test('matches an element combining all the options', () => { const { getByRole } = render( Array) => function queryAllByRoleFn(role, options) { return instance.findAll((node) => { + // run the cheapest checks first, and early exit too avoid unneeded computations const matchRole = typeof node.type === 'string' && matchStringProp(node.props.accessibilityRole, role); if (!matchRole) return false; - if (options?.name) { - if (!matchAccessibleNameIfNeeded(node, options.name)) { - return false; + const mismatchAccessibilityState = accessibilityStates.some( + (accessibilityState) => { + const queriedState = options?.[accessibilityState]; + + if (typeof queriedState !== 'undefined') { + return ( + queriedState !== + // default to false because disabled:undefined is equivalent to disabled:false + (node.props.accessibilityState?.[accessibilityState] ?? false) + ); + } else { + return false; + } } + ); + + if (mismatchAccessibilityState) { + return false; } - return accessibilityStates.every((accessibilityState) => { - const queriedState = options?.[accessibilityState]; - - if (typeof queriedState !== 'undefined') { - return ( - queriedState === - // default to false because disabled:undefined is equivalent to disabled:false - (node.props.accessibilityState?.[accessibilityState] ?? false) - ); - } else { - return true; - } - }); + return matchAccessibleNameIfNeeded(node, options?.name); }); }; From f324a0f7819af11951fa20c2ce85c56886618d63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Sat, 8 Oct 2022 19:08:04 -0400 Subject: [PATCH 05/11] Improve further QueryByRole error --- src/queries/__tests__/role.test.tsx | 22 ++++++++++++++++------ src/queries/role.ts | 17 +++++++++-------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/queries/__tests__/role.test.tsx b/src/queries/__tests__/role.test.tsx index f69eeabe7..6569153d3 100644 --- a/src/queries/__tests__/role.test.tsx +++ b/src/queries/__tests__/role.test.tsx @@ -15,11 +15,11 @@ const TEXT_LABEL = 'cool text'; const NO_MATCHES_TEXT: any = 'not-existent-element'; const getMultipleInstancesFoundMessage = (value: string) => { - return `Found multiple elements with accessibilityRole: ${value}`; + return `Found multiple elements with role: "${value}"`; }; const getNoInstancesFoundMessage = (value: string) => { - return `Unable to find an element with accessibilityRole: ${value}`; + return `Unable to find an element with role: "${value}"`; }; const Typography = ({ children, ...rest }: any) => { @@ -621,7 +621,7 @@ describe('error messages', () => { const { getByRole } = render(); expect(() => getByRole('button')).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with accessibilityRole: button"` + `"Unable to find an element with role: "button""` ); }); @@ -631,7 +631,7 @@ describe('error messages', () => { expect(() => getByRole('button', { name: 'Save' }) ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with accessibilityRole: button, name: Save"` + `"Unable to find an element with role: "button", name: "Save""` ); }); @@ -641,7 +641,17 @@ describe('error messages', () => { expect(() => getByRole('button', { name: 'Save', disabled: true }) ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with accessibilityRole: button, name: Save, accessibilityStates: disabled:true"` + `"Unable to find an element with role: "button", name: "Save", disabled state: true"` + ); + }); + + test('gives a descriptive error message when querying with a role, a name and several accessibility state', () => { + const { getByRole } = render(); + + expect(() => + 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"` ); }); @@ -651,7 +661,7 @@ describe('error messages', () => { expect(() => getByRole('button', { disabled: true }) ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with accessibilityRole: button, accessibilityStates: disabled:true"` + `"Unable to find an element with role: "button", disabled state: true"` ); }); }); diff --git a/src/queries/role.ts b/src/queries/role.ts index 8de7ffa64..ee3981ab7 100644 --- a/src/queries/role.ts +++ b/src/queries/role.ts @@ -73,11 +73,11 @@ const queryAllByRole = ( }); }; -const computeErrorMessage = (role: TextMatch, options: ByRoleOptions = {}) => { - let errorMessage = `accessibilityRole: ${String(role)}`; +const buildErrorMessage = (role: TextMatch, options: ByRoleOptions = {}) => { + const errors = [`role: "${String(role)}"`]; if (options.name) { - errorMessage += `, name: ${String(options.name)}`; + errors.push(`name: "${String(options.name)}"`); } if ( @@ -85,20 +85,21 @@ const computeErrorMessage = (role: TextMatch, options: ByRoleOptions = {}) => { (accessibilityState) => typeof options[accessibilityState] !== 'undefined' ) ) { - errorMessage += ', accessibilityStates:'; accessibilityStates.forEach((accessibilityState) => { if (options[accessibilityState]) { - errorMessage += ` ${accessibilityState}:${options[accessibilityState]}`; + errors.push( + `${accessibilityState} state: ${options[accessibilityState]}` + ); } }); } - return errorMessage; + return errors.join(', '); }; const getMultipleError = (role: TextMatch, options?: ByRoleOptions) => - `Found multiple elements with ${computeErrorMessage(role, options)}`; + `Found multiple elements with ${buildErrorMessage(role, options)}`; const getMissingError = (role: TextMatch, options?: ByRoleOptions) => - `Unable to find an element with ${computeErrorMessage(role, options)}`; + `Unable to find an element with ${buildErrorMessage(role, options)}`; const { getBy, getAllBy, queryBy, queryAllBy, findBy, findAllBy } = makeQueries( queryAllByRole, From c5b42affe36e3496f6d33bcf0fa46c00e5dc0316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Sat, 8 Oct 2022 19:50:47 -0400 Subject: [PATCH 06/11] FIx implicit accessibilityState --- src/queries/__tests__/role.test.tsx | 15 ++++---- src/queries/role.ts | 53 ++++++++++++++++++----------- 2 files changed, 41 insertions(+), 27 deletions(-) diff --git a/src/queries/__tests__/role.test.tsx b/src/queries/__tests__/role.test.tsx index 6569153d3..4df6d5e96 100644 --- a/src/queries/__tests__/role.test.tsx +++ b/src/queries/__tests__/role.test.tsx @@ -254,7 +254,7 @@ describe('supports accessibility states', () => { }); test('returns elements using the built-in disabled prop', () => { - const { debug, getByRole } = render( + const { getByRole } = render( <> Pressable @@ -279,7 +279,6 @@ describe('supports accessibility states', () => { disabled: true, }) ).toBeTruthy(); - debug(); expect( getByRole('button', { name: 'RNButton', disabled: true }) @@ -406,12 +405,12 @@ describe('supports accessibility states', () => { ).toBe('correct'); }); - test('returns an implicitly non checked element', () => { - const { getByRole } = render( + test('does not return return as non checked an element with checked: undefined', () => { + const { queryByRole } = render( ); - expect(getByRole('checkbox', { checked: false })).toBeTruthy(); + expect(queryByRole('checkbox', { checked: false })).toBe(null); }); test('returns an explicitly non checked element', () => { @@ -534,12 +533,12 @@ describe('supports accessibility states', () => { ).toBe('correct'); }); - test('returns an implicitly non expanded element', () => { - const { getByRole } = render( + test('does not return return as non expanded an element with expanded: undefined', () => { + const { queryByRole } = render( ); - expect(getByRole('button', { expanded: false })).toBeTruthy(); + expect(queryByRole('button', { expanded: false })).toBe(null); }); test('returns an explicitly non expanded element', () => { diff --git a/src/queries/role.ts b/src/queries/role.ts index ee3981ab7..9797b758e 100644 --- a/src/queries/role.ts +++ b/src/queries/role.ts @@ -17,6 +17,8 @@ type ByRoleOptions = { name?: TextMatch; } & AccessibilityState; +type AccessibilityStateField = keyof AccessibilityState; + const matchAccessibleNameIfNeeded = ( node: ReactTestInstance, name?: TextMatch @@ -29,13 +31,42 @@ const matchAccessibleNameIfNeeded = ( ); }; -const accessibilityStates = [ +// disabled:undefined is equivalent to disabled:false, same for selected. busy not, but it makes +// sense from a testing/voice-over perspective. checked and expanded do behave differently +const implicityFalseState: AccessibilityStateField[] = [ + 'disabled', + 'selected', + 'busy', +]; + +const matchAccessibleStateIfNeeded = ( + node: ReactTestInstance, + options?: ByRoleOptions +) => + accessibilityStates.every((accessibilityState) => { + const queriedState = options?.[accessibilityState]; + + if (typeof queriedState !== 'undefined') { + // Some accessibilityState properties have implicit value (when not set) + const defaultState = implicityFalseState.includes(accessibilityState) + ? false + : undefined; + return ( + queriedState === + (node.props.accessibilityState?.[accessibilityState] ?? defaultState) + ); + } else { + return true; + } + }); + +const accessibilityStates: AccessibilityStateField[] = [ 'disabled', 'selected', 'checked', 'busy', 'expanded', -] as const; +]; const queryAllByRole = ( instance: ReactTestInstance @@ -49,23 +80,7 @@ const queryAllByRole = ( if (!matchRole) return false; - const mismatchAccessibilityState = accessibilityStates.some( - (accessibilityState) => { - const queriedState = options?.[accessibilityState]; - - if (typeof queriedState !== 'undefined') { - return ( - queriedState !== - // default to false because disabled:undefined is equivalent to disabled:false - (node.props.accessibilityState?.[accessibilityState] ?? false) - ); - } else { - return false; - } - } - ); - - if (mismatchAccessibilityState) { + if (!matchAccessibleStateIfNeeded(node, options)) { return false; } From ff18c069fb7e2eef6f7932a225117141c14cb93f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Sat, 8 Oct 2022 20:09:23 -0400 Subject: [PATCH 07/11] Improve test coverage --- src/queries/__tests__/role.test.tsx | 63 ++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/src/queries/__tests__/role.test.tsx b/src/queries/__tests__/role.test.tsx index 4df6d5e96..ce7519186 100644 --- a/src/queries/__tests__/role.test.tsx +++ b/src/queries/__tests__/role.test.tsx @@ -192,7 +192,7 @@ describe('supports name option', () => { describe('supports accessibility states', () => { describe('disabled', () => { test('returns a disabled element when required', () => { - const { getByRole } = render( + const { getByRole, queryByRole } = render( { ); expect(getByRole('button', { disabled: true })).toBeTruthy(); + expect(queryByRole('button', { disabled: false })).toBe(null); }); test('returns the correct element when only one matches all the requirements', () => { @@ -224,15 +225,16 @@ describe('supports accessibility states', () => { }); test('returns an implicitly enabled element', () => { - const { getByRole } = render( + const { getByRole, queryByRole } = render( ); expect(getByRole('button', { disabled: false })).toBeTruthy(); + expect(queryByRole('button', { disabled: true })).toBe(null); }); test('returns an explicitly enabled element', () => { - const { getByRole } = render( + const { getByRole, queryByRole } = render( { ); expect(getByRole('button', { disabled: false })).toBeTruthy(); + expect(queryByRole('button', { disabled: true })).toBe(null); }); test('does not return disabled elements when querying for non disabled', () => { @@ -288,7 +291,7 @@ describe('supports accessibility states', () => { describe('selected', () => { test('returns a selected element when required', () => { - const { getByRole } = render( + const { getByRole, queryByRole } = render( { ); expect(getByRole('tab', { selected: true })).toBeTruthy(); + expect(queryByRole('tab', { selected: false })).toBe(null); }); test('returns the correct element when only one matches all the requirements', () => { @@ -320,15 +324,16 @@ describe('supports accessibility states', () => { }); test('returns an implicitly non selected element', () => { - const { getByRole } = render( + const { getByRole, queryByRole } = render( ); expect(getByRole('tab', { selected: false })).toBeTruthy(); + expect(queryByRole('tab', { selected: true })).toBe(null); }); test('returns an explicitly non selected element', () => { - const { getByRole } = render( + const { getByRole, queryByRole } = render( { ); expect(getByRole('tab', { selected: false })).toBeTruthy(); + expect(queryByRole('tab', { selected: true })).toBe(null); }); test('does not return selected elements when querying for non selected', () => { @@ -352,7 +358,7 @@ describe('supports accessibility states', () => { describe('checked', () => { test('returns a checked element when required', () => { - const { getByRole } = render( + const { getByRole, queryByRole } = render( { ); expect(getByRole('checkbox', { checked: true })).toBeTruthy(); + expect(queryByRole('checkbox', { checked: false })).toBe(null); + expect(queryByRole('checkbox', { checked: 'mixed' })).toBe(null); }); it('returns `mixed` checkboxes', () => { - const { getByRole } = render( + const { queryByRole, getByRole } = render( { ); expect(getByRole('checkbox', { checked: 'mixed' })).toBeTruthy(); + expect(queryByRole('checkbox', { checked: true })).toBe(null); + expect(queryByRole('checkbox', { checked: false })).toBe(null); }); it('does not return mixed checkboxes when querying for checked: true', () => { @@ -414,7 +424,7 @@ describe('supports accessibility states', () => { }); test('returns an explicitly non checked element', () => { - const { getByRole } = render( + const { getByRole, queryByRole } = render( { ); expect(getByRole('checkbox', { checked: false })).toBeTruthy(); + expect(queryByRole('checkbox', { checked: true })).toBe(null); }); test('does not return checked elements when querying for non checked', () => { @@ -434,11 +445,22 @@ describe('supports accessibility states', () => { expect(queryByRole('checkbox', { checked: false })).toBe(null); }); + + test('does not return mixed elements when querying for non checked', () => { + const { queryByRole } = render( + + ); + + expect(queryByRole('checkbox', { checked: false })).toBe(null); + }); }); describe('busy', () => { test('returns a busy element when required', () => { - const { getByRole } = render( + const { getByRole, queryByRole } = render( { ); expect(getByRole('button', { busy: true })).toBeTruthy(); + expect(queryByRole('button', { busy: false })).toBe(null); }); test('returns the correct element when only one matches all the requirements', () => { @@ -470,15 +493,16 @@ describe('supports accessibility states', () => { }); test('returns an implicitly non busy element', () => { - const { getByRole } = render( + const { getByRole, queryByRole } = render( ); expect(getByRole('button', { busy: false })).toBeTruthy(); + expect(queryByRole('button', { busy: true })).toBe(null); }); test('returns an explicitly non busy element', () => { - const { getByRole } = render( + const { getByRole, queryByRole } = render( { ); expect(getByRole('button', { busy: false })).toBeTruthy(); + expect(queryByRole('button', { busy: true })).toBe(null); }); test('does not return busy elements when querying for non busy', () => { @@ -502,7 +527,7 @@ describe('supports accessibility states', () => { describe('expanded', () => { test('returns a expanded element when required', () => { - const { getByRole } = render( + const { getByRole, queryByRole } = render( { ); expect(getByRole('button', { expanded: true })).toBeTruthy(); + expect(queryByRole('button', { expanded: false })).toBe(null); }); test('returns the correct element when only one matches all the requirements', () => { @@ -542,7 +568,7 @@ describe('supports accessibility states', () => { }); test('returns an explicitly non expanded element', () => { - const { getByRole } = render( + const { getByRole, queryByRole } = render( { ); expect(getByRole('button', { expanded: false })).toBeTruthy(); + expect(queryByRole('button', { expanded: true })).toBe(null); }); test('does not return expanded elements when querying for non expanded', () => { @@ -565,7 +592,7 @@ describe('supports accessibility states', () => { }); test('ignores non queried accessibilityState', () => { - const { getByRole } = render( + const { getByRole, queryByRole } = render( { disabled: true, }) ).toBeTruthy(); + expect( + queryByRole('button', { + name: 'Save', + disabled: false, + }) + ).toBe(null); }); test('matches an element combining all the options', () => { From a4a22dd19c4ab83c248c0c654d006964d8cef104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Sat, 8 Oct 2022 20:20:13 -0400 Subject: [PATCH 08/11] Add docs for querying by accessibilityState in ByRole queries --- website/docs/Queries.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/website/docs/Queries.md b/website/docs/Queries.md index 215f5d606..c9bf62116 100644 --- a/website/docs/Queries.md +++ b/website/docs/Queries.md @@ -255,6 +255,16 @@ const element = screen.getByRole('button'); `name`: Finds an element with given `accessibilityRole` and an accessible name (equivalent to `byText` or `byLabelText` query). +`disabled`: You can filter elements by their disabled state. The possible values are `true` or `false`. See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `disabled` state. + +`selected`: You can filter elements by their selected state. The possible values are `true` or `false`. See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `selected` state. + +`checked`: You can filter elements by their checked state. The possible values are `true`, `false`, or `"mixed"`. See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `checked` state. + +`busy`: You can filter elements by their busy state. The possible values are `true` or `false`. See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `busy` state. + +`expanded`: You can filter elements by their expanded state. The possible values are `true` or `false`. See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `expanded` state. + ### `ByA11yState`, `ByAccessibilityState` > getByA11yState, getAllByA11yState, queryByA11yState, queryAllByA11yState, findByA11yState, findAllByA11yState From 5eb1a26399f4549b4d566465e843555cb47ea057 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Wed, 19 Oct 2022 11:45:19 +0200 Subject: [PATCH 09/11] Improve readability of byRole function and udpate docs for default accessibilityState --- src/queries/role.ts | 27 +++++++++++---------------- website/docs/Queries.md | 6 +++--- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/queries/role.ts b/src/queries/role.ts index 9797b758e..cb77a22aa 100644 --- a/src/queries/role.ts +++ b/src/queries/role.ts @@ -17,7 +17,7 @@ type ByRoleOptions = { name?: TextMatch; } & AccessibilityState; -type AccessibilityStateField = keyof AccessibilityState; +type AccessibilityStateKey = keyof AccessibilityState; const matchAccessibleNameIfNeeded = ( node: ReactTestInstance, @@ -33,7 +33,7 @@ const matchAccessibleNameIfNeeded = ( // disabled:undefined is equivalent to disabled:false, same for selected. busy not, but it makes // sense from a testing/voice-over perspective. checked and expanded do behave differently -const implicityFalseState: AccessibilityStateField[] = [ +const implicityFalseState: AccessibilityStateKey[] = [ 'disabled', 'selected', 'busy', @@ -60,7 +60,7 @@ const matchAccessibleStateIfNeeded = ( } }); -const accessibilityStates: AccessibilityStateField[] = [ +const accessibilityStates: AccessibilityStateKey[] = [ 'disabled', 'selected', 'checked', @@ -72,20 +72,15 @@ const queryAllByRole = ( instance: ReactTestInstance ): ((role: TextMatch, options?: ByRoleOptions) => Array) => function queryAllByRoleFn(role, options) { - return instance.findAll((node) => { - // run the cheapest checks first, and early exit too avoid unneeded computations - const matchRole = - typeof node.type === 'string' && - matchStringProp(node.props.accessibilityRole, role); - - if (!matchRole) return false; - - if (!matchAccessibleStateIfNeeded(node, options)) { - return false; - } + return instance.findAll( + (node) => + // run the cheapest checks first, and early exit too avoid unneeded computations - return matchAccessibleNameIfNeeded(node, options?.name); - }); + typeof node.type === 'string' && + matchStringProp(node.props.accessibilityRole, role) && + matchAccessibleStateIfNeeded(node, options) && + matchAccessibleNameIfNeeded(node, options?.name) + ); }; const buildErrorMessage = (role: TextMatch, options: ByRoleOptions = {}) => { diff --git a/website/docs/Queries.md b/website/docs/Queries.md index c9bf62116..09c873c26 100644 --- a/website/docs/Queries.md +++ b/website/docs/Queries.md @@ -255,13 +255,13 @@ const element = screen.getByRole('button'); `name`: Finds an element with given `accessibilityRole` and an accessible name (equivalent to `byText` or `byLabelText` query). -`disabled`: You can filter elements by their disabled state. The possible values are `true` or `false`. See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `disabled` state. +`disabled`: You can filter elements by their disabled state. The possible values are `true` or `false`. Querying `disabled: false` will also query elements with `disabled: undefined` (see the [wiki](https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State) for more details). See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `disabled` state. -`selected`: You can filter elements by their selected state. The possible values are `true` or `false`. See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `selected` state. +`selected`: You can filter elements by their selected state. The possible values are `true` or `false`. Querying `selected: false` will also query elements with `selected: undefined` (see the [wiki](https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State) for more details). See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `selected` state. `checked`: You can filter elements by their checked state. The possible values are `true`, `false`, or `"mixed"`. See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `checked` state. -`busy`: You can filter elements by their busy state. The possible values are `true` or `false`. See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `busy` state. +`busy`: You can filter elements by their busy state. The possible values are `true` or `false`. Querying `busy: false` will also query elements with `busy: undefined` (see the [wiki](https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State) for more details). See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `busy` state. `expanded`: You can filter elements by their expanded state. The possible values are `true` or `false`. See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `expanded` state. From 3e01734838a37b361282c49386bb69435051531b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Wed, 19 Oct 2022 16:12:02 +0200 Subject: [PATCH 10/11] Add typedefs for ByRole with accessibilityState --- website/docs/Queries.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/website/docs/Queries.md b/website/docs/Queries.md index 09c873c26..89a7fb898 100644 --- a/website/docs/Queries.md +++ b/website/docs/Queries.md @@ -238,6 +238,11 @@ getByRole( role: TextMatch, option?: { name?: TextMatch + disabled?: boolean, + selected?: boolean, + checked?: boolean | 'mixed', + busy?: boolean, + expanded?: boolean, } ): ReactTestInstance; ``` From bc26162bce076c822dff2512bad71d82828feca3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Thu, 20 Oct 2022 09:44:42 +0200 Subject: [PATCH 11/11] Fix docs --- website/docs/Queries.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/docs/Queries.md b/website/docs/Queries.md index 89a7fb898..cc9c4b2a6 100644 --- a/website/docs/Queries.md +++ b/website/docs/Queries.md @@ -260,13 +260,13 @@ const element = screen.getByRole('button'); `name`: Finds an element with given `accessibilityRole` and an accessible name (equivalent to `byText` or `byLabelText` query). -`disabled`: You can filter elements by their disabled state. The possible values are `true` or `false`. Querying `disabled: false` will also query elements with `disabled: undefined` (see the [wiki](https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State) for more details). See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `disabled` state. +`disabled`: You can filter elements by their disabled state. The possible values are `true` or `false`. Querying `disabled: false` will also match elements with `disabled: undefined` (see the [wiki](https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State) for more details). See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `disabled` state. -`selected`: You can filter elements by their selected state. The possible values are `true` or `false`. Querying `selected: false` will also query elements with `selected: undefined` (see the [wiki](https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State) for more details). See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `selected` state. +`selected`: You can filter elements by their selected state. The possible values are `true` or `false`. Querying `selected: false` will also match elements with `selected: undefined` (see the [wiki](https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State) for more details). See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `selected` state. `checked`: You can filter elements by their checked state. The possible values are `true`, `false`, or `"mixed"`. See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `checked` state. -`busy`: You can filter elements by their busy state. The possible values are `true` or `false`. Querying `busy: false` will also query elements with `busy: undefined` (see the [wiki](https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State) for more details). See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `busy` state. +`busy`: You can filter elements by their busy state. The possible values are `true` or `false`. Querying `busy: false` will also match elements with `busy: undefined` (see the [wiki](https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State) for more details). See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `busy` state. `expanded`: You can filter elements by their expanded state. The possible values are `true` or `false`. See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `expanded` state.