chromium/ios/chrome/browser/autofill/model/suggestion_controller_java_script_feature_unittest.mm

// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#import "components/autofill/ios/browser/suggestion_controller_java_script_feature.h"

#import <Foundation/Foundation.h>

#import "base/strings/sys_string_conversions.h"
#import "base/test/ios/wait_util.h"
#import "base/test/test_timeouts.h"
#import "ios/chrome/browser/shared/model/profile/test/test_profile_ios.h"
#import "ios/chrome/browser/web/model/chrome_web_client.h"
#import "ios/web/public/js_messaging/web_frame.h"
#import "ios/web/public/js_messaging/web_frames_manager.h"
#import "ios/web/public/test/js_test_util.h"
#import "ios/web/public/test/scoped_testing_web_client.h"
#import "ios/web/public/test/web_state_test_util.h"
#import "ios/web/public/test/web_task_environment.h"
#import "ios/web/public/web_state.h"
#import "testing/gtest_mac.h"
#import "testing/platform_test.h"

namespace {

// Test fixture to test suggestions.
class SuggestionControllerJavaScriptFeatureTest : public PlatformTest {
 protected:
  SuggestionControllerJavaScriptFeatureTest()
      : web_client_(std::make_unique<ChromeWebClient>()) {
    browser_state_ = TestChromeBrowserState::Builder().Build();

    web::WebState::CreateParams params(browser_state_.get());
    web_state_ = web::WebState::Create(params);
    web_state_->GetView();
    web_state_->SetKeepRenderProcessAlive(true);
  }
  // Returns the main frame of `web_state()`'s current page.
  web::WebFrame* GetMainFrame();
  // Helper method that initializes a form with three fields. Can be used to
  // test whether adding an attribute on the second field causes it to be
  // skipped (or not, as is appropriate) by selectNextElement.
  void SequentialNavigationSkipCheck(NSString* attribute, BOOL shouldSkip);
  // Executes JavaScript in the content world associated with
  // SuggestionControllerJavaScriptFeature.
  id ExecuteJavaScript(NSString* java_script) {
    autofill::SuggestionControllerJavaScriptFeature* feature =
        autofill::SuggestionControllerJavaScriptFeature::GetInstance();
    return web::test::ExecuteJavaScriptForFeature(web_state(), java_script,
                                                  feature);
  }
  // Returns the active element name from the JS side.
  NSString* GetActiveElementName() {
    return ExecuteJavaScript(@"document.activeElement.name");
  }
  // Waits until the active element is `name`.
  BOOL WaitUntilElementSelected(NSString* name) {
    return base::test::ios::WaitUntilConditionOrTimeout(
        base::test::ios::kWaitForJSCompletionTimeout, ^bool {
          return [GetActiveElementName() isEqualToString:name];
        });
  }

 protected:
  web::WebState* web_state() { return web_state_.get(); }

  web::ScopedTestingWebClient web_client_;
  web::WebTaskEnvironment task_environment_;
  std::unique_ptr<TestChromeBrowserState> browser_state_;
  std::unique_ptr<web::WebState> web_state_;
};

web::WebFrame* SuggestionControllerJavaScriptFeatureTest::GetMainFrame() {
  autofill::SuggestionControllerJavaScriptFeature* feature =
      autofill::SuggestionControllerJavaScriptFeature::GetInstance();
  web::WebFramesManager* manager = feature->GetWebFramesManager(web_state());
  return manager->GetMainWebFrame();
}

TEST_F(SuggestionControllerJavaScriptFeatureTest, InitAndInject) {
  web::test::LoadHtml(@"<html></html>", web_state());
  EXPECT_NSEQ(@"object", ExecuteJavaScript(@"typeof __gCrWeb.suggestion"));
}

TEST_F(SuggestionControllerJavaScriptFeatureTest, SelectElementInTabOrder) {
  NSString* htmlFragment =
      @"<html> <body>"
       "<input id='1 (0)' tabIndex=1 href='http://www.w3schools.com'>1 (0)</a>"
       "<input id='0 (0)' tabIndex=0 href='http://www.w3schools.com'>0 (0)</a>"
       "<input id='2' tabIndex=2 href='http://www.w3schools.com'>2</a>"
       "<input id='0 (1)' tabIndex=0 href='http://www.w3schools.com'>0 (1)</a>"
       "<input id='-2' tabIndex=-2 href='http://www.w3schools.com'>-2</a>"
       "<a href='http://www.w3schools.com'></a>"
       "<input id='-1 (0)' tabIndex=-1 href='http://www.w3schools.com'>-1</a>"
       "<input id='-2 (2)' tabIndex=-2 href='http://www.w3schools.com'>-2</a>"
       "<input id='0 (2)' tabIndex=0 href='http://www.w3schools.com'>0 - 2</a>"
       "<input id='3' tabIndex=3 href='http://www.w3schools.com'>3</a>"
       "<input id='1 (1)' tabIndex=1 href='http://www.w3schools.com'>1 (1)</a>"
       "<input id='-1 (1)' tabIndex=-1 href='http://www.w3schools.com'>-1 </a>"
       "<input id='0 (3)' tabIndex=0 href='http://www.w3schools.com'>0 (3)</a>"
       "</body></html>";
  web::test::LoadHtml(htmlFragment, web_state());

  // clang-format off
  NSDictionary* next_expected_ids = @ {
      @"1 (0)"  : @"1 (1)",
      @"0 (0)"  : @"0 (1)",
      @"2"      : @"3",
      @"0 (1)"  : @"0 (2)",
      @"-2"     : @"0 (2)",
      @"-1 (0)" : @"0 (2)",
      @"-2 (2)" : @"0 (2)",
      @"0 (2)"  : @"0 (3)",
      @"3"      : @"0 (0)",
      @"1 (1)"  : @"2",
      @"-1 (1)" : @"0 (3)",
      @"0 (3)"  : @"null"
  };
  // clang-format on

  for (NSString* element_id : next_expected_ids) {
    NSString* expected_id = [next_expected_ids objectForKey:element_id];
    NSString* script = [NSString
        stringWithFormat:
            @"var elements=document.getElementsByTagName('input');"
             "var element=document.getElementById('%@');"
             "var next = __gCrWeb.suggestion.getNextElementInTabOrder("
             "    element, elements);"
             "next ? next.id : 'null';",
            element_id];
    EXPECT_NSEQ(expected_id, ExecuteJavaScript(script))
        << "Wrong when selecting next element of element with element id "
        << base::SysNSStringToUTF8(element_id);
  }
  EXPECT_NSEQ(@YES,
              ExecuteJavaScript(
                  @"var elements=document.getElementsByTagName('input');"
                   "var element=document.getElementsByTagName('a')[0];"
                   "var next = __gCrWeb.suggestion.getNextElementInTabOrder("
                   "    element, elements); next===null"))
      << "Wrong when selecting the next element of an element not in the "
      << "element list.";

  for (NSString* element_id : next_expected_ids) {
    NSString* expected_id = [next_expected_ids objectForKey:element_id];
    if ([expected_id isEqualToString:@"null"]) {
      // If the expected next element is null, the focus is not moved.
      expected_id = element_id;
    }
    NSString* script =
        [NSString stringWithFormat:@"document.getElementById('%@').focus();"
                                    "__gCrWeb.suggestion.selectNextElement();"
                                    "document.activeElement.id",
                                   element_id];
    EXPECT_NSEQ(expected_id, ExecuteJavaScript(script))
        << "Wrong when selecting next element with active element "
        << base::SysNSStringToUTF8(element_id);
  }

  for (NSString* element_id : next_expected_ids) {
    // If the expected next element is null, there is no next element.
    BOOL expected = ![next_expected_ids[element_id] isEqualToString:@"null"];
    NSString* script =
        [NSString stringWithFormat:@"document.getElementById('%@').focus();"
                                    "__gCrWeb.suggestion.hasNextElement()",
                                   element_id];
    EXPECT_NSEQ(@(expected), ExecuteJavaScript(script))
        << "Wrong when checking hasNextElement() for "
        << base::SysNSStringToUTF8(element_id);
  }

  // clang-format off
  NSDictionary* prev_expected_ids = @{
      @"1 (0)" : @"null",
      @"0 (0)" : @"3",
      @"2"     : @"1 (1)",
      @"0 (1)" : @"0 (0)",
      @"-2"    : @"0 (1)",
      @"-1 (0)": @"0 (1)",
      @"-2 (2)": @"0 (1)",
      @"0 (2)" : @"0 (1)",
      @"3"     : @"2",
      @"1 (1)" : @"1 (0)",
      @"-1 (1)": @"1 (1)",
      @"0 (3)" : @"0 (2)",
  };
  // clang-format on

  for (NSString* element_id : prev_expected_ids) {
    NSString* expected_id = [prev_expected_ids objectForKey:element_id];
    NSString* script = [NSString
        stringWithFormat:
            @"var elements=document.getElementsByTagName('input');"
             "var element=document.getElementById('%@');"
             "var prev = __gCrWeb.suggestion.getPreviousElementInTabOrder("
             "    element, elements);"
             "prev ? prev.id : 'null';",
            element_id];
    EXPECT_NSEQ(expected_id, ExecuteJavaScript(script))
        << "Wrong when selecting prev element of element with element id "
        << base::SysNSStringToUTF8(element_id);
  }
  EXPECT_NSEQ(
      @YES, ExecuteJavaScript(
                @"var elements=document.getElementsByTagName('input');"
                 "var element=document.getElementsByTagName('a')[0];"
                 "var prev = __gCrWeb.suggestion.getPreviousElementInTabOrder("
                 "    element, elements); prev===null"))
      << "Wrong when selecting the previous element of an element not in the "
      << "element list";

  for (NSString* element_id : prev_expected_ids) {
    NSString* expected_id = [prev_expected_ids objectForKey:element_id];
    if ([expected_id isEqualToString:@"null"]) {
      // If the expected previous element is null, the focus is not moved.
      expected_id = element_id;
    }
    NSString* script = [NSString
        stringWithFormat:@"document.getElementById('%@').focus();"
                          "__gCrWeb.suggestion.selectPreviousElement();"
                          "document.activeElement.id",
                         element_id];
    EXPECT_NSEQ(expected_id, ExecuteJavaScript(script))
        << "Wrong when selecting previous element with active element "
        << base::SysNSStringToUTF8(element_id);
  }

  for (NSString* element_id : prev_expected_ids) {
    // If the expected next element is null, there is no next element.
    BOOL expected = ![prev_expected_ids[element_id] isEqualToString:@"null"];
    NSString* script =
        [NSString stringWithFormat:@"document.getElementById('%@').focus();"
                                    "__gCrWeb.suggestion.hasPreviousElement()",
                                   element_id];
    EXPECT_NSEQ(@(expected), ExecuteJavaScript(script))
        << "Wrong when checking hasPreviousElement() for "
        << base::SysNSStringToUTF8(element_id);
  }
}

TEST_F(SuggestionControllerJavaScriptFeatureTest, SequentialNavigation) {
  web::test::LoadHtml(@"<html><body><form name='testform' method='post'>"
                       "<input type='text' name='firstname'/>"
                       "<input type='text' name='lastname'/>"
                       "<input type='email' name='email'/>"
                       "</form></body></html>",
                      web_state());

  ExecuteJavaScript(@"document.getElementsByName('firstname')[0].focus()");

  autofill::SuggestionControllerJavaScriptFeature::GetInstance()
      ->SelectNextElementInFrame(GetMainFrame());
  EXPECT_TRUE(WaitUntilElementSelected(@"lastname"));
  __block BOOL block_was_called = NO;
  autofill::SuggestionControllerJavaScriptFeature::GetInstance()
      ->FetchPreviousAndNextElementsPresenceInFrame(
          GetMainFrame(), base::BindOnce(^void(bool has_previous_element,
                                               bool has_next_element) {
            block_was_called = YES;
            EXPECT_TRUE(has_previous_element);
            EXPECT_TRUE(has_next_element);
          }));
  ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
      TestTimeouts::action_timeout(), ^bool() {
        return block_was_called;
      }));
  autofill::SuggestionControllerJavaScriptFeature::GetInstance()
      ->SelectNextElementInFrame(GetMainFrame());
  EXPECT_TRUE(WaitUntilElementSelected(@"email"));
  autofill::SuggestionControllerJavaScriptFeature::GetInstance()
      ->SelectPreviousElementInFrame(GetMainFrame());
  EXPECT_TRUE(WaitUntilElementSelected(@"lastname"));
}

void SuggestionControllerJavaScriptFeatureTest::SequentialNavigationSkipCheck(
    NSString* attribute,
    BOOL shouldSkip) {
  web::test::LoadHtml(
      [NSString stringWithFormat:@"<html><body>"
                                  "<form name='testform' method='post'>"
                                  "<input type='text' name='firstname'/>"
                                  "<%@ name='middlename'/>"
                                  "<input type='text' name='lastname'/>"
                                  "</form></body></html>",
                                 attribute],
      web_state());
  ExecuteJavaScript(@"document.getElementsByName('firstname')[0].focus()");
  EXPECT_NSEQ(@"firstname", GetActiveElementName());
  autofill::SuggestionControllerJavaScriptFeature::GetInstance()
      ->SelectNextElementInFrame(GetMainFrame());
  if (shouldSkip)
    EXPECT_TRUE(WaitUntilElementSelected(@"lastname"));
  else
    EXPECT_TRUE(WaitUntilElementSelected(@"middlename"));
}

TEST_F(SuggestionControllerJavaScriptFeatureTest,
       SequentialNavigationNoSkipText) {
  SequentialNavigationSkipCheck(@"input type='text'", NO);
}

TEST_F(SuggestionControllerJavaScriptFeatureTest,
       SequentialNavigationNoSkipTextArea) {
  SequentialNavigationSkipCheck(@"input type='textarea'", NO);
}

TEST_F(SuggestionControllerJavaScriptFeatureTest,
       SequentialNavigationOverInvisibleElement) {
  SequentialNavigationSkipCheck(@"input type='text' style='display:none'", YES);
}

TEST_F(SuggestionControllerJavaScriptFeatureTest,
       SequentialNavigationOverHiddenElement) {
  SequentialNavigationSkipCheck(@"input type='text' style='visibility:hidden'",
                                YES);
}

TEST_F(SuggestionControllerJavaScriptFeatureTest,
       SequentialNavigationOverDisabledElement) {
  SequentialNavigationSkipCheck(@"type='text' disabled", YES);
}

TEST_F(SuggestionControllerJavaScriptFeatureTest,
       SequentialNavigationNoSkipPassword) {
  SequentialNavigationSkipCheck(@"input type='password'", NO);
}

TEST_F(SuggestionControllerJavaScriptFeatureTest,
       SequentialNavigationSkipSubmit) {
  SequentialNavigationSkipCheck(@"input type='submit'", YES);
}

TEST_F(SuggestionControllerJavaScriptFeatureTest,
       SequentialNavigationSkipImage) {
  SequentialNavigationSkipCheck(@"input type='image'", YES);
}

TEST_F(SuggestionControllerJavaScriptFeatureTest,
       SequentialNavigationSkipButton) {
  SequentialNavigationSkipCheck(@"input type='button'", YES);
}

TEST_F(SuggestionControllerJavaScriptFeatureTest,
       SequentialNavigationSkipRange) {
  SequentialNavigationSkipCheck(@"input type='range'", YES);
}

TEST_F(SuggestionControllerJavaScriptFeatureTest,
       SequentialNavigationSkipRadio) {
  SequentialNavigationSkipCheck(@"type='radio'", YES);
}

TEST_F(SuggestionControllerJavaScriptFeatureTest,
       SequentialNavigationSkipCheckbox) {
  SequentialNavigationSkipCheck(@"type='checkbox'", YES);
}

// Test fixture to test
// `FetchPreviousAndNextElementsPresenceInFrameWithID`.
class FetchPreviousAndNextExceptionTest
    : public SuggestionControllerJavaScriptFeatureTest {
 public:
  void SetUp() override {
    SuggestionControllerJavaScriptFeatureTest::SetUp();
    web::test::LoadHtml(@"<html></html>", web_state());
  }

 protected:
  // Evaluates JS and tests that the completion handler passed to
  // `FetchPreviousAndNextElementsPresenceInFrameWithID` is called with
  // (false, false) indicating no previous and next element.
  void EvaluateJavaScriptAndExpectNoPreviousAndNextElement(NSString* js) {
    ExecuteJavaScript(js);
    __block BOOL block_was_called = NO;
    autofill::SuggestionControllerJavaScriptFeature::GetInstance()
        ->FetchPreviousAndNextElementsPresenceInFrame(
            GetMainFrame(),
            base::BindOnce(^(bool hasPreviousElement, bool hasNextElement) {
              EXPECT_FALSE(hasPreviousElement);
              EXPECT_FALSE(hasNextElement);
              block_was_called = YES;
            }));
    ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
        TestTimeouts::action_timeout(), ^bool() {
          base::RunLoop().RunUntilIdle();
          return block_was_called;
        }));
  }
};

// Tests that `fetchPreviousAndNextElementsPresenceWithCompletionHandler` works
// when `__gCrWeb.suggestion.hasPreviousElement` throws an exception.
TEST_F(FetchPreviousAndNextExceptionTest, HasPreviousElementException) {
  EvaluateJavaScriptAndExpectNoPreviousAndNextElement(
      @"__gCrWeb.suggestion.hasPreviousElement = function() { bar.foo1; }");
}

// Tests that `fetchPreviousAndNextElementsPresenceWithCompletionHandler` works
// when `__gCrWeb.suggestion.hasNextElement` throws an exception.
TEST_F(FetchPreviousAndNextExceptionTest, HasNextElementException) {
  EvaluateJavaScriptAndExpectNoPreviousAndNextElement(
      @"__gCrWeb.suggestion.hasNextElement = function() { bar.foo1; }");
}

// Tests that `fetchPreviousAndNextElementsPresenceWithCompletionHandler` works
// when `Array.toString` has been overridden to return a malformed string
// without a ",".
TEST_F(FetchPreviousAndNextExceptionTest, HasPreviousElementNull) {
  EvaluateJavaScriptAndExpectNoPreviousAndNextElement(
      @"Array.prototype.toString = function() { return 'Hello'; }");
}

}  // namespace