chromium/ios/chrome/browser/autofill/model/automation/automation_action.mm

// 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/model/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/model/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 {
  std::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