| // Copyright 2025 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "chrome/browser/contextual_tasks/contextual_tasks_ui_service.h" |
| |
| #include "base/test/task_environment.h" |
| #include "base/uuid.h" |
| #include "chrome/browser/contextual_tasks/contextual_tasks_context_controller.h" |
| #include "chrome/browser/contextual_tasks/contextual_tasks_ui.h" |
| #include "chrome/browser/contextual_tasks/mock_contextual_tasks_context_controller.h" |
| #include "chrome/common/webui_url_constants.h" |
| #include "chrome/test/base/testing_profile.h" |
| #include "components/sessions/content/session_tab_helper.h" |
| #include "content/public/test/browser_task_environment.h" |
| #include "content/public/test/test_renderer_host.h" |
| #include "content/public/test/web_contents_tester.h" |
| #include "net/base/url_util.h" |
| #include "testing/gmock/include/gmock/gmock.h" |
| #include "testing/gtest/include/gtest/gtest.h" |
| #include "url/gurl.h" |
| |
| using testing::_; |
| using testing::Return; |
| |
| namespace content { |
| class WebContents; |
| } // namespace content |
| |
| namespace contextual_tasks { |
| |
| namespace { |
| |
| constexpr char kTestUrl[] = "https://example.com"; |
| constexpr char kAiPageUrl[] = "https://google.com/search?udm=50"; |
| |
| // A mock ContextualTasksUiService that is specifically used for tests around |
| // intercepting navigation. Namely the `HandleNavigation` method is the real |
| // implementation with the events being mocked. |
| class MockUiServiceForUrlIntercept : public ContextualTasksUiService { |
| public: |
| explicit MockUiServiceForUrlIntercept( |
| ContextualTasksContextController* context_controller) |
| : ContextualTasksUiService(context_controller) {} |
| ~MockUiServiceForUrlIntercept() override = default; |
| |
| MOCK_METHOD(void, |
| OnNavigationToAiPageIntercepted, |
| (const GURL& url, |
| const content::FrameTreeNodeId& source_frame_tree_node_id, |
| bool is_to_new_tab), |
| (override)); |
| MOCK_METHOD(void, |
| OnThreadLinkClicked, |
| (const GURL& url, |
| const content::FrameTreeNodeId& source_frame_tree_node_id), |
| (override)); |
| }; |
| |
| } // namespace |
| |
| class ContextualTasksUiServiceTest : public content::RenderViewHostTestHarness { |
| public: |
| void SetUp() override { |
| content::RenderViewHostTestHarness::SetUp(); |
| context_controller_ = |
| std::make_unique<MockContextualTasksContextController>(); |
| service_for_nav_ = std::make_unique<MockUiServiceForUrlIntercept>( |
| context_controller_.get()); |
| } |
| |
| void TearDown() override { |
| service_for_nav_ = nullptr; |
| context_controller_ = nullptr; |
| content::RenderViewHostTestHarness::TearDown(); |
| } |
| |
| std::unique_ptr<content::BrowserContext> CreateBrowserContext() override { |
| return std::make_unique<TestingProfile>(); |
| } |
| |
| protected: |
| std::unique_ptr<MockUiServiceForUrlIntercept> service_for_nav_; |
| std::unique_ptr<MockContextualTasksContextController> context_controller_; |
| }; |
| |
| TEST_F(ContextualTasksUiServiceTest, IsAiUrl_InvalidUrl) { |
| GURL url("http://?a=12345"); |
| EXPECT_FALSE(url.is_valid()); |
| EXPECT_FALSE(service_for_nav_->IsAiUrl(url)); |
| } |
| |
| TEST_F(ContextualTasksUiServiceTest, LinkFromWebUiIntercepted) { |
| GURL navigated_url(kTestUrl); |
| GURL host_web_content_url(chrome::kChromeUIContextualTasksURL); |
| |
| EXPECT_CALL(*service_for_nav_, OnThreadLinkClicked(navigated_url, _)) |
| .Times(1); |
| EXPECT_CALL(*service_for_nav_, OnNavigationToAiPageIntercepted(_, _, _)) |
| .Times(0); |
| EXPECT_TRUE(service_for_nav_->HandleNavigation( |
| navigated_url, host_web_content_url, content::FrameTreeNodeId(), false)); |
| task_environment()->RunUntilIdle(); |
| } |
| |
| // Ensure we're not intercepting a link when it doesn't meet any of our |
| // conditions. |
| TEST_F(ContextualTasksUiServiceTest, NormalLinkNotIntercepted) { |
| EXPECT_CALL(*service_for_nav_, OnThreadLinkClicked(_, _)).Times(0); |
| EXPECT_CALL(*service_for_nav_, OnNavigationToAiPageIntercepted(_, _, _)) |
| .Times(0); |
| EXPECT_FALSE(service_for_nav_->HandleNavigation( |
| GURL(kTestUrl), GURL("https://example.com/foo"), |
| content::FrameTreeNodeId(), false)); |
| task_environment()->RunUntilIdle(); |
| } |
| |
| TEST_F(ContextualTasksUiServiceTest, AiHostNotIntercepted_BadPath) { |
| EXPECT_CALL(*service_for_nav_, OnThreadLinkClicked(_, _)).Times(0); |
| EXPECT_CALL(*service_for_nav_, OnNavigationToAiPageIntercepted(_, _, _)) |
| .Times(0); |
| EXPECT_FALSE(service_for_nav_->HandleNavigation( |
| GURL(kTestUrl), GURL("https://google.com/maps?udm=50"), |
| content::FrameTreeNodeId(), false)); |
| task_environment()->RunUntilIdle(); |
| } |
| |
| TEST_F(ContextualTasksUiServiceTest, AiPageIntercepted_FromTab) { |
| GURL ai_url(kAiPageUrl); |
| GURL tab_url(kTestUrl); |
| |
| EXPECT_CALL(*service_for_nav_, OnThreadLinkClicked(_, _)).Times(0); |
| EXPECT_CALL(*service_for_nav_, OnNavigationToAiPageIntercepted(ai_url, _, _)) |
| .Times(1); |
| EXPECT_TRUE(service_for_nav_->HandleNavigation( |
| ai_url, tab_url, content::FrameTreeNodeId(), false)); |
| task_environment()->RunUntilIdle(); |
| } |
| |
| TEST_F(ContextualTasksUiServiceTest, AiPageIntercepted_FromOmnibox) { |
| GURL ai_url(kAiPageUrl); |
| |
| EXPECT_CALL(*service_for_nav_, OnThreadLinkClicked(_, _)).Times(0); |
| EXPECT_CALL(*service_for_nav_, OnNavigationToAiPageIntercepted(ai_url, _, _)) |
| .Times(1); |
| EXPECT_TRUE(service_for_nav_->HandleNavigation( |
| ai_url, GURL(), content::FrameTreeNodeId(), false)); |
| task_environment()->RunUntilIdle(); |
| } |
| |
| // The AI page is allowed to load as long as it is part of the WebUI. |
| TEST_F(ContextualTasksUiServiceTest, AiPageNotIntercepted) { |
| GURL webui_url(chrome::kChromeUIContextualTasksURL); |
| |
| EXPECT_CALL(*service_for_nav_, OnThreadLinkClicked(_, _)).Times(0); |
| EXPECT_CALL(*service_for_nav_, OnNavigationToAiPageIntercepted(_, _, _)) |
| .Times(0); |
| EXPECT_FALSE(service_for_nav_->HandleNavigation( |
| GURL(kAiPageUrl), webui_url, content::FrameTreeNodeId(), false)); |
| task_environment()->RunUntilIdle(); |
| } |
| |
| TEST_F(ContextualTasksUiServiceTest, ContextControllerUpdatedOnUrlChange) { |
| GURL updated_url(kAiPageUrl); |
| |
| std::string turn_id = "1234"; |
| updated_url = net::AppendQueryParameter(updated_url, "mstk", turn_id); |
| |
| std::string thread_id = "5678"; |
| updated_url = net::AppendQueryParameter(updated_url, "mtid", thread_id); |
| |
| base::Uuid task_id = |
| base::Uuid::ParseCaseInsensitive("10000000-0000-0000-0000-000000000000"); |
| std::string title = "title"; |
| |
| EXPECT_CALL( |
| *context_controller_, |
| UpdateThreadForTask(task_id, _, thread_id, testing::Optional(turn_id), |
| testing::Optional(title))) |
| .Times(1); |
| |
| service_for_nav_->OnWebUiInnerFrameNavigation(task_id, updated_url, title); |
| } |
| |
| TEST_F(ContextualTasksUiServiceTest, |
| ContextControllerUpdatedOnUrlChange_NoThreadId) { |
| GURL updated_url(kAiPageUrl); |
| |
| std::string turn_id = "1234"; |
| updated_url = net::AppendQueryParameter(updated_url, "mstk", turn_id); |
| |
| base::Uuid task_id = |
| base::Uuid::ParseCaseInsensitive("10000000-0000-0000-0000-000000000000"); |
| std::string title = "title"; |
| |
| EXPECT_CALL(*context_controller_, UpdateThreadForTask(_, _, _, _, _)) |
| .Times(0); |
| |
| service_for_nav_->OnWebUiInnerFrameNavigation(task_id, updated_url, title); |
| } |
| |
| // The task should still updated without a turn ID. |
| TEST_F(ContextualTasksUiServiceTest, |
| ContextControllerUpdatedOnUrlChange_NoTurnId) { |
| GURL updated_url(kAiPageUrl); |
| |
| std::string thread_id = "5678"; |
| updated_url = net::AppendQueryParameter(updated_url, "mtid", thread_id); |
| |
| base::Uuid task_id = |
| base::Uuid::ParseCaseInsensitive("10000000-0000-0000-0000-000000000000"); |
| std::string title = "title"; |
| |
| EXPECT_CALL( |
| *context_controller_, |
| UpdateThreadForTask(task_id, _, thread_id, _, testing::Optional(title))) |
| .Times(1); |
| |
| service_for_nav_->OnWebUiInnerFrameNavigation(task_id, updated_url, title); |
| } |
| |
| TEST_F(ContextualTasksUiServiceTest, OnNavigationToAiPageIntercepted_SameTab) { |
| ContextualTasksUiService service(context_controller_.get()); |
| GURL intercepted_url("https://google.com/search?udm=50&q=test+query"); |
| |
| TestingProfile profile; |
| auto web_contents = content::WebContentsTester::CreateTestWebContents( |
| &profile, content::SiteInstance::Create(&profile)); |
| sessions::SessionTabHelper::CreateForWebContents( |
| web_contents.get(), |
| base::BindRepeating([](content::WebContents* contents) { |
| return static_cast<sessions::SessionTabHelperDelegate*>(nullptr); |
| })); |
| |
| ContextualTask task(base::Uuid::GenerateRandomV4()); |
| EXPECT_CALL(*context_controller_, CreateTaskFromUrl(intercepted_url)) |
| .WillOnce(Return(task)); |
| EXPECT_CALL(*context_controller_, |
| AssociateTabWithTask( |
| task.GetTaskId(), |
| sessions::SessionTabHelper::IdForTab(web_contents.get()))) |
| .Times(1); |
| |
| service.OnNavigationToAiPageIntercepted( |
| intercepted_url, |
| web_contents->GetPrimaryMainFrame()->GetFrameTreeNodeId(), false); |
| |
| GURL expected_initial_url( |
| "https://www.google.com/search?udm=50&gsc=2&gl=us&q=test+query"); |
| EXPECT_EQ(service.GetInitialUrlForTask(task.GetTaskId()), |
| expected_initial_url); |
| } |
| |
| } // namespace contextual_tasks |