chromium/ios/web/text_fragments/text_fragments_manager_impl_unittest.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 "ios/web/text_fragments/text_fragments_manager_impl.h"

#import <functional>

#import "base/memory/raw_ptr.h"
#import "base/strings/utf_string_conversions.h"
#import "base/test/metrics/histogram_tester.h"
#import "base/test/scoped_feature_list.h"
#import "components/shared_highlighting/core/common/fragment_directives_constants.h"
#import "components/shared_highlighting/core/common/shared_highlighting_metrics.h"
#import "components/ukm/test_ukm_recorder.h"
#import "ios/web/common/features.h"
#import "ios/web/navigation/navigation_item_impl.h"
#import "ios/web/public/navigation/referrer.h"
#import "ios/web/public/test/fakes/fake_navigation_context.h"
#import "ios/web/public/test/fakes/fake_navigation_manager.h"
#import "ios/web/public/test/fakes/fake_web_frame.h"
#import "ios/web/public/test/fakes/fake_web_frames_manager.h"
#import "ios/web/public/test/fakes/fake_web_state.h"
#import "ios/web/public/test/web_test.h"
#import "ios/web/text_fragments/text_fragments_java_script_feature.h"
#import "services/metrics/public/cpp/ukm_builders.h"
#import "testing/gmock/include/gmock/gmock.h"
#import "testing/gtest/include/gtest/gtest.h"
#import "third_party/ocmock/OCMock/OCMock.h"
#import "url/gurl.h"

using web::Referrer;
using ::testing::_;
using ::testing::Eq;
using shared_highlighting::TextFragmentLinkOpenSource;

namespace {

const char kValidFragmentsURL[] =
    "https://chromium.org#idFrag:~:text=text%201&text=text%202";

const char kSingleFragmentURL[] = "https://chromium.org#:~:text=text";
const char kTwoFragmentsURL[] =
    "https://chromium.org#:~:text=text&text=other%20text";
const char kFragmentsRemovedURL[] = "https://chromium.org";

const char kSearchEngineURL[] = "https://google.com";
const char kNonSearchEngineURL[] = "https://notasearchengine.com";

const char kSuccessUkmMetric[] = "Success";
const char kSourceUkmMetric[] = "Source";

class MockJSFeature : public web::TextFragmentsJavaScriptFeature {
 public:
  MOCK_METHOD(void,
              ProcessTextFragments,
              (web::WebState * web_state,
               base::Value parsed_fragments,
               std::string background_color_hex_rgb,
               std::string foreground_color_hex_rgb),
              (override));
  MOCK_METHOD(void,
              RemoveHighlights,
              (web::WebState * web_state, const GURL& new_url),
              (override));
};

base::Value ValueForTestURL() {
  base::Value::List list;

  base::Value::Dict text1;
  text1.Set("textStart", "text 1");
  base::Value::Dict text2;
  text2.Set("textStart", "text 2");

  list.Append(std::move(text1));
  list.Append(std::move(text2));

  return base::Value(std::move(list));
}

}  // namespace

namespace web {

class TextFragmentsManagerImplTest : public WebTest {
 protected:
  TextFragmentsManagerImplTest() : context_(), feature_list_() {}

  void SetUp() override {
    context_.SetWebState(&web_state_);
    last_committed_item_.SetReferrer(GetSearchEngineReferrer());
    auto fake_navigation_manager = std::make_unique<FakeNavigationManager>();
    fake_navigation_manager->SetLastCommittedItem(&last_committed_item_);
    web_state_.SetNavigationManager(std::move(fake_navigation_manager));
    TextFragmentsJavaScriptFeature* feature =
        TextFragmentsJavaScriptFeature::GetInstance();
    auto fake_web_frames_manager = std::make_unique<FakeWebFramesManager>();
    web_frames_manager_ = fake_web_frames_manager.get();
    web_state_.SetWebFramesManager(feature->GetSupportedContentWorld(),
                                   std::move(fake_web_frames_manager));
  }

  TextFragmentsManagerImpl* CreateDefaultManager() {
    return CreateManager(/*has_opener=*/false,
                         /*has_user_gesture=*/true,
                         /*is_same_document=*/false,
                         /*feature_color_change=*/false,
                         /*add_web_frame=*/true);
  }

  TextFragmentsManagerImpl* CreateManager(bool has_opener,
                                          bool has_user_gesture,
                                          bool is_same_document,
                                          bool feature_color_change,
                                          bool add_web_frame) {
    if (!feature_color_change) {
      feature_list_.InitWithFeatures(
          /*enabled_features=*/{},
          /*disabled_features=*/{features::kIOSSharedHighlightingColorChange});
    }
    web_state_.SetHasOpener(has_opener);
    context_.SetHasUserGesture(has_user_gesture);
    context_.SetIsSameDocument(is_same_document);

    TextFragmentsManagerImpl::CreateForWebState(&web_state_);
    auto* manager = TextFragmentsManagerImpl::FromWebState(&web_state_);
    manager->SetJSFeatureForTesting(&feature_);
    if (add_web_frame) {
      AddMainWebFrame(manager);
    }
    return manager;
  }

  void SetLastURL(const GURL& last_url) { web_state_.SetCurrentURL(last_url); }

  Referrer GetSearchEngineReferrer() {
    return Referrer(GURL(kSearchEngineURL), web::ReferrerPolicyDefault);
  }

  Referrer GetNonSearchEngineReferrer() {
    return Referrer(GURL(kNonSearchEngineURL), web::ReferrerPolicyDefault);
  }

  void ValidateLinkOpenedUkm(const ukm::TestAutoSetUkmRecorder& recorder,
                             bool success,
                             TextFragmentLinkOpenSource source) {
    auto entries = recorder.GetEntriesByName(
        ukm::builders::SharedHighlights_LinkOpened::kEntryName);
    ASSERT_EQ(1u, entries.size());
    const ukm::mojom::UkmEntry* entry = entries[0];
    EXPECT_NE(ukm::kInvalidSourceId, entry->source_id);
    recorder.ExpectEntryMetric(entry, kSuccessUkmMetric, success);
    recorder.ExpectEntryMetric(entry, kSourceUkmMetric,
                               static_cast<int64_t>(source));
  }

  void ValidateNoLinkOpenedUkm(const ukm::TestAutoSetUkmRecorder& recorder) {
    auto entries = recorder.GetEntriesByName(
        ukm::builders::SharedHighlights_LinkOpened::kEntryName);
    EXPECT_EQ(0u, entries.size());
  }

  void AddMainWebFrame(TextFragmentsManagerImpl* fragments_mgr) {
    web_frames_manager_->AddWebFrame(
        FakeWebFrame::CreateMainWebFrame(GURL("https://chromium.org")));
    fragments_mgr->WebFrameBecameAvailable(
        web_frames_manager_, web_frames_manager_->GetMainWebFrame());
  }

  MockJSFeature feature_;
  web::FakeNavigationContext context_;
  FakeWebState web_state_;
  raw_ptr<FakeWebFramesManager> web_frames_manager_;
  base::test::ScopedFeatureList feature_list_;
  NavigationItemImpl last_committed_item_;
};

// Tests that the manager will execute JavaScript if highlighting is allowed and
// fragments are present.
TEST_F(TextFragmentsManagerImplTest, ExecuteJavaScriptSuccess) {
  base::HistogramTester histogram_tester;
  SetLastURL(GURL(kValidFragmentsURL));

  base::Value expected = ValueForTestURL();
  EXPECT_CALL(feature_, ProcessTextFragments(&web_state_,
                                             Eq(std::ref(expected)), "", ""));

  TextFragmentsManagerImpl* manager = CreateDefaultManager();
  manager->DidFinishNavigation(&web_state_, &context_);
}

// Tests that JS still executes even if the main WebFrame isn't yet available
// when the navigation finishes.
TEST_F(TextFragmentsManagerImplTest, ExecuteJavaScriptDelayedWebFrame) {
  base::HistogramTester histogram_tester;
  SetLastURL(GURL(kValidFragmentsURL));

  base::Value expected = ValueForTestURL();
  EXPECT_CALL(feature_, ProcessTextFragments(&web_state_,
                                             Eq(std::ref(expected)), "", ""));

  TextFragmentsManagerImpl* manager =
      CreateManager(/*has_opener=*/false,
                    /*has_user_gesture=*/true,
                    /*is_same_document=*/false,
                    /*feature_color_change=*/false,
                    /*add_web_frame=*/false);
  manager->DidFinishNavigation(&web_state_, &context_);
  AddMainWebFrame(manager);
}

// Tests that the manager will execute JavaScript with the default colors
// if the IOSSharedHighlightingColorChange flag is enabled, if highlighting
// is allowed and fragments are present.
TEST_F(TextFragmentsManagerImplTest, ExecuteJavaScriptWithColorChange) {
  base::HistogramTester histogram_tester;
  SetLastURL(GURL(kValidFragmentsURL));

  base::Value expected = ValueForTestURL();
  EXPECT_CALL(feature_,
              ProcessTextFragments(&web_state_, Eq(std::ref(expected)),
                                   "e9d2fd", "000000"));

  TextFragmentsManagerImpl* manager =
      CreateManager(/*has_opener=*/false,
                    /*has_user_gesture=*/true,
                    /*is_same_document=*/false,
                    /*feature_color_change=*/true,
                    /*add_web_frame=*/true);
  manager->DidFinishNavigation(&web_state_, &context_);
}

// Tests that the manager will not execute JavaScript if the WebState has an
// opener.
TEST_F(TextFragmentsManagerImplTest, HasOpenerFragmentsDisallowed) {
  TextFragmentsManagerImpl* manager =
      CreateManager(/*has_opener=*/true,
                    /*has_user_gesture=*/true,
                    /*is_same_document=*/false,
                    /*feature_color_change=*/false,
                    /*add_web_frame=*/true);

  EXPECT_CALL(feature_, ProcessTextFragments(_, _, _, _)).Times(0);
  manager->DidFinishNavigation(&web_state_, &context_);
}

// Tests that the manager will not execute JavaScript if the WebState has no
// user gesture.
TEST_F(TextFragmentsManagerImplTest, NoGestureFragmentsDisallowed) {
  TextFragmentsManagerImpl* manager =
      CreateManager(/*has_opener=*/false,
                    /*has_user_gesture=*/false,
                    /*is_same_document=*/false,
                    /*feature_color_change=*/false,
                    /*add_web_frame=*/true);

  EXPECT_CALL(feature_, ProcessTextFragments(_, _, _, _)).Times(0);
  manager->DidFinishNavigation(&web_state_, &context_);
}

// Tests that the manager will not execute JavaScript if we navigated on the
// same document.
TEST_F(TextFragmentsManagerImplTest, SameDocumentFragmentsDisallowed) {
  TextFragmentsManagerImpl* manager =
      CreateManager(/*has_opener=*/false,
                    /*has_user_gesture=*/true,
                    /*is_same_document=*/true,
                    /*feature_color_change=*/false,
                    /*add_web_frame=*/true);

  EXPECT_CALL(feature_, ProcessTextFragments(_, _, _, _)).Times(0);
  manager->DidFinishNavigation(&web_state_, &context_);
}

// Tests that the manager will not execute JavaScript if there are no
// fragments on the current URL.
TEST_F(TextFragmentsManagerImplTest, NoFragmentsNoJavaScript) {
  SetLastURL(GURL("https://www.chromium.org/"));

  TextFragmentsManagerImpl* manager =
      CreateManager(/*has_opener=*/false,
                    /*has_user_gesture=*/true,
                    /*is_same_document=*/false,
                    /*feature_color_change=*/false,
                    /*add_web_frame=*/true);

  EXPECT_CALL(feature_, ProcessTextFragments(_, _, _, _)).Times(0);
  manager->DidFinishNavigation(&web_state_, &context_);
}

// Tests that the manager will not execute JavaScript if there are no
// text fragments on the current URL, even if it contains a fragment id.
TEST_F(TextFragmentsManagerImplTest, IdFragmentNoJavaScript) {
  SetLastURL(GURL("https://www.chromium.org/#fragmentId"));

  TextFragmentsManagerImpl* manager =
      CreateManager(/*has_opener=*/false,
                    /*has_user_gesture=*/true,
                    /*is_same_document=*/false,
                    /*feature_color_change=*/false,
                    /*add_web_frame=*/true);

  EXPECT_CALL(feature_, ProcessTextFragments(_, _, _, _)).Times(0);
  manager->DidFinishNavigation(&web_state_, &context_);
}

// Tests that the LinkSource metric is recorded properly when the link comes
// from a search engine.
TEST_F(TextFragmentsManagerImplTest, LinkSourceMetricSearchEngine) {
  base::HistogramTester histogram_tester;
  ukm::TestAutoSetUkmRecorder ukm_recorder;
  SetLastURL(GURL(kValidFragmentsURL));

  TextFragmentsManagerImpl* manager = CreateDefaultManager();

  manager->DidFinishNavigation(&web_state_, &context_);

  histogram_tester.ExpectUniqueSample("TextFragmentAnchor.LinkOpenSource", 1,
                                      1);
}

// Tests that the LinkSource metric is recorded properly when the link doesn't
// come from a search engine.
TEST_F(TextFragmentsManagerImplTest, LinkSourceMetricNonSearchEngine) {
  base::HistogramTester histogram_tester;
  ukm::TestAutoSetUkmRecorder ukm_recorder;
  SetLastURL(GURL(kValidFragmentsURL));

  TextFragmentsManagerImpl* manager = CreateDefaultManager();

  last_committed_item_.SetReferrer(GetNonSearchEngineReferrer());
  manager->DidFinishNavigation(&web_state_, &context_);

  histogram_tester.ExpectUniqueSample("TextFragmentAnchor.LinkOpenSource", 0,
                                      1);
}

// Tests that the SelectorCount metric is recorded properly when a single
// selector is present.
TEST_F(TextFragmentsManagerImplTest, SelectorCountMetricSingleSelector) {
  base::HistogramTester histogram_tester;
  SetLastURL(GURL(kSingleFragmentURL));

  TextFragmentsManagerImpl* manager = CreateDefaultManager();

  manager->DidFinishNavigation(&web_state_, &context_);

  histogram_tester.ExpectUniqueSample("TextFragmentAnchor.SelectorCount", 1, 1);
}

// Tests that the SelectorCount metric is recorded properly when two selectors
// are present.
TEST_F(TextFragmentsManagerImplTest, SelectorCountMetricTwoSelectors) {
  base::HistogramTester histogram_tester;
  SetLastURL(GURL(kTwoFragmentsURL));

  TextFragmentsManagerImpl* manager = CreateDefaultManager();

  manager->DidFinishNavigation(&web_state_, &context_);

  histogram_tester.ExpectUniqueSample("TextFragmentAnchor.SelectorCount", 2, 1);
}

// Tests that the AmbiguousMatch and MatchRate success metrics are recorded
// properly in a variety of cases.
TEST_F(TextFragmentsManagerImplTest, OnProcessingCompleteSuccessMetrics) {
  SetLastURL(GURL(kTwoFragmentsURL));
  TextFragmentsManagerImpl* manager = CreateDefaultManager();
  manager->DidFinishNavigation(&web_state_, &context_);

  // 100% rate case.
  {
    base::HistogramTester histogram_tester;
    ukm::TestAutoSetUkmRecorder ukm_recorder;

    manager->OnProcessingComplete(2, 2);

    histogram_tester.ExpectUniqueSample("TextFragmentAnchor.AmbiguousMatch", 0,
                                        1);
    histogram_tester.ExpectUniqueSample("TextFragmentAnchor.MatchRate", 100, 1);

    ValidateLinkOpenedUkm(ukm_recorder, /*success=*/true,
                          TextFragmentLinkOpenSource::kSearchEngine);
  }

  // 50% rate case.
  {
    base::HistogramTester histogram_tester;
    ukm::TestAutoSetUkmRecorder ukm_recorder;

    manager->OnProcessingComplete(3, 6);

    histogram_tester.ExpectUniqueSample("TextFragmentAnchor.AmbiguousMatch", 1,
                                        1);
    histogram_tester.ExpectUniqueSample("TextFragmentAnchor.MatchRate", 50, 1);

    ValidateLinkOpenedUkm(ukm_recorder, /*success=*/false,
                          TextFragmentLinkOpenSource::kSearchEngine);
  }

  // 0% rate case.
  {
    base::HistogramTester histogram_tester;
    ukm::TestAutoSetUkmRecorder ukm_recorder;

    manager->OnProcessingComplete(0, 2);

    histogram_tester.ExpectUniqueSample("TextFragmentAnchor.AmbiguousMatch", 1,
                                        1);
    histogram_tester.ExpectUniqueSample("TextFragmentAnchor.MatchRate", 0, 1);

    ValidateLinkOpenedUkm(ukm_recorder, /*success=*/false,
                          TextFragmentLinkOpenSource::kSearchEngine);
  }
}

TEST_F(TextFragmentsManagerImplTest, ClickRemovesHighlights) {
  SetLastURL(GURL(kSingleFragmentURL));
  TextFragmentsManagerImpl* manager = CreateDefaultManager();
  GURL fragments_removed_gurl(kFragmentsRemovedURL);
  EXPECT_CALL(feature_, RemoveHighlights(_, fragments_removed_gurl));
  manager->OnClick();
}

}  // namespace web