chromium/ios/chrome/browser/text_fragments/ui_bundled/text_fragments_egtest.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 "base/test/ios/wait_util.h"
#import "components/shared_highlighting/core/common/shared_highlighting_features.h"
#import "components/shared_highlighting/ios/shared_highlighting_constants.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ios/chrome/test/earl_grey/chrome_earl_grey.h"
#import "ios/chrome/test/earl_grey/chrome_matchers.h"
#import "ios/chrome/test/earl_grey/chrome_test_case.h"
#import "ios/testing/earl_grey/earl_grey_test.h"
#import "ios/web/public/test/element_selector.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"
#import "ui/base/l10n/l10n_util.h"

namespace {

const char kTestURL[] = "/testPage";
const char kURLWithFragment[] = "/testPage/#:~:text=lorem%20ipsum";
const char kHTMLOfTestPage[] =
    "<html><body><p>"
    "<span id='target'>Lorem ipsum</span> dolor sit amet, consectetur "
    "adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore "
    "magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco "
    "laboris nisi ut aliquip ex ea commodo consequat.</p>"
    "<a href='/testPage2' id='link1'>Link 1</a>"
    "<a href='#target' id='link2'>Link 2</a>"
    "</body></html>";
const char kTestPageTextSample[] = "Lorem ipsum";

const char kTestURL2[] = "/testPage2";
const char kHTMLOfTestPage2[] =
    "<html><body>Navigated to second page</body></html>";
const char kTestPage2TextSample[] = "Navigated to second page";

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);
}

auto GetMenuTitleMatcher() {
  return grey_text(l10n_util::GetNSString(IDS_IOS_SHARED_HIGHLIGHT_MENU_TITLE));
}

void ClickMarkAndWaitForMenu() {
  ElementSelector* selector = [ElementSelector selectorWithCSSSelector:"mark"];
  [ChromeEarlGrey waitForWebStateContainingElement:selector];
  [ChromeEarlGrey
      evaluateJavaScriptForSideEffect:
          @"document.getElementById('target').children[0].click();"];
  [ChromeEarlGrey
      waitForSufficientlyVisibleElementWithMatcher:GetMenuTitleMatcher()];
}

void DismissMenu() {
  if ([ChromeEarlGrey isIPadIdiom]) {
    // Tap the tools menu to dismiss the popover.
    [[EarlGrey selectElementWithMatcher:chrome_test_util::ToolsMenuButton()]
        performAction:grey_tap()];
  } else {
    [[EarlGrey selectElementWithMatcher:chrome_test_util::CancelButton()]
        performAction:grey_tap()];
  }
}

void ReshareToPasteboard(const GURL& expected) {
  [[EarlGrey selectElementWithMatcher:grey_text(l10n_util::GetNSString(
                                          IDS_IOS_SHARED_HIGHLIGHT_RESHARE))]
      performAction:grey_tap()];

  // Tap on the Copy action.
  [ChromeEarlGrey tapButtonInActivitySheetWithID:@"Copy"];

  // Wait for the value to be in the pasteboard.
  GREYCondition* getPastedURL = [GREYCondition
      conditionWithName:@"Could not get expected URL from the pasteboard."
                  block:^{
                    return expected == [ChromeEarlGrey pasteboardURL];
                  }];
  GREYAssert(
      [getPastedURL
          waitWithTimeout:base::test::ios::kWaitForActionTimeout.InSecondsF()],
      @"Could not get expected URL from pasteboard.");
}

}  // namespace

// Test class verifying behavior of interactions with text fragments in web
// pages.
@interface TextFragmentsTestCase : ChromeTestCase
@end

@implementation TextFragmentsTestCase

- (AppLaunchConfiguration)appConfigurationForTestCase {
  AppLaunchConfiguration config;
  config.features_enabled.push_back(
      shared_highlighting::kIOSSharedHighlightingV2);
  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, kTestURL2,
                          base::BindRepeating(&LoadHtml, kHTMLOfTestPage2)));

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

- (void)testOpenMenu {
  // TODO(crbug.com/361562688): Fix and re-enable.
  if (@available(iOS 18, *)) {
    EARL_GREY_TEST_DISABLED(@"Failing on iOS 18 simulators.");
  }

  [ChromeEarlGrey loadURL:self.testServer->GetURL(kURLWithFragment)];
  [ChromeEarlGrey waitForWebStateContainingText:kTestPageTextSample];

  ClickMarkAndWaitForMenu();
}

// Disabled test due to multiple builder failures.
// TODO(crbug.com/40214683): re-enable the test with fix.
- (void)DISABLED_testRemove {
  [ChromeEarlGrey loadURL:self.testServer->GetURL(kURLWithFragment)];
  [ChromeEarlGrey waitForWebStateContainingText:kTestPageTextSample];

  ClickMarkAndWaitForMenu();

  [[EarlGrey selectElementWithMatcher:grey_text(l10n_util::GetNSString(
                                          IDS_IOS_SHARED_HIGHLIGHT_REMOVE))]
      performAction:grey_tap()];

  // Verify that the mark is gone
  ElementSelector* selector = [ElementSelector selectorWithCSSSelector:"mark"];
  [ChromeEarlGrey waitForWebStateNotContainingElement:selector];
}

- (void)testCancel {
  // TODO(crbug.com/361562688): Fix and re-enable.
  if (@available(iOS 18, *)) {
    EARL_GREY_TEST_DISABLED(@"Failing on iOS 18 simulators.");
  }

  [ChromeEarlGrey loadURL:self.testServer->GetURL(kURLWithFragment)];
  [ChromeEarlGrey waitForWebStateContainingText:kTestPageTextSample];

  ClickMarkAndWaitForMenu();

  DismissMenu();

  [ChromeEarlGrey waitForUIElementToDisappearWithMatcher:GetMenuTitleMatcher()];

  // Verify that the mark is still present
  ElementSelector* selector = [ElementSelector selectorWithCSSSelector:"mark"];
  [ChromeEarlGrey waitForWebStateContainingElement:selector];
}

- (void)testLearnMore {
  // TODO(crbug.com/361562688): Fix and re-enable.
  if (@available(iOS 18, *)) {
    EARL_GREY_TEST_DISABLED(@"Failing on iOS 18 simulators.");
  }

  [ChromeEarlGrey loadURL:self.testServer->GetURL(kURLWithFragment)];
  [ChromeEarlGrey waitForWebStateContainingText:kTestPageTextSample];

  ClickMarkAndWaitForMenu();
  [[EarlGrey selectElementWithMatcher:grey_text(l10n_util::GetNSString(
                                          IDS_IOS_SHARED_HIGHLIGHT_LEARN_MORE))]
      performAction:grey_tap()];

  [ChromeEarlGrey waitForMainTabCount:2];

  // Compare only the host; the path could change upon opening.
  GREYAssertEqual([ChromeEarlGrey webStateLastCommittedURL].host(),
                  GURL(shared_highlighting::kLearnMoreUrl).host(),
                  @"Did not open correct Learn More URL.");
}

- (void)testReshare {
  // TODO(crbug.com/361562688): Fix and re-enable.
  if (@available(iOS 18, *)) {
    EARL_GREY_TEST_DISABLED(@"Failing on iOS 18 simulators.");
  }

  // Clear the pasteboard
  UIPasteboard* pasteboard = UIPasteboard.generalPasteboard;
  [pasteboard setValue:@"" forPasteboardType:UIPasteboardNameGeneral];

  GURL pageURL = self.testServer->GetURL(kURLWithFragment);
  [ChromeEarlGrey loadURL:pageURL];
  [ChromeEarlGrey waitForWebStateContainingText:kTestPageTextSample];
  ClickMarkAndWaitForMenu();
  ReshareToPasteboard(pageURL);
}

// Verify that navigating away from the page and then coming back does not
// result in two sets of <mark> elements being created.
- (void)testNoDuplicatesOnNavigation {
  [ChromeEarlGrey loadURL:self.testServer->GetURL(kURLWithFragment)];
  [ChromeEarlGrey waitForWebStateContainingText:kTestPageTextSample];
  ElementSelector* selector = [ElementSelector selectorWithCSSSelector:"mark"];
  [ChromeEarlGrey waitForWebStateContainingElement:selector];

  // Click link to navigate away, then return to where we started
  [ChromeEarlGrey evaluateJavaScriptForSideEffect:
                      @"document.getElementById('link1').click();"];
  [ChromeEarlGrey waitForWebStateContainingText:kTestPage2TextSample];
  [ChromeEarlGrey goBack];
  [ChromeEarlGrey waitForWebStateContainingText:kTestPageTextSample];

  // Count how many <mark> elements exist in the page. It should be OK to call
  // this now because the JS to create highlights runs as soon as navigation
  // finishes, and JS is single-threaded, so this will be evaluated after that.
  base::Value result = [ChromeEarlGrey
      evaluateJavaScript:@"(function() {"
                          "return document.getElementsByTagName('mark').length;"
                          "})();"];

  // Even though it's a count, we retrieve it as a double because JS numbers are
  // always treated as doubles.
  GREYAssertTrue(result.is_double(), @"Count of mark elements is not a number");
  GREYAssertEqual(1, result.GetDouble(),
                  @"Found wrong number of mark elements");
}

// Verify that navigating away from the page makes the menu go away.
- (void)testMenuDismissesOnNavigation {
  // TODO(crbug.com/361562688): Fix and re-enable.
  if (@available(iOS 18, *)) {
    EARL_GREY_TEST_DISABLED(@"Failing on iOS 18 simulators.");
  }

  [ChromeEarlGrey loadURL:self.testServer->GetURL(kURLWithFragment)];
  [ChromeEarlGrey waitForWebStateContainingText:kTestPageTextSample];

  ClickMarkAndWaitForMenu();

  // Navigation after the menu is already showing should cause it to disappear.
  [ChromeEarlGrey loadURL:self.testServer->GetURL(kTestURL2)];
  [ChromeEarlGrey waitForUIElementToDisappearWithMatcher:GetMenuTitleMatcher()];

  // Go back to the original page.
  [ChromeEarlGrey goBack];
  [ChromeEarlGrey waitForWebStateContainingText:kTestPageTextSample];

  // Clicking a link inside a highlight will fire both events at roughly the
  // same time. Verify that the menu either goes away or never shows up to begin
  // with.
  [ChromeEarlGrey evaluateJavaScriptForSideEffect:
                      @"document.getElementById('link1').click();"];
  [ChromeEarlGrey waitForUIElementToDisappearWithMatcher:GetMenuTitleMatcher()];
}

- (void)testReshareWorksAfterNavigation {
  // Clear the pasteboard
  UIPasteboard* pasteboard = UIPasteboard.generalPasteboard;
  [pasteboard setValue:@"" forPasteboardType:UIPasteboardNameGeneral];

  GURL pageURL = self.testServer->GetURL(kURLWithFragment);
  [ChromeEarlGrey loadURL:pageURL];
  [ChromeEarlGrey waitForWebStateContainingText:kTestPageTextSample];

  // Click a link to an anchor in the document
  [ChromeEarlGrey evaluateJavaScriptForSideEffect:
                      @"document.getElementById('link2').click();"];
  GREYCondition* finishedSameDocNavigation = [GREYCondition
      conditionWithName:@"Did not navigate within document."
                  block:^{
                    return [ChromeEarlGrey webStateLastCommittedURL].ref() ==
                           "target";
                  }];
  GREYAssert(
      [finishedSameDocNavigation
          waitWithTimeout:base::test::ios::kWaitForActionTimeout.InSecondsF()],
      @"Did not navigate within document.");

  // When resharing, the text fragments should persist even though we've
  // added a reference fragment.
  GURL expected =
      self.testServer->GetURL("/testPage/#target:~:text=lorem%20ipsum");
  ClickMarkAndWaitForMenu();
  ReshareToPasteboard(expected);

  // When navigating back, the highlights persist even though the committed (and
  // displayed) URL doesn't contain a text fragment. Resharing should still
  // include the text fragments.
  [ChromeEarlGrey evaluateJavaScriptForSideEffect:
                      @"document.getElementById('link1').click();"];
  [ChromeEarlGrey waitForWebStateContainingText:kTestPage2TextSample];
  [ChromeEarlGrey goBack];

  ClickMarkAndWaitForMenu();
  ReshareToPasteboard(expected);
}

@end