blob: 0a92f03b7e173dc4b6353eee4f4e09dfd2a3229c [file] [log] [blame]
// Copyright 2018 The Chromium Authors. All rights reserved.
// 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 <EarlGrey/EarlGrey.h>
#include "base/guid.h"
#include "base/mac/foundation_util.h"
#include "base/strings/stringprintf.h"
#include "base/strings/sys_string_conversions.h"
#include "base/strings/utf_string_conversions.h"
#import "base/test/ios/wait_util.h"
#include "components/autofill/core/browser/autofill_manager.h"
#include "components/autofill/core/browser/personal_data_manager.h"
#include "components/autofill/ios/browser/autofill_driver_ios.h"
#import "ios/chrome/browser/autofill/form_suggestion_label.h"
#import "ios/chrome/browser/ui/infobars/infobar_constants.h"
#import "ios/chrome/test/app/chrome_test_util.h"
#import "ios/chrome/test/earl_grey/chrome_earl_grey.h"
#import "ios/testing/nserror_util.h"
#include "ios/web/public/js_messaging/web_frame_util.h"
#import "ios/web/public/js_messaging/web_frames_manager.h"
#import "ios/web/public/test/earl_grey/web_view_actions.h"
#import "ios/web/public/test/earl_grey/web_view_matchers.h"
#include "ios/web/public/test/element_selector.h"
#import "ios/web/public/test/js_test_util.h"
#if !defined(__has_feature) || !__has_feature(objc_arc)
#error "This file requires ARC support."
#endif
@interface AutomationAction () {
std::unique_ptr<const base::DictionaryValue> actionDictionary_;
}
@property(nonatomic, readonly)
const std::unique_ptr<const base::DictionaryValue>& 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)initWithValueDictionary:
(const base::DictionaryValue&)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)actionWithValueDictionary:
(const base::DictionaryValue&)actionDictionary {
const base::Value* typeValue =
actionDictionary.FindKeyOfType("type", base::Value::Type::STRING);
GREYAssert(typeValue, @"Type is missing in action.");
const std::string type(typeValue->GetString());
GREYAssert(!type.empty(), @"Type is an empty value.");
return [[[self classForType:base::SysUTF8ToNSString(type)] alloc]
initWithValueDictionary: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)initWithValueDictionary:
(const base::DictionaryValue&)actionDictionary {
self = [super init];
if (self) {
actionDictionary_ = actionDictionary.DeepCopyWithoutEmptyChildren();
}
return self;
}
- (void)execute {
GREYAssert(NO, @"Should not be called!");
}
- (const std::unique_ptr<const base::DictionaryValue>&)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 {
web::WebState* web_state = chrome_test_util::GetCurrentWebState();
// Wait for the element to be visible on the page.
[ChromeEarlGrey waitForWebStateContainingElement:selector];
// Potentially scroll into view if below the fold.
[[EarlGrey selectElementWithMatcher:web::WebViewInWebState(web_state)]
performAction:WebViewScrollElementToVisible(web_state, 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.
[[GREYCondition conditionWithName:@"forced wait to allow for redraw"
block:^BOOL {
return false;
}] waitWithTimeout:0.1];
// Tap on the element.
[[EarlGrey selectElementWithMatcher:web::WebViewInWebState(web_state)]
performAction:web::WebViewTapElement(web_state, selector)];
}
// Creates a selector targeting the element specified in the action.
- (ElementSelector*)selectorForTarget {
const std::string xpath = [self getStringFromDictionaryWithKey:"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)getStringFromDictionaryWithKey:(std::string)key {
const base::Value* expectedTypeValue(
self.actionDictionary->FindKeyOfType(key, base::Value::Type::STRING));
GREYAssert(expectedTypeValue, @"%s is missing in action.", key.c_str());
const std::string expectedType(expectedTypeValue->GetString());
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)getIntFromDictionaryWithKey:(std::string)key {
const base::Value* expectedTypeValue(
self.actionDictionary->FindKeyOfType(key, base::Value::Type::INTEGER));
GREYAssert(expectedTypeValue, @"%s is missing in action.", key.c_str());
return expectedTypeValue->GetInt();
}
// 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
- (id)executeJavascript:(std::string)function
onTarget:(ElementSelector*)selector {
NSError* error;
id result = chrome_test_util::ExecuteJavaScript(
[NSString
stringWithFormat:@" (function() {"
" try {"
" return function(target){%@}(%@);"
" } catch (ex) {return 'Exception encountered "
"' + ex.message;}"
" "
" })();",
base::SysUTF8ToNSString(function),
selector.selectorScript],
&error);
if (error) {
GREYAssert(NO, @"Javascript execution error: %@", result);
return nil;
}
return result;
}
@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* assertionsValue(self.actionDictionary->FindKeyOfType(
"assertions", base::Value::Type::LIST));
GREYAssert(assertionsValue, @"Assertions key is missing in action.");
const base::Value::ListStorage& assertionsValues(assertionsValue->GetList());
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);
}
GREYAssert(base::test::ios::WaitUntilConditionOrTimeout(
base::test::ios::kWaitForActionTimeout,
^{
return [self CheckForJsAssertionFailures:state_assertions] ==
nil;
}),
@"waitFor State change hasn't completed within timeout.");
}
// 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) {
NSError* error;
NSString* assertionString = base::SysUTF8ToNSString(assertion);
NSNumber* result =
base::mac::ObjCCastStrict<NSNumber>(chrome_test_util::ExecuteJavaScript(
[NSString stringWithFormat:@""
" (function() {"
" try {"
" %@"
" } catch (ex) {}"
" return false;"
" })();",
assertionString],
&error));
if (![result boolValue] || error) {
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 getStringFromDictionaryWithKey:"expectedAutofillType"]);
NSString* expectedValue = base::SysUTF8ToNSString(
[self getStringFromDictionaryWithKey:"expectedValue"]);
NSString* predictionType = base::mac::ObjCCastStrict<NSString>([self
executeJavascript:"return target.placeholder;"
onTarget:[self selectorForTarget]]);
NSString* autofilledValue = base::mac::ObjCCastStrict<NSString>(
[self executeJavascript:"return target.value;" onTarget:selector]);
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 getIntFromDictionaryWithKey:"index"];
[self executeJavascript:
base::SysNSStringToUTF8([NSString
stringWithFormat:@"target.options.selectedIndex = %d; "
@"triggerOnChangeEventOnElement(target);",
selectedIndex])
onTarget:selector];
}
@end
@implementation AutomationActionUnrecognized
- (void)execute {
const base::Value* typeValue =
self.actionDictionary->FindKeyOfType("type", base::Value::Type::STRING);
const std::string type(typeValue->GetString());
GREYAssert(NO, @"Unknown action of type %s", type.c_str());
}
@end
@implementation AutomationActionType
- (void)execute {
ElementSelector* selector = [self selectorForTarget];
std::string value = [self getStringFromDictionaryWithKey:"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