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:
Родитель
7d3e48c60d
Коммит
46fc2070e9
|
@ -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';
|
||||||
|
|
Загрузка…
Ссылка в новой задаче