| // Copyright 2018 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #import "ios/chrome/browser/autofill/automation/automation_action.h" |
| |
| #import "base/apple/foundation_util.h" |
| #import "base/strings/sys_string_conversions.h" |
| #import "base/strings/utf_string_conversions.h" |
| #import "base/test/ios/wait_util.h" |
| #import "base/values.h" |
| #import "ios/chrome/browser/autofill/form_suggestion_constants.h" |
| #import "ios/chrome/browser/ui/infobars/infobar_constants.h" |
| #import "ios/chrome/test/earl_grey/chrome_actions.h" |
| #import "ios/chrome/test/earl_grey/chrome_earl_grey.h" |
| #import "ios/chrome/test/earl_grey/chrome_matchers.h" |
| #import "ios/testing/earl_grey/earl_grey_test.h" |
| #import "ios/web/public/test/element_selector.h" |
| |
| @interface AutomationAction () { |
| base::Value::Dict _actionDictionary; |
| } |
| |
| @property(nonatomic, readonly) const base::Value::Dict& actionDictionary; |
| |
| // Selects the proper subclass in the class cluster for the given type. Called |
| // from the class method creating the actions. |
| + (Class)classForType:(NSString*)type; |
| |
| - (instancetype)initWithValueDict:(base::Value::Dict)actionDictionary |
| NS_DESIGNATED_INITIALIZER; |
| @end |
| |
| // An action that always fails. |
| @interface AutomationActionUnrecognized : AutomationAction |
| @end |
| |
| // An action that simply tap on an element on the page. |
| // Right now this always assumes a click event of the following format: |
| // { |
| // "selectorType": "xpath", |
| // "selector": "//*[@id=\"add-to-cart-button\"]", |
| // "context": [], |
| // "type": "click" |
| // } |
| @interface AutomationActionClick : AutomationAction |
| @end |
| |
| // An action that waits for a series of JS assertions to become true before |
| // continuing. We assume this action has a format resembling: |
| // { |
| // "context": [], |
| // "type": "waitFor", |
| // "assertions": ["return document.querySelector().style.display === |
| // 'none';"] |
| // } |
| @interface AutomationActionWaitFor : AutomationAction |
| @end |
| |
| // An action that performs autofill on a form by selecting an element |
| // that is part of an autofillable form, then tapping the relevant |
| // autofill suggestion. We assume this action has a format resembling: |
| // { |
| // "selectorType": "xpath", |
| // "selector": "//*[@data-tl-id=\"COAC2ShpAddrFirstName\"]", |
| // "context": [], |
| // "type": "autofill" |
| // } |
| @interface AutomationActionAutofill : AutomationAction |
| @end |
| |
| // An action that validates a previously autofilled element. |
| // Confirms that the target element has is of the expected autofill field type |
| // and contains the specified value. |
| // We assume this action has a format resembling: |
| // { |
| // "selectorType": "xpath", |
| // "selector": "//*[@data-tl-id=\"COAC2ShpAddrFirstName\"]", |
| // "context": [], |
| // "expectedAutofillType": "NAME_FIRST", |
| // "expectedValue": "Yuki", |
| // "type": "validateField" |
| // } |
| @interface AutomationActionValidateField : AutomationAction |
| @end |
| |
| // An action that selects a given option from a dropdown selector. |
| // Checks are not made to confirm that given item is a dropdown. |
| // We assume this action has a format resembling: |
| // { |
| // "selectorType": "xpath", |
| // "selector": "//*[@id=\"shipping-user-lookup-options\"]", |
| // "context": [], |
| // "index": 1, |
| // "type": "select" |
| // } |
| @interface AutomationActionSelectDropdown : AutomationAction |
| @end |
| |
| // An action that loads a web page. |
| // This is recorded in tandem with the actions that cause loads to |
| // occur (i.e. clicking on a link); therefore, this action is |
| // a no-op when replaying. |
| // We assume this action has a format resembling: |
| // { |
| // "url": "www.google.com", |
| // "type": "loadPage" |
| // } |
| @interface AutomationActionLoadPage : AutomationAction |
| @end |
| |
| // An action that types the provided text in the specified field. |
| // This can be either of the "type" or "typePassword" type due to |
| // the two actions needing to be handled differently on desktop in order |
| // to ensure passwords are saved. They can be treated the same on iOS. |
| // We assume this action has a format resembling: |
| // { |
| // "type": "type" OR "typePassword", |
| // "selector": "//input[@autocapitalize=\"none\" and |
| // @name=\"session[password]\"]", "value": "mycoolpassword", |
| // } |
| @interface AutomationActionType : AutomationAction |
| @end |
| |
| // An action that selects the affirmative option in an open confirmation dialog. |
| // One main use case is selecting "Save" from the "Save password?" dialog, |
| // but this action cannot tell the difference between different confirmation |
| // dialogs, so it is multi-purpose. This action assumes that this dialog is |
| // already open. |
| // We assume this action has a format resembling: |
| // { |
| // "type": "savePassword" |
| // } |
| @interface AutomationActionConfirmInfobar : AutomationAction |
| @end |
| |
| @implementation AutomationAction |
| |
| + (instancetype)actionWithValueDict:(base::Value::Dict)actionDictionary { |
| const std::string* type = actionDictionary.FindString("type"); |
| GREYAssert(type, @"Type is missing in action."); |
| GREYAssert(!type->empty(), @"Type is an empty value."); |
| |
| return [[[self classForType:base::SysUTF8ToNSString(*type)] alloc] |
| initWithValueDict:std::move(actionDictionary)]; |
| } |
| |
| + (Class)classForType:(NSString*)type { |
| static NSDictionary* classForType = @{ |
| @"click" : [AutomationActionClick class], |
| @"waitFor" : [AutomationActionWaitFor class], |
| @"autofill" : [AutomationActionAutofill class], |
| @"validateField" : [AutomationActionValidateField class], |
| @"select" : [AutomationActionSelectDropdown class], |
| @"loadPage" : [AutomationActionLoadPage class], |
| @"type" : [AutomationActionType class], |
| @"typePassword" : [AutomationActionType class], |
| @"savePassword" : [AutomationActionConfirmInfobar class], |
| // More to come. |
| }; |
| |
| return classForType[type] ?: [AutomationActionUnrecognized class]; |
| } |
| |
| - (instancetype)initWithValueDict:(base::Value::Dict)actionDictionary { |
| self = [super init]; |
| if (self) { |
| _actionDictionary = std::move(actionDictionary); |
| } |
| return self; |
| } |
| |
| - (void)execute { |
| GREYAssert(NO, @"Should not be called!"); |
| } |
| |
| - (const base::Value::Dict&)actionDictionary { |
| return _actionDictionary; |
| } |
| |
| // A shared flow across many actions, this waits for the target element to be |
| // visible, scrolls it into view, then taps on it. |
| - (void)tapOnTarget:(ElementSelector*)selector { |
| |
| // Wait for the element to be visible on the page. |
| [ChromeEarlGrey waitForWebStateContainingElement:selector]; |
| |
| // Potentially scroll into view if below the fold. |
| [[EarlGrey selectElementWithMatcher:chrome_test_util::WebViewMatcher()] |
| performAction:chrome_test_util::ScrollElementToVisible(selector)]; |
| |
| // Calling WebViewTapElement right after WebViewScrollElement caused flaky |
| // issues with the wrong location being provided for the tap target, |
| // seemingly caused by the screen not redrawing in-between these two actions. |
| // We force a brief wait here to avoid this issue. `waitWithTimeout` requires |
| // its result to be used. Void the result as it's always false. |
| (void)[[GREYCondition conditionWithName:@"forced wait to allow for redraw" |
| block:^BOOL { |
| return false; |
| }] waitWithTimeout:0.1]; |
| // Tap on the element. |
| [[EarlGrey selectElementWithMatcher:chrome_test_util::WebViewMatcher()] |
| performAction:chrome_test_util::TapWebElement(selector)]; |
| } |
| |
| // Creates a selector targeting the element specified in the action. |
| - (ElementSelector*)selectorForTarget { |
| const std::string xpath = [self stringFromDictionaryWithKey:"selector"]; |
| |
| // Creates a selector from the action dictionary. |
| ElementSelector* selector = [ElementSelector selectorWithXPathQuery:xpath]; |
| return selector; |
| } |
| |
| // Returns a std::string corrensponding to the given key in the action |
| // dictionary. Will raise a test failure if the key is missing or the value is |
| // empty. |
| - (std::string)stringFromDictionaryWithKey:(const std::string&)key { |
| const std::string* expectedType = self.actionDictionary.FindString(key); |
| GREYAssert(expectedType, @"%s is missing in action.", key.c_str()); |
| GREYAssert(!expectedType->empty(), @"%s is an empty value", key.c_str()); |
| |
| return *expectedType; |
| } |
| |
| // Returns an int corrensponding to the given key in the action |
| // dictionary. Will raise a test failure if the key is missing or the value is |
| // empty. |
| - (int)intFromDictionaryWithKey:(const std::string&)key { |
| absl::optional<int> expectedTypeValue = self.actionDictionary.FindInt(key); |
| GREYAssert(expectedTypeValue, @"%s is missing in action.", key.c_str()); |
| |
| return *expectedTypeValue; |
| } |
| |
| // Runs the JS code passed in against the target element specified by the |
| // selector passed in. The target element is passed in to the JS function |
| // by the name "target", so example JS code is like: |
| // return target.value |
| - (base::Value)executeJavaScript:(std::string)function |
| onTarget:(ElementSelector*)selector { |
| NSString* javaScript = [NSString |
| stringWithFormat:@" (function() {" |
| " try {" |
| " return function(target){%@}(%@);" |
| " } catch (ex) {return 'Exception encountered " |
| "' + ex.message;}" |
| " " |
| " })();", |
| base::SysUTF8ToNSString(function), |
| selector.selectorScript]; |
| |
| return [ChromeEarlGrey evaluateJavaScript:javaScript]; |
| } |
| |
| @end |
| |
| @implementation AutomationActionClick |
| |
| - (void)execute { |
| ElementSelector* selector = [self selectorForTarget]; |
| [self tapOnTarget:selector]; |
| } |
| |
| @end |
| |
| @implementation AutomationActionLoadPage |
| |
| - (void)execute { |
| // loadPage is a no-op action - perform nothing |
| } |
| |
| @end |
| |
| @implementation AutomationActionWaitFor |
| |
| - (void)execute { |
| const base::Value::List* assertionsValues = |
| self.actionDictionary.FindList("assertions"); |
| GREYAssert(assertionsValues, @"Assertions key is missing in action."); |
| GREYAssert(assertionsValues->size(), @"Assertions list is empty."); |
| |
| std::vector<std::string> state_assertions; |
| |
| for (auto const& assertionValue : *assertionsValues) { |
| const std::string assertionString(assertionValue.GetString()); |
| GREYAssert(!assertionString.empty(), @"assertionString is an empty value."); |
| state_assertions.push_back(assertionString); |
| } |
| |
| NSString* conditionDescription = |
| @"waitFor State change hasn't completed within timeout."; |
| GREYCondition* waitForElement = [GREYCondition |
| conditionWithName:conditionDescription |
| block:^{ |
| return |
| [self checkForJsAssertionFailures:state_assertions] == |
| nil; |
| }]; |
| bool waitForCompleted = [waitForElement |
| waitWithTimeout:base::test::ios::kWaitForActionTimeout.InSecondsF()]; |
| GREYAssertTrue(waitForCompleted, conditionDescription); |
| } |
| |
| // Executes a vector of Javascript assertions on the webpage, returning the |
| // first assertion that fails to be true, or nil if all assertions are true. |
| - (NSString*)checkForJsAssertionFailures: |
| (const std::vector<std::string>&)assertions { |
| for (std::string const& assertion : assertions) { |
| NSString* assertionString = base::SysUTF8ToNSString(assertion); |
| NSString* javascript = [NSString stringWithFormat:@"" |
| " (function() {" |
| " try {" |
| " %@" |
| " } catch (ex) {}" |
| " return false;" |
| " })();", |
| assertionString]; |
| |
| base::Value result = [ChromeEarlGrey evaluateJavaScript:javascript]; |
| GREYAssertTrue(result.is_bool(), @"The result is not a boolean."); |
| |
| if (!result.GetBool()) { |
| return assertionString; |
| } |
| } |
| return nil; |
| } |
| |
| @end |
| |
| @implementation AutomationActionAutofill |
| |
| - (void)execute { |
| // The autofill profile is configured in |
| // automation_egtest::prepareAutofillProfileWithValues. |
| |
| ElementSelector* selector = [self selectorForTarget]; |
| [self tapOnTarget:selector]; |
| |
| // Tap on the autofill suggestion to perform the actual autofill. |
| [[EarlGrey |
| selectElementWithMatcher:grey_accessibilityID( |
| kFormSuggestionLabelAccessibilityIdentifier)] |
| performAction:grey_tap()]; |
| } |
| |
| @end |
| |
| @implementation AutomationActionValidateField |
| |
| - (void)execute { |
| ElementSelector* selector = [self selectorForTarget]; |
| |
| // Wait for the element to be visible on the page. |
| [ChromeEarlGrey waitForWebStateContainingElement:selector]; |
| |
| NSString* expectedType = base::SysUTF8ToNSString( |
| [self stringFromDictionaryWithKey:"expectedAutofillType"]); |
| NSString* expectedValue = base::SysUTF8ToNSString( |
| [self stringFromDictionaryWithKey:"expectedValue"]); |
| |
| base::Value result = [self executeJavaScript:"return target.placeholder;" |
| onTarget:[self selectorForTarget]]; |
| GREYAssertTrue(result.is_string(), @"The result is not a string."); |
| NSString* predictionType = base::SysUTF8ToNSString(result.GetString()); |
| |
| result = [self executeJavaScript:"return target.value;" onTarget:selector]; |
| GREYAssertTrue(result.is_string(), @"The result is not a string."); |
| NSString* autofilledValue = base::SysUTF8ToNSString(result.GetString()); |
| |
| GREYAssertEqualObjects(predictionType, expectedType, |
| @"Expected prediction type %@ but got %@", |
| expectedType, predictionType); |
| GREYAssertEqualObjects(autofilledValue, expectedValue, |
| @"Expected autofilled value %@ but got %@", |
| expectedValue, autofilledValue); |
| } |
| |
| @end |
| |
| @implementation AutomationActionSelectDropdown |
| |
| - (void)execute { |
| ElementSelector* selector = [self selectorForTarget]; |
| |
| // Wait for the element to be visible on the page. |
| [ChromeEarlGrey waitForWebStateContainingElement:selector]; |
| |
| int selectedIndex = [self intFromDictionaryWithKey:"index"]; |
| [self executeJavaScript: |
| base::SysNSStringToUTF8([NSString |
| stringWithFormat:@"target.options.selectedIndex = %d; " |
| @"triggerOnChangeEventOnElement(target);", |
| selectedIndex]) |
| onTarget:selector]; |
| } |
| |
| @end |
| |
| @implementation AutomationActionUnrecognized |
| |
| - (void)execute { |
| const std::string* type = self.actionDictionary.FindString("type"); |
| GREYAssert(type, @"Unknown action; missing type string"); |
| GREYAssert(NO, @"Unknown action of type %s", type->c_str()); |
| } |
| |
| @end |
| |
| @implementation AutomationActionType |
| |
| - (void)execute { |
| ElementSelector* selector = [self selectorForTarget]; |
| std::string value = [self stringFromDictionaryWithKey:"value"]; |
| [self executeJavaScript: |
| base::SysNSStringToUTF8([NSString |
| stringWithFormat: |
| @"__gCrWeb.fill.setInputElementValue(\"%s\", target);", |
| value.c_str()]) |
| onTarget:selector]; |
| } |
| |
| @end |
| |
| @implementation AutomationActionConfirmInfobar |
| |
| - (void)execute { |
| [[EarlGrey |
| selectElementWithMatcher: |
| grey_accessibilityID(kConfirmInfobarButton1AccessibilityIdentifier)] |
| performAction:grey_tap()]; |
| } |
| |
| @end |