chromium/ios/chrome/browser/link_to_text/model/link_to_text_java_script_feature_unittest.mm

// Copyright 2022 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/link_to_text/model/link_to_text_java_script_feature.h"

#import "base/gtest_prod_util.h"
#import "base/test/scoped_feature_list.h"
#import "base/timer/elapsed_timer.h"
#import "base/values.h"
#import "components/shared_highlighting/core/common/shared_highlighting_features.h"
#import "components/shared_highlighting/core/common/shared_highlighting_metrics.h"
#import "components/ukm/ios/ukm_url_recorder.h"
#import "ios/chrome/browser/link_to_text/model/link_generation_outcome.h"
#import "ios/web/public/test/fakes/fake_navigation_context.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_task_environment.h"
#import "testing/gtest/include/gtest/gtest.h"
#import "testing/gtest_mac.h"
#import "testing/platform_test.h"

namespace {
base::Value GetSuccessValue() {
  auto dict =
      base::Value::Dict()
          .Set("status", static_cast<int>(LinkGenerationOutcome::kSuccess))
          .Set("fragment", base::Value::Dict().Set("textStart", "text"))
          .Set("selectedText", "text")
          .Set("selectionRect", base::Value::Dict()
                                    .Set("x", 0.0)
                                    .Set("y", 0.0)
                                    .Set("width", 50.0)
                                    .Set("height", 50.0));

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

base::Value GetNoSelectionValue() {
  auto dict = base::Value::Dict().Set(
      "status", static_cast<int>(LinkGenerationOutcome::kInvalidSelection));
  return base::Value(std::move(dict));
}

base::Value GetFailureValue() {
  auto dict = base::Value::Dict().Set(
      "status", static_cast<int>(LinkGenerationOutcome::kExecutionFailed));
  return base::Value(std::move(dict));
}

// Fake that disarms the JS calls but leaves all other logic intact.
class DisarmedFeature : public LinkToTextJavaScriptFeature {
 public:
  void SetResponse(web::WebFrame* frame, base::Value* value) {
    response_map_[frame] = value;
  }

  bool WasJsInvokedInFrame(web::WebFrame* frame) {
    return invoked_.count(frame);
  }

 protected:
  void RunGenerationJS(
      web::WebFrame* frame,
      base::OnceCallback<void(const base::Value*)> callback) override {
    DCHECK(response_map_[frame]);
    invoked_.insert(frame);
    std::move(callback).Run(response_map_[frame]);
  }

 private:
  std::set<web::WebFrame*> invoked_;
  std::map<web::WebFrame*, base::Value*> response_map_;
};
}  // namespace

class LinkToTextJavaScriptFeatureTest : public PlatformTest {
 public:
  LinkToTextJavaScriptFeatureTest()
      : feature_list_(shared_highlighting::kSharedHighlightingAmp) {}

  void SetUp() override {
    web_state_.SetTitle(u"Main Frame Title");
    web_state_.SetWebFramesManager(
        std::make_unique<web::FakeWebFramesManager>());

    UIView* fake_view = [[UIView alloc] init];
    web_state_.SetView(fake_view);

    // Fake Navigation End for UKM setup.
    ukm::InitializeSourceUrlRecorderForWebState(&web_state_);
    web::FakeNavigationContext context;
    context.SetHasCommitted(true);
    context.SetIsSameDocument(false);
    web_state_.OnNavigationStarted(&context);
    web_state_.OnNavigationFinished(&context);
  }

  void AddMainFrame(const GURL& url, base::Value* response_value) {
    auto main_frame = web::FakeWebFrame::CreateMainWebFrame(url);
    feature_.SetResponse(main_frame.get(), response_value);
    manager()->AddWebFrame(std::move(main_frame));
    web_state_.SetCurrentURL(url);
  }

  web::FakeWebFramesManager* manager() {
    return static_cast<web::FakeWebFramesManager*>(
        web_state_.GetPageWorldWebFramesManager());
  }

  void InvokeGenerationAndExpectSuccess() {
    feature_.GetLinkToText(
        &web_state_, base::BindOnce([](LinkToTextResponse* response) {
          EXPECT_TRUE(response.payload != nil);
          EXPECT_EQ(base::ScopedMockElapsedTimersForTest::kMockElapsedTime,
                    response.latency);
        }));
  }

  void InvokeGenerationAndExpectError(
      shared_highlighting::LinkGenerationError expected) {
    feature_.GetLinkToText(
        &web_state_,
        base::BindOnce(
            [](shared_highlighting::LinkGenerationError expected_error,
               LinkToTextResponse* response) {
              EXPECT_TRUE(response.error);
              EXPECT_EQ(expected_error, *response.error);
              EXPECT_EQ(base::ScopedMockElapsedTimersForTest::kMockElapsedTime,
                        response.latency);
            },
            expected));
  }

  web::WebTaskEnvironment task_environment_;
  base::test::ScopedFeatureList feature_list_;
  base::ScopedMockElapsedTimersForTest timer_;
  DisarmedFeature feature_;
  web::FakeWebState web_state_;
};

TEST_F(LinkToTextJavaScriptFeatureTest, ShouldAttemptIframeGeneration) {
  {
    base::test::ScopedFeatureList feature_on(
        shared_highlighting::kSharedHighlightingAmp);

    EXPECT_TRUE(LinkToTextJavaScriptFeature::ShouldAttemptIframeGeneration(
        shared_highlighting::LinkGenerationError::kIncorrectSelector,
        GURL("https://www.google.com/amp/")));

    // Only kIncorrectSelector should trigger iframe generation. If we found a
    // selection in the main frame, then there won't be one in iframes, so it's
    // pointless to retry in other error cases.
    EXPECT_FALSE(LinkToTextJavaScriptFeature::ShouldAttemptIframeGeneration(
        {}, GURL("https://www.google.com/amp/")));
    EXPECT_FALSE(LinkToTextJavaScriptFeature::ShouldAttemptIframeGeneration(
        shared_highlighting::LinkGenerationError::kContextExhausted,
        GURL("https://www.google.com/amp/")));
    EXPECT_FALSE(LinkToTextJavaScriptFeature::ShouldAttemptIframeGeneration(
        shared_highlighting::LinkGenerationError::kEmptySelection,
        GURL("https://www.google.com/amp/")));
    EXPECT_FALSE(LinkToTextJavaScriptFeature::ShouldAttemptIframeGeneration(
        shared_highlighting::LinkGenerationError::kUnknown,
        GURL("https://www.google.com/amp/")));
    EXPECT_FALSE(LinkToTextJavaScriptFeature::ShouldAttemptIframeGeneration(
        shared_highlighting::LinkGenerationError::kTimeout,
        GURL("https://www.google.com/amp/")));

    // Iframe generation is limited to certain domains and paths.
    EXPECT_FALSE(LinkToTextJavaScriptFeature::ShouldAttemptIframeGeneration(
        shared_highlighting::LinkGenerationError::kIncorrectSelector,
        GURL("https://www.google.com")));
    EXPECT_FALSE(LinkToTextJavaScriptFeature::ShouldAttemptIframeGeneration(
        shared_highlighting::LinkGenerationError::kIncorrectSelector,
        GURL("https://www.example.com/amp/")));
  }
  {
    base::test::ScopedFeatureList feature_off;
    feature_off.InitAndDisableFeature(
        shared_highlighting::kSharedHighlightingAmp);

    // Retest that the true condition above is false when the feature is off.
    EXPECT_FALSE(LinkToTextJavaScriptFeature::ShouldAttemptIframeGeneration(
        shared_highlighting::LinkGenerationError::kIncorrectSelector,
        GURL("https://www.google.com/amp/")));
  }
}

TEST_F(LinkToTextJavaScriptFeatureTest, GenerateInMainFrame) {
  base::Value success = GetSuccessValue();
  AddMainFrame(GURL("https://www.example.com"), &success);
  InvokeGenerationAndExpectSuccess();
}

TEST_F(LinkToTextJavaScriptFeatureTest, FailInMainFrame) {
  base::Value failure = GetFailureValue();
  AddMainFrame(GURL("https://www.example.com"), &failure);
  InvokeGenerationAndExpectError(
      shared_highlighting::LinkGenerationError::kUnknown);
}

TEST_F(LinkToTextJavaScriptFeatureTest, SuccessInMainFramePreemptsIframes) {
  base::Value success = GetSuccessValue();
  AddMainFrame(GURL("https://www.google.com/amp/"), &success);

  auto child_frame = web::FakeWebFrame::CreateChildWebFrame(
      GURL("https://www.ampproject.org"));
  auto* child_frame_raw = child_frame.get();
  base::Value failure = GetFailureValue();
  feature_.SetResponse(child_frame.get(), &failure);
  manager()->AddWebFrame(std::move(child_frame));

  InvokeGenerationAndExpectSuccess();

  EXPECT_FALSE(feature_.WasJsInvokedInFrame(child_frame_raw));
}

TEST_F(LinkToTextJavaScriptFeatureTest, FailInMainFramePreemptsIframes) {
  base::Value failure = GetFailureValue();
  AddMainFrame(GURL("https://www.google.com/amp/"), &failure);

  auto child_frame = web::FakeWebFrame::CreateChildWebFrame(
      GURL("https://www.ampproject.org"));
  auto* child_frame_raw = child_frame.get();
  feature_.SetResponse(child_frame.get(), &failure);
  manager()->AddWebFrame(std::move(child_frame));

  InvokeGenerationAndExpectError(
      shared_highlighting::LinkGenerationError::kUnknown);

  EXPECT_FALSE(feature_.WasJsInvokedInFrame(child_frame_raw));
}

TEST_F(LinkToTextJavaScriptFeatureTest, GenerateInIframe) {
  base::Value noselect = GetNoSelectionValue();
  AddMainFrame(GURL("https://www.google.com/amp/"), &noselect);

  auto child_frame = web::FakeWebFrame::CreateChildWebFrame(
      GURL("https://www.ampproject.org"));
  base::Value success = GetSuccessValue();
  feature_.SetResponse(child_frame.get(), &success);
  manager()->AddWebFrame(std::move(child_frame));

  InvokeGenerationAndExpectSuccess();
}

TEST_F(LinkToTextJavaScriptFeatureTest, NoIframeGenerationOnUnknownDomain) {
  base::Value noselect = GetNoSelectionValue();
  AddMainFrame(GURL("https://www.example.com"), &noselect);

  auto child_frame = web::FakeWebFrame::CreateChildWebFrame(
      GURL("https://www.ampproject.org"));
  auto* child_frame_raw = child_frame.get();
  base::Value failure = GetFailureValue();
  feature_.SetResponse(child_frame.get(), &failure);
  manager()->AddWebFrame(std::move(child_frame));

  InvokeGenerationAndExpectError(
      shared_highlighting::LinkGenerationError::kIncorrectSelector);

  EXPECT_FALSE(feature_.WasJsInvokedInFrame(child_frame_raw));
}

TEST_F(LinkToTextJavaScriptFeatureTest, OnlyGenerateOnIframesFromKnownCache) {
  base::Value noselect = GetNoSelectionValue();
  AddMainFrame(GURL("https://www.google.com/amp/"), &noselect);

  auto child_frame_good = web::FakeWebFrame::CreateChildWebFrame(
      GURL("https://www.ampproject.org"));
  base::Value success = GetSuccessValue();
  feature_.SetResponse(child_frame_good.get(), &success);
  manager()->AddWebFrame(std::move(child_frame_good));

  auto child_frame_bad = web::FakeWebFrame::Create(
      web::kChildFakeFrameId2, /* is_main_frame */ false,
      GURL("https://www.example.com"));
  auto* child_frame_bad_raw = child_frame_bad.get();
  base::Value failure = GetFailureValue();
  feature_.SetResponse(child_frame_bad.get(), &failure);
  manager()->AddWebFrame(std::move(child_frame_bad));

  InvokeGenerationAndExpectSuccess();

  EXPECT_FALSE(feature_.WasJsInvokedInFrame(child_frame_bad_raw));
}

// If we execute on two iframes, a frame with a success should take precedence
// over one with an error.
TEST_F(LinkToTextJavaScriptFeatureTest, SuccessOnIframePreemptsError) {
  base::Value noselect = GetNoSelectionValue();
  AddMainFrame(GURL("https://www.google.com/amp/"), &noselect);

  auto child_frame_error = web::FakeWebFrame::Create(
      web::kChildFakeFrameId2, /* is_main_frame */ false,
      GURL("https://www.ampproject.org"));
  auto* child_frame_error_raw = child_frame_error.get();
  base::Value failure = GetFailureValue();
  feature_.SetResponse(child_frame_error.get(), &failure);
  manager()->AddWebFrame(std::move(child_frame_error));

  auto child_frame_success = web::FakeWebFrame::CreateChildWebFrame(
      GURL("https://www.ampproject.org"));
  auto* child_frame_success_raw = child_frame_success.get();
  base::Value success = GetSuccessValue();
  feature_.SetResponse(child_frame_success.get(), &success);
  manager()->AddWebFrame(std::move(child_frame_success));

  InvokeGenerationAndExpectSuccess();

  EXPECT_TRUE(feature_.WasJsInvokedInFrame(child_frame_error_raw));
  EXPECT_TRUE(feature_.WasJsInvokedInFrame(child_frame_success_raw));
}

// If we execute on two iframes, a frame with a success should take precedence
// over one with no selection.
TEST_F(LinkToTextJavaScriptFeatureTest, SuccessOnIframePreemptsNoSelect) {
  base::Value noselect = GetNoSelectionValue();
  AddMainFrame(GURL("https://www.google.com/amp/"), &noselect);

  auto child_frame_noselect = web::FakeWebFrame::Create(
      web::kChildFakeFrameId2, /* is_main_frame */ false,
      GURL("https://www.ampproject.org"));
  auto* child_frame_noselect_raw = child_frame_noselect.get();
  feature_.SetResponse(child_frame_noselect.get(), &noselect);
  manager()->AddWebFrame(std::move(child_frame_noselect));

  auto child_frame_success = web::FakeWebFrame::CreateChildWebFrame(
      GURL("https://www.ampproject.org"));
  auto* child_frame_success_raw = child_frame_success.get();
  base::Value success = GetSuccessValue();
  feature_.SetResponse(child_frame_success.get(), &success);
  manager()->AddWebFrame(std::move(child_frame_success));

  InvokeGenerationAndExpectSuccess();

  EXPECT_TRUE(feature_.WasJsInvokedInFrame(child_frame_noselect_raw));
  EXPECT_TRUE(feature_.WasJsInvokedInFrame(child_frame_success_raw));
}

// If we execute on two iframes, a frame with an error (other than no selection)
// should take precedence over one with no selection.
TEST_F(LinkToTextJavaScriptFeatureTest, ErrorOnIframePreemptsNoSelect) {
  base::Value noselect = GetNoSelectionValue();
  AddMainFrame(GURL("https://www.google.com/amp/"), &noselect);

  auto child_frame_noselect = web::FakeWebFrame::Create(
      web::kChildFakeFrameId2, /* is_main_frame */ false,
      GURL("https://www.ampproject.org"));
  auto* child_frame_noselect_raw = child_frame_noselect.get();
  feature_.SetResponse(child_frame_noselect.get(), &noselect);
  manager()->AddWebFrame(std::move(child_frame_noselect));

  auto child_frame_error = web::FakeWebFrame::CreateChildWebFrame(
      GURL("https://www.ampproject.org"));
  auto* child_frame_error_raw = child_frame_error.get();
  base::Value error = GetFailureValue();
  feature_.SetResponse(child_frame_error.get(), &error);
  manager()->AddWebFrame(std::move(child_frame_error));

  InvokeGenerationAndExpectError(
      shared_highlighting::LinkGenerationError::kUnknown);

  EXPECT_TRUE(feature_.WasJsInvokedInFrame(child_frame_noselect_raw));
  EXPECT_TRUE(feature_.WasJsInvokedInFrame(child_frame_error_raw));
}

TEST_F(LinkToTextJavaScriptFeatureTest, TwoFramesWithNoSelection) {
  base::Value noselect = GetNoSelectionValue();
  AddMainFrame(GURL("https://www.google.com/amp/"), &noselect);

  auto child_frame_noselect = web::FakeWebFrame::CreateChildWebFrame(
      GURL("https://www.ampproject.org"));
  auto* child_frame_noselect_raw = child_frame_noselect.get();
  feature_.SetResponse(child_frame_noselect.get(), &noselect);
  manager()->AddWebFrame(std::move(child_frame_noselect));

  auto child_frame_noselect2 = web::FakeWebFrame::Create(
      web::kChildFakeFrameId2, /* is_main_frame */ false,
      GURL("https://www.ampproject.org"));
  auto* child_frame_noselect2_raw = child_frame_noselect2.get();
  feature_.SetResponse(child_frame_noselect2.get(), &noselect);
  manager()->AddWebFrame(std::move(child_frame_noselect2));

  InvokeGenerationAndExpectError(
      shared_highlighting::LinkGenerationError::kIncorrectSelector);

  EXPECT_TRUE(feature_.WasJsInvokedInFrame(child_frame_noselect_raw));
  EXPECT_TRUE(feature_.WasJsInvokedInFrame(child_frame_noselect2_raw));
}