feat: testID property for use with e2e testing without interfering with a11y (#9793)

* fix(android): nested frames were sometimes not recreated (#9748)

Co-authored-by: Eduardo Speroni <edusperoni@gmail.com>

* feat: testID property for use with e2e testing without interfering with a11y

* feat: better testID support along a11y

wip

* fix: make sure we have a defined id

* feat: --env.e2e to enable testID

* chore: return if using testID

* chore: cleanup

Co-authored-by: Eduardo Speroni <edusperoni@gmail.com>
Co-authored-by: Igor Randjelovic <rigor789@gmail.com>
This commit is contained in:
Nathan Walker
2022-03-08 14:25:05 -08:00
committed by GitHub
parent 86fdf5810a
commit 8be543bcc7
17 changed files with 123 additions and 37 deletions

View File

@ -7,25 +7,25 @@
<GridLayout padding="20" class="a11y-demo-page"> <GridLayout padding="20" class="a11y-demo-page">
<ScrollView> <ScrollView>
<StackLayout> <StackLayout>
<Button text="Open Modal Page" class="view-item" tap="{{openModal}}" /> <Button testID="openModalPageButton" text="Open Modal Page" class="view-item" tap="{{openModal}}" />
<Button text="Open Normal Page" class="view-item" tap="{{openNormal}}" /> <Button testID="openNormalPageButton" text="Open Normal Page" class="view-item" tap="{{openNormal}}" />
<Label text="Accessible Label" class="view-item a11y text-center" accessibilityLabel="Accessible Label" accessibilityHint="Just a label" accessibilityRole="{{accessibilityRole.StaticText}}" accessibilityValue="Accessible Label" /> <Label testID="testLabel1" text="Accessible Label" class="view-item a11y text-center" accessibilityLabel="Accessible Label" accessibilityHint="Just a label" accessibilityRole="{{accessibilityRole.StaticText}}" accessibilityValue="Accessible Label" />
<Button text="Accessible Button" class="view-item a11y" accessibilityLabel="Accessible Button" accessibilityHint="Tapping this really does nothing" /> <Button testID="testLabel2" text="Accessible Button" class="view-item a11y" accessibilityLabel="Accessible Button" accessibilityHint="Tapping this really does nothing" />
<Image src="res://icon" width="50" class="view-item a11y" accessibilityLabel="Image with explicit attribute role" accessibilityRole="{{accessibilityRole.Image}}" /> <Image testID="testImage1" src="res://icon" width="50" class="view-item a11y" accessibilityLabel="Image with explicit attribute role" accessibilityRole="{{accessibilityRole.Image}}" />
<Image src="res://icon" width="50" class="view-item a11y a11y-role-image" accessibilityLabel="Image with css defined role" /> <Image testID="testImage2" src="res://icon" width="50" class="view-item a11y a11y-role-image" accessibilityLabel="Image with css defined role" />
<Image src="{{ largeImageSrc }}" width="50" class="view-item a11y a11y-role-image" accessibilityLabel="Image with css defined role" /> <Image src="{{ largeImageSrc }}" width="50" class="view-item a11y a11y-role-image" accessibilityLabel="Image with css defined role" />
<Switch checked="true" class="view-item a11y" accessibilityLabel="Switch with attribute state" accessibilityState="{{accessibilityState.Checked}}" checkedChange="{{checkedChange}}" /> <Switch testID="testSwitch1" checked="true" class="view-item a11y" accessibilityLabel="Switch with attribute state" accessibilityState="{{accessibilityState.Checked}}" checkedChange="{{checkedChange}}" />
<Switch checked="true" class="view-item a11y a11y-state-checked" accessibilityLabel="Switch with css state" checkedChange="{{checkedChange}}" /> <Switch testID="testSwitch2" checked="true" class="view-item a11y a11y-state-checked" accessibilityLabel="Switch with css state" checkedChange="{{checkedChange}}" />
<TextView hint="TextView" text="{{switchCheckedText}}" class="view-item a11y" accessibilityLabel="TestView with a value" accessibilityLiveRegion="{{accessibilityLiveRegions.Polite}}" /> <TextView testID="testTextView" hint="TextView" text="{{switchCheckedText}}" class="view-item a11y" accessibilityLabel="TestView with a value" accessibilityLiveRegion="{{accessibilityLiveRegions.Polite}}" />
<TextField hint="TextField" class="view-item a11y" accessibilityLabel="Plain jane TextField" accessibilityHint="Tell us your real name Jane" /> <TextField testID="testTextField" hint="TextField" class="view-item a11y" accessibilityLabel="Plain jane TextField" accessibilityHint="Tell us your real name Jane" />
<TextView hint="TextView" class="view-item a11y" accessibilityLabel="Nice TextView" accessibilityHint="Tell us about yourself Jane" /> <TextView hint="TextView" class="view-item a11y" accessibilityLabel="Nice TextView" accessibilityHint="Tell us about yourself Jane" />
<GridLayout rows="25" columns="*" class="view-item" accessibilityLabel="No can go GridLayout" accessibilityHint="A grid that will not get bigger when increasing accessible text size"> <GridLayout testID="testGridLayout1" rows="25" columns="*" class="view-item" accessibilityLabel="No can go GridLayout" accessibilityHint="A grid that will not get bigger when increasing accessible text size">
<Label text="IN-Accessible Grid" class="view-item text-center" /> <Label text="IN-Accessible Grid" class="view-item text-center" />
</GridLayout> </GridLayout>
<GridLayout rows="25,25" columns="*,50" class="view-item a11y" accessibilityLabel="Yes an accessible GridLayout" accessibilityHint="A grid that WILL get bigger dynamically when increasing accessible text size"> <GridLayout rows="25,25" columns="*,50" class="view-item a11y" accessibilityLabel="Yes an accessible GridLayout" accessibilityHint="A grid that WILL get bigger dynamically when increasing accessible text size">
@ -33,7 +33,7 @@
<Label row="1" text="With another item in a row" class="view-item text-center" /> <Label row="1" text="With another item in a row" class="view-item text-center" />
<Label rowSpan="2" col="1" text="Hi" /> <Label rowSpan="2" col="1" text="Hi" />
</GridLayout> </GridLayout>
<Slider value="10" minValue="0" maxValue="100" class="view-item a11y" accessibilityLabel="Slider" accessibilityHint="A smooth slider" accessibilityValue="10" /> <Slider testID="testSlider" value="10" minValue="0" maxValue="100" class="view-item a11y" accessibilityLabel="Slider" accessibilityHint="A smooth slider" accessibilityValue="10" />
</StackLayout> </StackLayout>
</ScrollView> </ScrollView>
</GridLayout> </GridLayout>

View File

@ -645,6 +645,11 @@ function applyContentDescription(view: Partial<View>, forceUpdate?: boolean) {
const contentDescription = contentDescriptionBuilder.join('. ').trim().replace(/^\.$/, ''); const contentDescription = contentDescriptionBuilder.join('. ').trim().replace(/^\.$/, '');
if (typeof __USE_TEST_ID__ !== 'undefined' && __USE_TEST_ID__ && view.testID) {
// ignore when testID is enabled
return;
}
if (contentDescription) { if (contentDescription) {
if (Trace.isEnabled()) { if (Trace.isEnabled()) {
Trace.write(`${cls} - set to "${contentDescription}"`, Trace.categories.Accessibility); Trace.write(`${cls} - set to "${contentDescription}"`, Trace.categories.Accessibility);

View File

@ -133,6 +133,7 @@ declare const __CSS_PARSER__: string;
declare const __NS_WEBPACK__: boolean; declare const __NS_WEBPACK__: boolean;
declare const __UI_USE_EXTERNAL_RENDERER__: boolean; declare const __UI_USE_EXTERNAL_RENDERER__: boolean;
declare const __UI_USE_XML_PARSER__: boolean; declare const __UI_USE_XML_PARSER__: boolean;
declare const __USE_TEST_ID__: boolean | undefined;
declare const __ANDROID__: boolean; declare const __ANDROID__: boolean;
declare const __IOS__: boolean; declare const __IOS__: boolean;

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item type="id" name="nativescript_accessibility_id"/>
</resources>

View File

@ -3,7 +3,7 @@ import type { Point, CustomLayoutView as CustomLayoutViewDefinition } from '.';
import type { GestureTypes, GestureEventData } from '../../gestures'; import type { GestureTypes, GestureEventData } from '../../gestures';
// Types. // Types.
import { ViewCommon, isEnabledProperty, originXProperty, originYProperty, isUserInteractionEnabledProperty } from './view-common'; import { ViewCommon, isEnabledProperty, originXProperty, originYProperty, isUserInteractionEnabledProperty, testIDProperty } from './view-common';
import { paddingLeftProperty, paddingTopProperty, paddingRightProperty, paddingBottomProperty, Length } from '../../styling/style-properties'; import { paddingLeftProperty, paddingTopProperty, paddingRightProperty, paddingBottomProperty, Length } from '../../styling/style-properties';
import { layout } from '../../../utils'; import { layout } from '../../../utils';
import { Trace } from '../../../trace'; import { Trace } from '../../../trace';
@ -796,6 +796,23 @@ export class View extends ViewCommon {
this.nativeViewProtected.setAlpha(float(value)); this.nativeViewProtected.setAlpha(float(value));
} }
[testIDProperty.setNative](value: string) {
this.setTestID(this.nativeViewProtected, value);
}
setTestID(view, value) {
if (typeof __USE_TEST_ID__ !== 'undefined' && __USE_TEST_ID__) {
const id = Utils.ad.resources.getId(':id/nativescript_accessibility_id');
if (id) {
view.setTag(id, value);
view.setTag(value);
}
view.setContentDescription(value);
}
}
[accessibilityEnabledProperty.setNative](value: boolean): void { [accessibilityEnabledProperty.setNative](value: boolean): void {
this.nativeViewProtected.setFocusable(!!value); this.nativeViewProtected.setFocusable(!!value);
@ -803,6 +820,9 @@ export class View extends ViewCommon {
} }
[accessibilityIdentifierProperty.setNative](value: string): void { [accessibilityIdentifierProperty.setNative](value: string): void {
if (typeof __USE_TEST_ID__ !== 'undefined' && __USE_TEST_ID__ && this.testID) {
// ignore when using testID;
} else {
const id = Utils.ad.resources.getId(':id/nativescript_accessibility_id'); const id = Utils.ad.resources.getId(':id/nativescript_accessibility_id');
if (id) { if (id) {
@ -810,6 +830,7 @@ export class View extends ViewCommon {
this.nativeViewProtected.setTag(value); this.nativeViewProtected.setTag(value);
} }
} }
}
[accessibilityRoleProperty.setNative](value: AccessibilityRole): void { [accessibilityRoleProperty.setNative](value: AccessibilityRole): void {
this.accessibilityRole = value; this.accessibilityRole = value;

View File

@ -2,7 +2,7 @@
import { Point, View as ViewDefinition } from '.'; import { Point, View as ViewDefinition } from '.';
// Requires // Requires
import { ViewCommon, isEnabledProperty, originXProperty, originYProperty, isUserInteractionEnabledProperty } from './view-common'; import { ViewCommon, isEnabledProperty, originXProperty, originYProperty, isUserInteractionEnabledProperty, testIDProperty } from './view-common';
import { ShowModalOptions, hiddenProperty } from '../view-base'; import { ShowModalOptions, hiddenProperty } from '../view-base';
import { Trace } from '../../../trace'; import { Trace } from '../../../trace';
import { layout, iOSNativeHelper } from '../../../utils'; import { layout, iOSNativeHelper } from '../../../utils';
@ -572,6 +572,16 @@ export class View extends ViewCommon implements ViewDefinition {
this.updateOriginPoint(this.originX, value); this.updateOriginPoint(this.originX, value);
} }
[testIDProperty.setNative](value: string) {
this.setTestID(this.nativeViewProtected, value);
}
public setTestID(view: any, value: string): void {
if (typeof __USE_TEST_ID__ !== 'undefined' && __USE_TEST_ID__) {
view.accessibilityIdentifier = value;
}
}
[accessibilityEnabledProperty.setNative](value: boolean): void { [accessibilityEnabledProperty.setNative](value: boolean): void {
this.nativeViewProtected.isAccessibilityElement = !!value; this.nativeViewProtected.isAccessibilityElement = !!value;
@ -581,9 +591,14 @@ export class View extends ViewCommon implements ViewDefinition {
[accessibilityIdentifierProperty.getDefault](): string { [accessibilityIdentifierProperty.getDefault](): string {
return this.nativeViewProtected.accessibilityLabel; return this.nativeViewProtected.accessibilityLabel;
} }
[accessibilityIdentifierProperty.setNative](value: string): void { [accessibilityIdentifierProperty.setNative](value: string): void {
if (typeof __USE_TEST_ID__ !== 'undefined' && __USE_TEST_ID__ && this.testID) {
// ignore when using testID
} else {
this.nativeViewProtected.accessibilityIdentifier = value; this.nativeViewProtected.accessibilityIdentifier = value;
} }
}
[accessibilityRoleProperty.setNative](value: AccessibilityRole): void { [accessibilityRoleProperty.setNative](value: AccessibilityRole): void {
this.accessibilityRole = value; this.accessibilityRole = value;

View File

@ -82,6 +82,8 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition {
public accessibilityValue: string; public accessibilityValue: string;
public accessibilityHint: string; public accessibilityHint: string;
public testID: string;
public touchAnimation: boolean | TouchAnimationOptions; public touchAnimation: boolean | TouchAnimationOptions;
public ignoreTouchAnimation: boolean; public ignoreTouchAnimation: boolean;
@ -1119,6 +1121,10 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition {
public accessibilityScreenChanged(): void { public accessibilityScreenChanged(): void {
return; return;
} }
public setTestID(view: any, value: string) {
return;
}
} }
export const originXProperty = new Property<ViewCommon, number>({ export const originXProperty = new Property<ViewCommon, number>({
@ -1196,6 +1202,11 @@ const ignoreTouchAnimationProperty = new Property<ViewCommon, boolean>({
}); });
ignoreTouchAnimationProperty.register(ViewCommon); ignoreTouchAnimationProperty.register(ViewCommon);
export const testIDProperty = new Property<ViewCommon, string>({
name: 'testID',
});
testIDProperty.register(ViewCommon);
accessibilityIdentifierProperty.register(ViewCommon); accessibilityIdentifierProperty.register(ViewCommon);
accessibilityLabelProperty.register(ViewCommon); accessibilityLabelProperty.register(ViewCommon);
accessibilityValueProperty.register(ViewCommon); accessibilityValueProperty.register(ViewCommon);

View File

@ -15,6 +15,7 @@ import { layout } from '../../utils';
import { isString, isNullOrUndefined } from '../../utils/types'; import { isString, isNullOrUndefined } from '../../utils/types';
import { accessibilityIdentifierProperty } from '../../accessibility/accessibility-properties'; import { accessibilityIdentifierProperty } from '../../accessibility/accessibility-properties';
import * as Utils from '../../utils'; import * as Utils from '../../utils';
import { testIDProperty } from '../../ui/core/view';
export * from './text-base-common'; export * from './text-base-common';
@ -443,7 +444,14 @@ export class TextBase extends TextBaseCommon {
org.nativescript.widgets.ViewHelper.setPaddingLeft(this.nativeTextViewProtected, Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderLeftWidth, 0)); org.nativescript.widgets.ViewHelper.setPaddingLeft(this.nativeTextViewProtected, Length.toDevicePixels(value, 0) + Length.toDevicePixels(this.style.borderLeftWidth, 0));
} }
[testIDProperty.setNative](value: string): void {
this.setTestID(this.nativeTextViewProtected, value);
}
[accessibilityIdentifierProperty.setNative](value: string): void { [accessibilityIdentifierProperty.setNative](value: string): void {
if (typeof __USE_TEST_ID__ !== 'undefined' && __USE_TEST_ID__ && this.testID) {
// ignore when using testID;
} else {
// we override the default setter to apply it on nativeTextViewProtected // we override the default setter to apply it on nativeTextViewProtected
const id = Utils.ad.resources.getId(':id/nativescript_accessibility_id'); const id = Utils.ad.resources.getId(':id/nativescript_accessibility_id');
@ -452,6 +460,7 @@ export class TextBase extends TextBaseCommon {
this.nativeTextViewProtected.setTag(value); this.nativeTextViewProtected.setTag(value);
} }
} }
}
_setNativeText(reset = false): void { _setNativeText(reset = false): void {
if (reset) { if (reset) {

View File

@ -351,7 +351,8 @@ exports[`angular configuration for android 1`] = `
__IOS__: false, __IOS__: false,
'global.isAndroid': true, 'global.isAndroid': true,
'global.isIOS': false, 'global.isIOS': false,
process: 'global.process' process: 'global.process',
__USE_TEST_ID__: false
} }
), ),
/* config.plugin('CopyWebpackPlugin') */ /* config.plugin('CopyWebpackPlugin') */
@ -774,7 +775,8 @@ exports[`angular configuration for ios 1`] = `
__IOS__: true, __IOS__: true,
'global.isAndroid': false, 'global.isAndroid': false,
'global.isIOS': true, 'global.isIOS': true,
process: 'global.process' process: 'global.process',
__USE_TEST_ID__: false
} }
), ),
/* config.plugin('CopyWebpackPlugin') */ /* config.plugin('CopyWebpackPlugin') */

View File

@ -262,7 +262,8 @@ exports[`base configuration for android 1`] = `
__IOS__: false, __IOS__: false,
'global.isAndroid': true, 'global.isAndroid': true,
'global.isIOS': false, 'global.isIOS': false,
process: 'global.process' process: 'global.process',
__USE_TEST_ID__: false
} }
), ),
/* config.plugin('CopyWebpackPlugin') */ /* config.plugin('CopyWebpackPlugin') */
@ -582,7 +583,8 @@ exports[`base configuration for ios 1`] = `
__IOS__: true, __IOS__: true,
'global.isAndroid': false, 'global.isAndroid': false,
'global.isIOS': true, 'global.isIOS': true,
process: 'global.process' process: 'global.process',
__USE_TEST_ID__: false
} }
), ),
/* config.plugin('CopyWebpackPlugin') */ /* config.plugin('CopyWebpackPlugin') */

View File

@ -262,7 +262,8 @@ exports[`javascript configuration for android 1`] = `
__IOS__: false, __IOS__: false,
'global.isAndroid': true, 'global.isAndroid': true,
'global.isIOS': false, 'global.isIOS': false,
process: 'global.process' process: 'global.process',
__USE_TEST_ID__: false
} }
), ),
/* config.plugin('CopyWebpackPlugin') */ /* config.plugin('CopyWebpackPlugin') */
@ -591,7 +592,8 @@ exports[`javascript configuration for ios 1`] = `
__IOS__: true, __IOS__: true,
'global.isAndroid': false, 'global.isAndroid': false,
'global.isIOS': true, 'global.isIOS': true,
process: 'global.process' process: 'global.process',
__USE_TEST_ID__: false
} }
), ),
/* config.plugin('CopyWebpackPlugin') */ /* config.plugin('CopyWebpackPlugin') */

View File

@ -285,6 +285,7 @@ exports[`react configuration > android > adds ReactRefreshWebpackPlugin when HMR
'global.isAndroid': true, 'global.isAndroid': true,
'global.isIOS': false, 'global.isIOS': false,
process: 'global.process', process: 'global.process',
__USE_TEST_ID__: false,
__TEST__: false, __TEST__: false,
'process.env.NODE_ENV': '\\"development\\"' 'process.env.NODE_ENV': '\\"development\\"'
} }
@ -616,6 +617,7 @@ exports[`react configuration > android > base config 1`] = `
'global.isAndroid': true, 'global.isAndroid': true,
'global.isIOS': false, 'global.isIOS': false,
process: 'global.process', process: 'global.process',
__USE_TEST_ID__: false,
__TEST__: false, __TEST__: false,
'process.env.NODE_ENV': '\\"development\\"' 'process.env.NODE_ENV': '\\"development\\"'
} }
@ -954,6 +956,7 @@ exports[`react configuration > ios > adds ReactRefreshWebpackPlugin when HMR ena
'global.isAndroid': false, 'global.isAndroid': false,
'global.isIOS': true, 'global.isIOS': true,
process: 'global.process', process: 'global.process',
__USE_TEST_ID__: false,
__TEST__: false, __TEST__: false,
'process.env.NODE_ENV': '\\"development\\"' 'process.env.NODE_ENV': '\\"development\\"'
} }
@ -1286,6 +1289,7 @@ exports[`react configuration > ios > base config 1`] = `
'global.isAndroid': false, 'global.isAndroid': false,
'global.isIOS': true, 'global.isIOS': true,
process: 'global.process', process: 'global.process',
__USE_TEST_ID__: false,
__TEST__: false, __TEST__: false,
'process.env.NODE_ENV': '\\"development\\"' 'process.env.NODE_ENV': '\\"development\\"'
} }

View File

@ -289,7 +289,8 @@ exports[`svelte configuration for android 1`] = `
__IOS__: false, __IOS__: false,
'global.isAndroid': true, 'global.isAndroid': true,
'global.isIOS': false, 'global.isIOS': false,
process: 'global.process' process: 'global.process',
__USE_TEST_ID__: false
} }
), ),
/* config.plugin('CopyWebpackPlugin') */ /* config.plugin('CopyWebpackPlugin') */
@ -630,7 +631,8 @@ exports[`svelte configuration for ios 1`] = `
__IOS__: true, __IOS__: true,
'global.isAndroid': false, 'global.isAndroid': false,
'global.isIOS': true, 'global.isIOS': true,
process: 'global.process' process: 'global.process',
__USE_TEST_ID__: false
} }
), ),
/* config.plugin('CopyWebpackPlugin') */ /* config.plugin('CopyWebpackPlugin') */

View File

@ -262,7 +262,8 @@ exports[`typescript configuration for android 1`] = `
__IOS__: false, __IOS__: false,
'global.isAndroid': true, 'global.isAndroid': true,
'global.isIOS': false, 'global.isIOS': false,
process: 'global.process' process: 'global.process',
__USE_TEST_ID__: false
} }
), ),
/* config.plugin('CopyWebpackPlugin') */ /* config.plugin('CopyWebpackPlugin') */
@ -591,7 +592,8 @@ exports[`typescript configuration for ios 1`] = `
__IOS__: true, __IOS__: true,
'global.isAndroid': false, 'global.isAndroid': false,
'global.isIOS': true, 'global.isIOS': true,
process: 'global.process' process: 'global.process',
__USE_TEST_ID__: false
} }
), ),
/* config.plugin('CopyWebpackPlugin') */ /* config.plugin('CopyWebpackPlugin') */

View File

@ -302,7 +302,8 @@ exports[`vue configuration for android 1`] = `
__IOS__: false, __IOS__: false,
'global.isAndroid': true, 'global.isAndroid': true,
'global.isIOS': false, 'global.isIOS': false,
process: 'global.process' process: 'global.process',
__USE_TEST_ID__: false
} }
), ),
/* config.plugin('CopyWebpackPlugin') */ /* config.plugin('CopyWebpackPlugin') */
@ -656,7 +657,8 @@ exports[`vue configuration for ios 1`] = `
__IOS__: true, __IOS__: true,
'global.isAndroid': false, 'global.isAndroid': false,
'global.isIOS': true, 'global.isIOS': true,
process: 'global.process' process: 'global.process',
__USE_TEST_ID__: false
} }
), ),
/* config.plugin('CopyWebpackPlugin') */ /* config.plugin('CopyWebpackPlugin') */

View File

@ -425,6 +425,9 @@ export default function (config: Config, env: IWebpackEnv = _env): Config {
/* for compat only */ 'global.isIOS': platform === 'ios', /* for compat only */ 'global.isIOS': platform === 'ios',
process: 'global.process', process: 'global.process',
// enable testID when using --env.e2e
__USE_TEST_ID__: !!env.e2e,
// todo: ?!?! // todo: ?!?!
// profile: '() => {}', // profile: '() => {}',
}, },

View File

@ -49,6 +49,7 @@ export interface IWebpackEnv {
// misc // misc
replace?: string[] | string; replace?: string[] | string;
watchNodeModules?: boolean; watchNodeModules?: boolean;
e2e?: boolean;
} }
interface IChainEntry { interface IChainEntry {