chromium/ios/chrome/browser/link_to_text/ui_bundled/link_to_text_egtest.mm

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

#import <vector>

#import "base/ios/ios_util.h"
#import "base/strings/string_util.h"
#import "base/strings/sys_string_conversions.h"
#import "base/test/ios/wait_util.h"
#import "components/shared_highlighting/core/common/fragment_directives_utils.h"
#import "components/shared_highlighting/core/common/text_fragment.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/ui/browser_container/edit_menu_app_interface.h"
#import "ios/chrome/test/earl_grey/chrome_actions.h"
#import "ios/chrome/test/earl_grey/chrome_actions_app_interface.h"
#import "ios/chrome/test/earl_grey/chrome_earl_grey.h"
#import "ios/chrome/test/earl_grey/chrome_earl_grey_ui.h"
#import "ios/chrome/test/earl_grey/chrome_matchers.h"
#import "ios/chrome/test/earl_grey/chrome_test_case.h"
#import "ios/chrome/test/scoped_eg_synchronization_disabler.h"
#import "ios/testing/earl_grey/earl_grey_test.h"
#import "ios/web/common/features.h"
#import "ios/web/public/test/element_selector.h"
#import "net/base/apple/url_conversions.h"
#import "net/test/embedded_test_server/default_handlers.h"
#import "net/test/embedded_test_server/http_request.h"
#import "net/test/embedded_test_server/http_response.h"
#import "net/test/embedded_test_server/request_handler_util.h"

using shared_highlighting::TextFragment;

namespace {

const char kFirstFragmentText[] = "Hello foo!";
const char kSecondFragmentText[] = "bar";
const char kTestPageTextSample[] = "Lorem ipsum";
const char kNoTextTestPageTextSample[] = "only boundary";
const char kInputTestPageTextSample[] = "has an input";
const char kSimpleTextElementId[] = "toBeSelected";
const char kToBeSelectedText[] = "VeryUniqueWord";

const char kTestURL[] = "/testPage";
const char kURLWithTwoFragments[] = "/testPage/#:~:text=Hello%20foo!&text=bar";
const char kHTMLOfTestPage[] =
    "<html><body>"
    "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do "
    "eiusmod "
    "tempor incididunt ut labore et dolore magna aliqua. Hello foo! Ut enim ad "
    "minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip "
    "ex ea "
    "commodo consequat. bar</p>"
    "<p id=\"toBeSelected\">VeryUniqueWord</p>"
    "</body></html>";

const char kTestLongPageURL[] = "/longTestPage";
const char kLongPageURLWithOneFragment[] =
    "/longTestPage/#:~:text=Hello%20foo!";
const char kHTMLOfLongTestPage[] =
    "<html><body>"
    "<div style=\"background:blue; height: 4000px; width: 250px;\"></div>"
    "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do "
    "eiusmod "
    "tempor incididunt ut labore et dolore magna aliqua. Hello foo! Ut enim ad "
    "minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip "
    "ex ea "
    "commodo consequat. bar</p>"
    "<div style=\"background:blue; height: 4000px; width: 250px;\"></div>"
    "</body></html>";

const char kNoTextTestURL[] = "/noTextPage";
const char kHTMLOfNoTextTestPage[] =
    "<html><body>"
    "This page has a paragraph with only boundary characters"
    "<p id=\"toBeSelected\"> .!, \t</p>"
    "</body></html>";

const char kInputTestURL[] = "/inputTextPage";
const char kHTMLOfInputTestPage[] =
    "<html><body>"
    "This page has an input"
    "<input type=\"text\" id=\"toBeSelected\"></p>"
    "</body></html>";

NSArray<NSString*>* GetMarkedText() {
  NSString* js = @"(function() {"
                  "  const marks = document.getElementsByTagName('mark');"
                  "  const markedText = [];"
                  "  for (const mark of marks) {"
                  "    if (mark && mark.innerText) {"
                  "      markedText.push(mark.innerText);"
                  "    }"
                  "  }"
                  "  return markedText;"
                  "})();";
  base::Value result = [ChromeEarlGrey evaluateJavaScript:js];
  GREYAssertTrue(result.is_list(), @"Result is not iterable.");

  NSMutableArray<NSString*>* marked_texts = [NSMutableArray array];
  for (const auto& element : result.GetList()) {
    if (element.is_string()) {
      NSString* ns_element = base::SysUTF8ToNSString(element.GetString());
      [marked_texts addObject:ns_element];
    }
  }

  return [marked_texts copy];
}

NSString* GetFirstVisibleMarkedText() {
  NSString* js =
      @"(function () {"
       "  const firstMark = document.getElementsByTagName('mark')[0];"
       "  if (!firstMark) {"
       "    return '';"
       "  }"
       "  const rect = firstMark.getBoundingClientRect();"
       "  const isVisible = rect.top >= 0 &&"
       "    rect.bottom <= window.innerHeight &&"
       "    rect.left >= 0 &&"
       "    rect.right <= window.innerWidth;"
       "  return isVisible ? firstMark.innerText : '';"
       "})();";
  base::Value result = [ChromeEarlGrey evaluateJavaScript:js];
  GREYAssertTrue(result.is_string(), @"Result is not a string.");
  return base::SysUTF8ToNSString(result.GetString());
}

std::unique_ptr<net::test_server::HttpResponse> LoadHtml(
    const std::string& html,
    const net::test_server::HttpRequest& request) {
  std::unique_ptr<net::test_server::BasicHttpResponse> http_response(
      new net::test_server::BasicHttpResponse);
  http_response->set_content_type("text/html");
  http_response->set_content(html);
  return std::move(http_response);
}

}  // namespace

// Test class for the scroll-to-text and link-to-text features.
@interface LinkToTextTestCase : ChromeTestCase
@end

@implementation LinkToTextTestCase

- (AppLaunchConfiguration)appConfigurationForTestCase {
  AppLaunchConfiguration config;
  config.features_enabled.push_back(kSharedHighlightingIOS);
  return config;
}

- (void)setUp {
  [super setUp];

  RegisterDefaultHandlers(self.testServer);
  self.testServer->RegisterRequestHandler(
      base::BindRepeating(&net::test_server::HandlePrefixedRequest, kTestURL,
                          base::BindRepeating(&LoadHtml, kHTMLOfTestPage)));
  self.testServer->RegisterRequestHandler(base::BindRepeating(
      &net::test_server::HandlePrefixedRequest, kTestLongPageURL,
      base::BindRepeating(&LoadHtml, kHTMLOfLongTestPage)));
  self.testServer->RegisterRequestHandler(base::BindRepeating(
      &net::test_server::HandlePrefixedRequest, kNoTextTestURL,
      base::BindRepeating(&LoadHtml, kHTMLOfNoTextTestPage)));
  self.testServer->RegisterRequestHandler(base::BindRepeating(
      &net::test_server::HandlePrefixedRequest, kInputTestURL,
      base::BindRepeating(&LoadHtml, kHTMLOfInputTestPage)));

  GREYAssertTrue(self.testServer->Start(), @"Test server failed to start.");
}

// Tests that navigating to a URL with text fragments will highlight all
// fragments.
- (void)testHighlightAllFragments {
  [ChromeEarlGrey loadURL:self.testServer->GetURL(kURLWithTwoFragments)];
  [ChromeEarlGrey waitForWebStateContainingText:kTestPageTextSample];

  NSArray<NSString*>* markedText = GetMarkedText();

  GREYAssertEqual(2, markedText.count,
                  @"Did not get the expected number of marked text.");
  GREYAssertEqualObjects(@(kFirstFragmentText), markedText[0],
                         @"First marked text is not valid.");
  GREYAssertEqualObjects(@(kSecondFragmentText), markedText[1],
                         @"Second marked text is not valid.");
}

// Tests that a fragment will be scrolled to if it's lower on the page.
- (void)testScrollToHighlight {
  [ChromeEarlGrey loadURL:self.testServer->GetURL(kLongPageURLWithOneFragment)];
  [ChromeEarlGrey waitForWebStateContainingText:kTestPageTextSample];

  __block NSString* firstVisibleMark;
  GREYCondition* scrolledToText = [GREYCondition
      conditionWithName:@"Did not scroll to marked text."
                  block:^{
                    NSString* visibleMarkedText = GetFirstVisibleMarkedText();
                    if (visibleMarkedText &&
                        visibleMarkedText != [NSString string]) {
                      firstVisibleMark = visibleMarkedText;
                      return YES;
                    }
                    return NO;
                  }];

  GREYAssert([scrolledToText
                 waitWithTimeout:base::test::ios::kWaitForJSCompletionTimeout
                                     .InSecondsF()],
             @"Could not find visible marked element.");

  GREYAssertEqualObjects(@(kFirstFragmentText), firstVisibleMark,
                         @"Visible marked text is not valid.");
}

// Tests that a link can be generated for a simple text selection.
// crbug.com/1403831 Disable flaky test
- (void)DISABLED_testGenerateLinkForSimpleText {
  [ChromeEarlGrey clearPasteboard];
  GURL pageURL = self.testServer->GetURL(kTestURL);
  [ChromeEarlGrey loadURL:pageURL];
  [ChromeEarlGrey waitForWebStateContainingText:kTestPageTextSample];

  [ChromeTestCase removeAnyOpenMenusAndInfoBars];

  [[EarlGrey selectElementWithMatcher:chrome_test_util::WebViewMatcher()]
      performAction:chrome_test_util::LongPressElementForContextMenu(
                        [ElementSelector
                            selectorWithElementID:kSimpleTextElementId],
                        true)];

  // Wait for the menu to open. The "Copy" menu item will always be present,
  // but other items may be hidden behind the overflow button.
  [ChromeEarlGrey waitForSufficientlyVisibleElementWithMatcher:
                      chrome_test_util::SystemSelectionCalloutCopyButton()];

  // The link to text button may be in the overflow, so use a search action to
  // find it, if necessary.
  id<GREYMatcher> linkToTextMatcher =
      grey_allOf(chrome_test_util::SystemSelectionCalloutLinkToTextButton(),
                 grey_sufficientlyVisible(), nil);
  [[[EarlGrey selectElementWithMatcher:linkToTextMatcher]
         usingSearchAction:grey_tap()
      onElementWithMatcher:chrome_test_util::
                               SystemSelectionCalloutOverflowButton()]
      performAction:grey_tap()];

  // Make sure the Edit menu is gone.
  [[EarlGrey
      selectElementWithMatcher:chrome_test_util::SystemSelectionCallout()]
      assertWithMatcher:grey_notVisible()];

  // Wait for the Activity View to show up (look for the Copy action).
  id<GREYMatcher> copyActivityButton = chrome_test_util::CopyActivityButton();
  [ChromeEarlGrey
      waitForSufficientlyVisibleElementWithMatcher:copyActivityButton];

  // Tap on the Copy action.
  [[EarlGrey selectElementWithMatcher:copyActivityButton]
      performAction:grey_tap()];

  // Assert the values stored in the pasteboard. Lower-casing the expected
  // GURL as that is what the JS library is doing.
  NSString* stringURL = base::SysUTF8ToNSString(pageURL.spec());
  NSString* fragment = @"#:~:text=bar-,";
  NSString* selectedText =
      base::SysUTF8ToNSString(base::ToLowerASCII(kToBeSelectedText));

  NSString* expectedURL =
      [NSString stringWithFormat:@"%@%@%@", stringURL, fragment, selectedText];
  [ChromeEarlGrey verifyStringCopied:expectedURL];

  [ChromeEarlGrey clearPasteboard];
}

- (void)testBadSelectionDisablesGenerateLink {
  [ChromeEarlGrey loadURL:self.testServer->GetURL(kNoTextTestURL)];
  [ChromeEarlGrey waitForWebStateContainingText:kNoTextTestPageTextSample];

  [ChromeTestCase removeAnyOpenMenusAndInfoBars];

  [[EarlGrey selectElementWithMatcher:chrome_test_util::WebViewMatcher()]
      performAction:chrome_test_util::LongPressElementForContextMenu(
                        [ElementSelector
                            selectorWithElementID:kSimpleTextElementId],
                        true)];

  [ChromeEarlGrey
      waitForSufficientlyVisibleElementWithMatcher:[EditMenuAppInterface
                                                       editMenuMatcher]];

  // Make sure the Link to Text button is not visible.
  [[EarlGrey selectElementWithMatcher:
                 chrome_test_util::SystemSelectionCalloutLinkToTextButton()]
      assertWithMatcher:grey_notVisible()];

  // TODO(crbug.com/40191349): Tap to dismiss the system selection callout
  // buttons so tearDown doesn't hang when `disabler` goes out of scope.
  [[EarlGrey selectElementWithMatcher:chrome_test_util::WebViewMatcher()]
      performAction:grey_tap()];
}

- (void)testInputDisablesGenerateLink {
  // In order to make the menu show up later in the test, the pasteboard can't
  // be empty.
  UIPasteboard* pasteboard = [UIPasteboard generalPasteboard];
  pasteboard.string = @"anything";

  [ChromeEarlGrey loadURL:self.testServer->GetURL(kInputTestURL)];
  [ChromeEarlGrey waitForWebStateContainingText:kInputTestPageTextSample];

  [ChromeTestCase removeAnyOpenMenusAndInfoBars];

  // Tap to focus the field, then long press to make the Edit Menu pop up.
  [[EarlGrey selectElementWithMatcher:chrome_test_util::WebViewMatcher()]
      performAction:chrome_test_util::TapWebElementWithId(
                        kSimpleTextElementId)];
  [[EarlGrey selectElementWithMatcher:chrome_test_util::WebViewMatcher()]
      performAction:chrome_test_util::LongPressElementForContextMenu(
                        [ElementSelector
                            selectorWithElementID:kSimpleTextElementId],
                        true)];

  // TODO(crbug.com/40191349): Xcode 13 gesture recognizers seem to get stuck
  // when the user longs presses on plain text.  For this test, disable EG
  // synchronization.
  ScopedSynchronizationDisabler disabler;

  // Ensure the menu is visible by finding the Paste button.
  // TODO(crbug.com/328271981): either remove call to selectElementWithMatcher
  // or do something with its return value
  // id<GREYMatcher> menu = grey_accessibilityLabel(@"Paste");
  // [EarlGrey selectElementWithMatcher:menu];

  // Make sure the Link to Text button is not visible.
  [[EarlGrey selectElementWithMatcher:
                 chrome_test_util::SystemSelectionCalloutLinkToTextButton()]
      assertWithMatcher:grey_notVisible()];

  // TODO(crbug.com/40191349): Tap to dismiss the system selection callout
  // buttons so tearDown doesn't hang when `disabler` goes out of scope.
  [[EarlGrey selectElementWithMatcher:chrome_test_util::WebViewMatcher()]
      performAction:grey_tap()];
}

@end