Add AutomationClient and AutomationElement Facade (#8517)

* Add AutomationClient and AutomationElement Facade

WebDriverIO exposes a whole webdriver client, with loads of functionality that doesn't work or make sense on a desktop app. This facade exposes the bits that are supported by WinAppDriver, in an easier API. It also lets us potentially decouple from WebDriverIO if we want to move to a differnt WebDriver client.

Currently being done in the test app, but will be extracted to a separate package after to allow use in apps outside the repo.

* update docs

* Keep dumpVisualTree outside of AutomationClient for now

* fix lint

* raw example
This commit is contained in:
Nick Gerleman 2021-09-01 22:24:18 -07:00 коммит произвёл GitHub
Родитель 7d3e48c60d
Коммит 46fc2070e9
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
11 изменённых файлов: 128 добавлений и 56 удалений

Просмотреть файл

@ -116,37 +116,13 @@ describe('FancyWidget', () => {
test('FancyWidget is populated with placeholder', async () => { test('FancyWidget is populated with placeholder', async () => {
// Query for an element with accessibilityId of "foo" (see "locators" below) // Query for an element with accessibilityId of "foo" (see "locators" below)
const field = await $('~foo'); const field = await app.findElementByTestID('foo');
expect(await field.getText()).toBe('placeholder'); expect(await field.getText()).toBe('placeholder');
}); });
}); });
``` ```
### Locators to find UI Element
No matter what JavaScript framework you choose for native app testing, you have to use one of the locators which is described in [mobile JSON wire protocol](https://github.com/SeleniumHQ/mobile-spec/blob/master/spec-draft.md#locator-strategies). Since locators are implemented significant different on iOS, Android and Windows, as below I only talk about the locators for Windows.
[Locators WinAppDriver supports](https://github.com/microsoft/WinAppDriver/blob/master/Docs/AuthoringTestScripts.md#supported-locators-to-find-ui-elements)
WinAppDriver provides rich API to help locate the UI element. If [testID](https://facebook.github.io/react-native/docs/picker-item#testid) is specified in React Native app for Windows, the locator strategy should choose `accessibility id`.
| **Client API** | **Locator Strategy** | **Matched Attribute in inspect.exe** | **Example** |
| --- | --- | --- | --- |
| FindElementByAccessibilityId | accessibility id | AutomationId | AppNameTitle |
| FindElementByClassName | class name | ClassName | TextBlock |
| FindElementById | Id | RuntimeId (decimal) | 42.333896.3.1 |
| FindElementByName | Name | Name | Calculator |
| FindElementByTagName | tag name | LocalizedControlType (upper camel case) | Text |
| FindElementByXPath | Xpath | Any | //Button[0] |
[Selectors WebDriverIO supports](https://webdriver.io/docs/selectors.html#mobile-selectors)
| **Client API by Example** | **Locator Strategy** |
| --- | --- |
| $('~AppNameTitle') | accessibility id |
| $('TextBlock') | class name |
### Adding a custom RNTester page ### Adding a custom RNTester page
Before adding a custom page, consider whether the change can be made to an existing RNTester page and upstreamed. If needed, new examples may be integrated into the Windows fork of RNTester, [`@react-native-windows/tester`](../packages/@react-native-windows/tester). Before adding a custom page, consider whether the change can be made to an existing RNTester page and upstreamed. If needed, new examples may be integrated into the Windows fork of RNTester, [`@react-native-windows/tester`](../packages/@react-native-windows/tester).

Просмотреть файл

@ -2,8 +2,6 @@ module.exports = {
extends: ['@rnw-scripts'], extends: ['@rnw-scripts'],
parserOptions: { tsconfigRootDir: __dirname }, parserOptions: { tsconfigRootDir: __dirname },
globals: { globals: {
$: 'readonly',
browser: 'readonly',
expect: 'readonly', expect: 'readonly',
fail: 'readonly', fail: 'readonly',
rpcClient: 'readonly', rpcClient: 'readonly',

Просмотреть файл

@ -23,7 +23,7 @@ global.jasmine.getEnv().addReporter({
); );
const filename = path.join(screenshotDir, friendlySpecName); const filename = path.join(screenshotDir, friendlySpecName);
await browser.saveScreenshot(filename); await global.browser.saveScreenshot(filename);
} }
}, },
}); });

Просмотреть файл

@ -5,7 +5,7 @@
* @format * @format
*/ */
import {goToComponentExample, dumpVisualTree} from './framework'; import {app, goToComponentExample, dumpVisualTree} from './framework';
beforeAll(async () => { beforeAll(async () => {
await goToComponentExample('Display:none Style'); await goToComponentExample('Display:none Style');
@ -27,6 +27,8 @@ describe('DisplayNoneTest', () => {
}); });
async function toggleDisplayNone() { async function toggleDisplayNone() {
const showDisplayNoneToggle = await $('~toggle-display:none'); const showDisplayNoneToggle = await app.findElementByTestID(
'toggle-display:none',
);
await showDisplayNoneToggle.click(); await showDisplayNoneToggle.click();
} }

Просмотреть файл

@ -5,7 +5,7 @@
* @format * @format
*/ */
import {goToComponentExample, dumpVisualTree} from './framework'; import {app, goToComponentExample, dumpVisualTree} from './framework';
beforeAll(async () => { beforeAll(async () => {
await goToComponentExample('LegacyControlStyleTest'); await goToComponentExample('LegacyControlStyleTest');
@ -34,6 +34,6 @@ describe('LegacyControlStyleTest', () => {
}); });
async function toggleControlBorder() { async function toggleControlBorder() {
const showBorderToggle = await $('~show-border-toggle'); const showBorderToggle = await app.findElementByTestID('show-border-toggle');
await showBorderToggle.click(); await showBorderToggle.click();
} }

Просмотреть файл

@ -5,7 +5,7 @@
* @format * @format
*/ */
import {goToComponentExample, dumpVisualTree} from './framework'; import {app, goToComponentExample, dumpVisualTree} from './framework';
beforeAll(async () => { beforeAll(async () => {
await goToComponentExample('LegacyImageTest'); await goToComponentExample('LegacyImageTest');
@ -40,11 +40,13 @@ describe('LegacyImageTest', () => {
}); });
async function toggleImageBorder() { async function toggleImageBorder() {
const imageBorderToggle = await $('~toggle-border-button'); const imageBorderToggle = await app.findElementByTestID(
'toggle-border-button',
);
await imageBorderToggle.click(); await imageBorderToggle.click();
} }
async function toggleRTLMode() { async function toggleRTLMode() {
const rtlToggleButton = await $('~set-rtl-button'); const rtlToggleButton = await app.findElementByTestID('set-rtl-button');
await rtlToggleButton.click(); await rtlToggleButton.click();
} }

Просмотреть файл

@ -5,7 +5,7 @@
* @format * @format
*/ */
import {goToComponentExample} from './framework'; import {goToComponentExample, app} from './framework';
beforeAll(async () => { beforeAll(async () => {
await goToComponentExample('LegacyLoginTest'); await goToComponentExample('LegacyLoginTest');
@ -67,32 +67,34 @@ describe('LegacyLoginTest', () => {
}); });
async function setUsername(username: string) { async function setUsername(username: string) {
const usernameField = await $('~username-field'); const usernameField = await app.findElementByTestID('username-field');
await usernameField.setValue(username); await usernameField.setValue(username);
} }
async function setPassword(password: string) { async function setPassword(password: string) {
const passwordField = await $('~password-field'); const passwordField = await app.findElementByTestID('password-field');
await passwordField.setValue(password); await passwordField.setValue(password);
} }
async function appendPassword(password: string) { async function appendPassword(password: string) {
const passwordField = await $('~password-field'); const passwordField = await app.findElementByTestID('password-field');
await passwordField.addValue('End'); await passwordField.addValue('End');
await passwordField.addValue(password); await passwordField.addValue(password);
} }
async function toggleShowPassword() { async function toggleShowPassword() {
const showPasswordToggle = await $('~show-password-toggle'); const showPasswordToggle = await app.findElementByTestID(
'show-password-toggle',
);
await showPasswordToggle.click(); await showPasswordToggle.click();
} }
async function submitForm() { async function submitForm() {
const submitButton = await $('~submit-button'); const submitButton = await app.findElementByTestID('submit-button');
await submitButton.click(); await submitButton.click();
} }
async function getLoginResult(): Promise<string> { async function getLoginResult(): Promise<string> {
const loginResult = await $('~result-text'); const loginResult = await app.findElementByTestID('result-text');
return await loginResult.getText(); return await loginResult.getText();
} }

Просмотреть файл

@ -5,7 +5,7 @@
* @format * @format
*/ */
import {goToComponentExample} from './framework'; import {goToComponentExample, app} from './framework';
beforeAll(async () => { beforeAll(async () => {
await goToComponentExample('LegacyTextInputTest'); await goToComponentExample('LegacyTextInputTest');
@ -86,21 +86,21 @@ describe('LegacyTextInputTest', () => {
}); });
async function textInputField() { async function textInputField() {
return await $('~textinput-field'); return await app.findElementByTestID('textinput-field');
} }
async function autoCapsTextInputField() { async function autoCapsTextInputField() {
return await $('~auto-caps-textinput-field'); return await app.findElementByTestID('auto-caps-textinput-field');
} }
async function multiLineTextInputField() { async function multiLineTextInputField() {
return await $('~multi-line-textinput-field'); return await app.findElementByTestID('multi-line-textinput-field');
} }
async function assertLogContains(text: string) { async function assertLogContains(text: string) {
const textLogComponent = await $('~textinput-log'); const textLogComponent = await app.findElementByTestID('textinput-log');
await browser.waitUntil( await app.waitUntil(
async () => { async () => {
const loggedText = await textLogComponent.getText(); const loggedText = await textLogComponent.getText();
return loggedText.split('\n').includes(text); return loggedText.split('\n').includes(text);
@ -112,9 +112,9 @@ async function assertLogContains(text: string) {
} }
async function assertLogContainsInOrder(expectedLines: string[]) { async function assertLogContainsInOrder(expectedLines: string[]) {
const textLogComponent = await $('~textinput-log'); const textLogComponent = await app.findElementByTestID('textinput-log');
await browser.waitUntil( await app.waitUntil(
async () => { async () => {
const loggedText = await textLogComponent.getText(); const loggedText = await textLogComponent.getText();
const actualLines = loggedText.split('\n'); const actualLines = loggedText.split('\n');

Просмотреть файл

@ -0,0 +1,89 @@
/**
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT License.
*
* @format
*/
/* global $:false, browser:false */
/**
* Projection of a WebDriver Element, with functions corresponding to supported
* WinAppDriver APIs.
*
* See https://github.com/microsoft/WinAppDriver/blob/master/Docs/SupportedAPIs.md
*/
export type AutomationElement = Pick<
WebdriverIO.Element,
| 'addValue'
| 'clearValue'
| 'click'
| 'doubleClick'
| 'getAttribute'
| 'getLocation'
| 'getSize'
| 'getText'
| 'getValue'
| 'isDisplayed'
| 'isDisplayedInViewport'
| 'isEnabled'
| 'isEqual'
| 'isSelected'
| 'moveTo'
| 'saveScreenshot'
| 'setValue'
| 'touchAction'
| 'waitForDisplayed'
| 'waitForExist'
>;
/**
* WebDriverIO exposes a whole webdriver client, with loads of functionality
* that doesn't work or make sense on a desktop app. This facade exposes the
* bits that consistently work, in an easier API.
*
* This may not be 100% complete
*/
export const app = {
/**
* Find an element by testID property
*/
findElementByTestID: (id: string): Promise<AutomationElement> => $(`~${id}`),
/**
* Find an element by Automation ID
*
* https://docs.microsoft.com/en-us/dotnet/api/system.windows.automation.automationelement.automationelementinformation.automationid?view=net-5.0
*/
findElementByAutomationID: (id: string): Promise<AutomationElement> =>
$(`~${id}`),
/**
* Finds an element by the name of its class name (e.g. ListViewItem)
*
* https://docs.microsoft.com/en-us/dotnet/api/system.windows.automation.automationelement.automationelementinformation.classname?view=net-5.0
*/
findElementByClassName: (className: string): Promise<AutomationElement> =>
$(className),
/**
* Find element by ControlType (e.g. Button, CheckBox)
*
* https://docs.microsoft.com/en-us/dotnet/api/system.windows.automation.controltype?view=net-5.0
*/
findElementByControlType: (controlType: string): Promise<AutomationElement> =>
$(`<${controlType} />`),
/**
* Find element by a WinAppDriver compatible XPath Selector (e.g. '//Button[@AutomationId=\"MoreButton\"]')
*/
findElementByXPath: (xpath: string): Promise<AutomationElement> => $(xpath),
setWindowSize: browser.setWindowSize.bind(browser),
setWindowPosition: browser.setWindowPosition.bind(browser),
getWindowPosition: browser.getWindowPosition.bind(browser),
getWindowSize: browser.getWindowSize.bind(browser),
switchWindow: browser.switchWindow.bind(browser),
waitUntil: browser.waitUntil.bind(browser),
};

Просмотреть файл

@ -5,11 +5,13 @@
* @format * @format
*/ */
import {app} from './AutomationClient';
/** /**
* Visit an example on the RNTester Components tab * Visit an example on the RNTester Components tab
*/ */
export async function goToComponentExample(example: string) { export async function goToComponentExample(example: string) {
const componentsTabButton = await $('~components-tab'); const componentsTabButton = await app.findElementByTestID('components-tab');
await componentsTabButton.click(); await componentsTabButton.click();
await goToExample(example); await goToExample(example);
} }
@ -18,24 +20,24 @@ export async function goToComponentExample(example: string) {
* Visit an example on the RNTester APIs tab * Visit an example on the RNTester APIs tab
*/ */
export async function goToApiExample(example: string) { export async function goToApiExample(example: string) {
const componentsTabButton = await $('~apis-tab'); const componentsTabButton = await app.findElementByTestID('apis-tab');
await componentsTabButton.click(); await componentsTabButton.click();
await goToExample(example); await goToExample(example);
} }
async function goToExample(example: string) { async function goToExample(example: string) {
// Filter the list down to the one test, to improve the stability of selectors // Filter the list down to the one test, to improve the stability of selectors
const searchBox = await $('~explorer_search'); const searchBox = await app.findElementByTestID('explorer_search');
await searchBox.setValue(regexEscape(example)); await searchBox.setValue(regexEscape(example));
const exampleButton = await $(`~${example}`); const exampleButton = await app.findElementByTestID(example);
await exampleButton.click(); await exampleButton.click();
// Make sure we've launched the example by waiting until the search box is // Make sure we've launched the example by waiting until the search box is
// no longer present, but make sure we haven't crashed by checking that nav // no longer present, but make sure we haven't crashed by checking that nav
// buttons are still visible // buttons are still visible
await browser.waitUntil(async () => !(await exampleButton.isDisplayed())); await app.waitUntil(async () => !(await exampleButton.isDisplayed()));
const componentsTab = await $('~components-tab'); const componentsTab = await app.findElementByTestID('components-tab');
expect(await componentsTab.isDisplayed()).toBe(true); expect(await componentsTab.isDisplayed()).toBe(true);
} }

Просмотреть файл

@ -5,5 +5,6 @@
* @format * @format
*/ */
export * from './AutomationClient';
export * from './Navigation'; export * from './Navigation';
export * from './TreeDump'; export * from './TreeDump';