| // 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/actor/execution_engine.h" |
| |
| #include <optional> |
| |
| #include "base/test/bind.h" |
| #include "base/test/metrics/histogram_tester.h" |
| #include "base/test/scoped_feature_list.h" |
| #include "base/test/test_future.h" |
| #include "base/timer/elapsed_timer.h" |
| #include "chrome/browser/actor/actor_tab_data.h" |
| #include "chrome/browser/actor/actor_task.h" |
| #include "chrome/browser/actor/actor_test_util.h" |
| #include "chrome/browser/actor/shared_types.h" |
| #include "chrome/browser/actor/tools/click_tool_request.h" |
| #include "chrome/browser/actor/tools/tool_request.h" |
| #include "chrome/browser/actor/ui/event_dispatcher.h" |
| #include "chrome/browser/actor/ui/mocks/mock_event_dispatcher.h" |
| #include "chrome/common/actor.mojom.h" |
| #include "chrome/common/actor/action_result.h" |
| #include "chrome/common/chrome_features.h" |
| #include "chrome/common/chrome_render_frame.mojom.h" |
| #include "chrome/common/webui_url_constants.h" |
| #include "chrome/test/base/chrome_render_view_host_test_harness.h" |
| #include "components/optimization_guide/content/browser/page_content_proto_provider.h" |
| #include "components/tabs/public/mock_tab_interface.h" |
| #include "components/tabs/public/tab_interface.h" |
| #include "content/public/test/navigation_simulator.h" |
| #include "mojo/public/cpp/bindings/associated_receiver_set.h" |
| #include "testing/gtest/include/gtest/gtest.h" |
| #include "third_party/blink/public/common/associated_interfaces/associated_interface_provider.h" |
| #include "third_party/blink/public/mojom/window_features/window_features.mojom.h" |
| #include "url/gurl.h" |
| |
| namespace actor { |
| |
| using ::optimization_guide::proto::Actions; |
| using testing::_; |
| using testing::Eq; |
| using testing::Field; |
| using testing::Invoke; |
| using testing::Property; |
| using testing::VariantWith; |
| using ChangeTaskState = ui::UiEventDispatcher::ChangeTaskState; |
| using AddTab = ui::UiEventDispatcher::AddTab; |
| |
| namespace { |
| constexpr int kFakeContentNodeId = 123; |
| constexpr char kActionResultHistogram[] = |
| "Actor.ExecutionEngine.Action.ResultCode"; |
| constexpr char kActorTaskDurationCompletedHistogram[] = |
| "Actor.Task.Duration.Completed"; |
| constexpr char kActorTaskDurationCancelledHistogram[] = |
| "Actor.Task.Duration.Cancelled"; |
| |
| class FakeChromeRenderFrame : public chrome::mojom::ChromeRenderFrame { |
| public: |
| FakeChromeRenderFrame() = default; |
| ~FakeChromeRenderFrame() override = default; |
| |
| void OverrideBinder(content::RenderFrameHost* rfh) { |
| blink::AssociatedInterfaceProvider* remote_interfaces = |
| rfh->GetRemoteAssociatedInterfaces(); |
| remote_interfaces->OverrideBinderForTesting( |
| chrome::mojom::ChromeRenderFrame::Name_, |
| base::BindRepeating(&FakeChromeRenderFrame::Bind, |
| base::Unretained(this))); |
| } |
| |
| // chrome::mojom::ChromeRenderFrame: |
| void SetWindowFeatures( |
| blink::mojom::WindowFeaturesPtr window_features) override {} |
| void RequestReloadImageForContextNode() override {} |
| void RequestBitmapForContextNode( |
| RequestBitmapForContextNodeCallback callback) override {} |
| void RequestBitmapForContextNodeWithBoundsHint( |
| RequestBitmapForContextNodeWithBoundsHintCallback callback) override {} |
| void RequestBoundsHintForAllImages( |
| RequestBoundsHintForAllImagesCallback callback) override {} |
| void RequestImageForContextNode( |
| int32_t image_min_area_pixels, |
| const gfx::Size& image_max_size_pixels, |
| chrome::mojom::ImageFormat image_format, |
| int32_t quality, |
| RequestImageForContextNodeCallback callback) override {} |
| void ExecuteWebUIJavaScript(const std::u16string& javascript) override {} |
| void GetMediaFeedURL(GetMediaFeedURLCallback callback) override {} |
| void LoadBlockedPlugins(const std::string& identifier) override {} |
| void SetSupportsDraggableRegions(bool supports_draggable_regions) override {} |
| void SetShouldDeferMediaLoad(bool should_defer) override {} |
| |
| void InvokeTool(actor::mojom::ToolInvocationPtr request, |
| InvokeToolCallback callback) override { |
| std::move(callback).Run(MakeOkResult()); |
| } |
| void StartActorJournal( |
| mojo::PendingAssociatedRemote<actor::mojom::JournalClient> client) |
| override {} |
| |
| private: |
| void Bind(mojo::ScopedInterfaceEndpointHandle handle) { |
| receivers_.Add( |
| this, mojo::PendingAssociatedReceiver<chrome::mojom::ChromeRenderFrame>( |
| std::move(handle))); |
| } |
| |
| mojo::AssociatedReceiverSet<chrome::mojom::ChromeRenderFrame> receivers_; |
| }; |
| |
| class ExecutionEngineTest : public ChromeRenderViewHostTestHarness { |
| public: |
| ExecutionEngineTest() |
| : ChromeRenderViewHostTestHarness( |
| content::BrowserTaskEnvironment::TimeSource::MOCK_TIME) {} |
| ~ExecutionEngineTest() override = default; |
| |
| void SetUp() override { |
| scoped_feature_list_.InitWithFeatures( |
| /*enabled_features=*/{features::kGlicActor}, |
| /*disabled_features=*/{}); |
| ChromeRenderViewHostTestHarness::SetUp(); |
| AssociateTabInterface(); |
| |
| // ExecutionEngine & ActorTask use separate UiEventDispatcher objects, so |
| // we create separate mocks for each. |
| std::unique_ptr<ui::UiEventDispatcher> ui_event_dispatcher = |
| ui::NewMockUiEventDispatcher(); |
| std::unique_ptr<ui::UiEventDispatcher> task_ui_event_dispatcher = |
| ui::NewMockUiEventDispatcher(); |
| mock_ui_event_dispatcher_ = |
| static_cast<ui::MockUiEventDispatcher*>(ui_event_dispatcher.get()); |
| task_mock_ui_event_dispatcher_ = |
| static_cast<ui::MockUiEventDispatcher*>(task_ui_event_dispatcher.get()); |
| |
| auto execution_engine = ExecutionEngine::CreateForTesting( |
| profile(), std::move(ui_event_dispatcher)); |
| auto raw_execution_engine = execution_engine.get(); |
| task_ = std::make_unique<ActorTask>(profile(), std::move(execution_engine), |
| std::move(task_ui_event_dispatcher)); |
| task_->SetIdForTesting(0); |
| raw_execution_engine->SetOwner(task_.get()); |
| |
| for (auto& mock : |
| {mock_ui_event_dispatcher_, task_mock_ui_event_dispatcher_}) { |
| ON_CALL(*mock, OnPreFirstAct(_, _)) |
| .WillByDefault(Invoke(Invoke( |
| UiEventDispatcherCallback<ui::UiEventDispatcher::FirstActInfo>( |
| base::BindRepeating(MakeOkResult))))); |
| ON_CALL(*mock, OnPreTool(_, _)) |
| .WillByDefault(Invoke(UiEventDispatcherCallback<ToolRequest>( |
| base::BindRepeating(MakeOkResult)))); |
| ON_CALL(*mock, OnPostTool(_, _)) |
| .WillByDefault(Invoke(UiEventDispatcherCallback<ToolRequest>( |
| base::BindRepeating(MakeOkResult)))); |
| ON_CALL(*mock, OnActorTaskAsyncChange(_, _)) |
| .WillByDefault(Invoke(UiEventDispatcherCallback< |
| ui::UiEventDispatcher::ActorTaskAsyncChange>( |
| base::BindRepeating(MakeOkResult)))); |
| } |
| } |
| |
| void TearDown() override { |
| mock_ui_event_dispatcher_ = nullptr; |
| task_mock_ui_event_dispatcher_ = nullptr; |
| task_.reset(); |
| ClearTabInterface(); |
| |
| ChromeRenderViewHostTestHarness::TearDown(); |
| } |
| |
| base::OnceCallback<std::unique_ptr<ToolRequest>()> MakeClickCallback( |
| int content_node_id) { |
| return base::BindLambdaForTesting([this, content_node_id]() { |
| std::string document_identifier = |
| *optimization_guide::DocumentIdentifierUserData:: |
| GetDocumentIdentifier(main_rfh()->GetGlobalFrameToken()); |
| actor::PageTarget target( |
| actor::DomNode{.node_id = content_node_id, |
| .document_identifier = document_identifier}); |
| std::unique_ptr<ToolRequest> request = |
| std::make_unique<actor::ClickToolRequest>( |
| GetTab()->GetHandle(), target, MouseClickType::kLeft, |
| MouseClickCount::kSingle); |
| return request; |
| }); |
| } |
| |
| protected: |
| // Note: action must be generated from a callback because this method |
| // navigates the render frame and the generated action must include a |
| // document identifier token which is only available after the navigation. |
| bool Act(const GURL& url, |
| base::OnceCallback<std::unique_ptr<ToolRequest>()> make_action) { |
| content::NavigationSimulator::NavigateAndCommitFromBrowser(web_contents(), |
| url); |
| fake_chrome_render_frame_.OverrideBinder(main_rfh()); |
| |
| ActResultFuture success; |
| std::unique_ptr<ToolRequest> action = std::move(make_action).Run(); |
| task_->Act(ToRequestList(std::move(action)), success.GetCallback()); |
| return IsOk(*success.Get<0>()); |
| } |
| |
| tabs::MockTabInterface* GetTab() { |
| return tab_state_ ? &tab_state_->tab : nullptr; |
| } |
| |
| void AssociateTabInterface() { tab_state_.emplace(web_contents()); } |
| void ClearTabInterface() { tab_state_.reset(); } |
| |
| base::HistogramTester histograms_; |
| FakeChromeRenderFrame fake_chrome_render_frame_; |
| std::unique_ptr<ActorTask> task_; |
| raw_ptr<ui::MockUiEventDispatcher> mock_ui_event_dispatcher_; |
| raw_ptr<ui::MockUiEventDispatcher> task_mock_ui_event_dispatcher_; |
| |
| private: |
| struct TabState { |
| explicit TabState(content::WebContents* web_contents) { |
| ON_CALL(tab, GetContents).WillByDefault(::testing::Return(web_contents)); |
| ON_CALL(tab, RegisterWillDetach) |
| .WillByDefault([this](tabs::TabInterface::WillDetach callback) { |
| return will_detach_callback_list_.Add(std::move(callback)); |
| }); |
| ON_CALL(tab, GetUnownedUserDataHost()) |
| .WillByDefault(::testing::ReturnRef(user_data_host_)); |
| tab_data_ = std::make_unique<ActorTabData>(&tab); |
| } |
| |
| ~TabState() { |
| will_detach_callback_list_.Notify( |
| &tab, tabs::TabInterface::DetachReason::kDelete); |
| } |
| |
| using WillDetachCallbackList = |
| base::RepeatingCallbackList<void(tabs::TabInterface*, |
| tabs::TabInterface::DetachReason)>; |
| WillDetachCallbackList will_detach_callback_list_; |
| |
| tabs::MockTabInterface tab; |
| |
| private: |
| ::ui::UnownedUserDataHost user_data_host_; |
| std::unique_ptr<ActorTabData> tab_data_; |
| }; |
| std::optional<TabState> tab_state_; |
| |
| base::test::ScopedFeatureList scoped_feature_list_; |
| }; |
| |
| TEST_F(ExecutionEngineTest, ActSucceedsOnSupportedUrl) { |
| EXPECT_CALL(*mock_ui_event_dispatcher_, OnPreFirstAct(_, _)).Times(1); |
| EXPECT_CALL(*mock_ui_event_dispatcher_, |
| OnPreTool(Property(&ToolRequest::JournalEvent, Eq("Click")), _)) |
| .Times(1); |
| EXPECT_CALL(*mock_ui_event_dispatcher_, |
| OnPostTool(Property(&ToolRequest::JournalEvent, Eq("Click")), _)) |
| .Times(1); |
| EXPECT_CALL( |
| *task_mock_ui_event_dispatcher_, |
| OnActorTaskSyncChange(VariantWith<ChangeTaskState>(AllOf( |
| Field(&ChangeTaskState::old_state, ActorTask::State::kCreated), |
| Field(&ChangeTaskState::new_state, ActorTask::State::kActing))))) |
| .Times(1); |
| EXPECT_CALL( |
| *task_mock_ui_event_dispatcher_, |
| OnActorTaskSyncChange(VariantWith<ChangeTaskState>(AllOf( |
| Field(&ChangeTaskState::old_state, ActorTask::State::kActing), |
| Field(&ChangeTaskState::new_state, ActorTask::State::kReflecting))))); |
| EXPECT_CALL(*task_mock_ui_event_dispatcher_, |
| OnActorTaskAsyncChange(VariantWith<AddTab>(_), _)) |
| .Times(1); |
| EXPECT_TRUE( |
| Act(GURL("http://localhost/"), MakeClickCallback(kFakeContentNodeId))); |
| histograms_.ExpectUniqueSample(kActionResultHistogram, |
| mojom::ActionResultCode::kOk, 1); |
| } |
| |
| TEST_F(ExecutionEngineTest, ActFailsOnUnsupportedUrl) { |
| EXPECT_CALL(*mock_ui_event_dispatcher_, OnPreFirstAct(_, _)).Times(1); |
| EXPECT_CALL(*mock_ui_event_dispatcher_, OnPreTool(_, _)).Times(0); |
| EXPECT_CALL(*mock_ui_event_dispatcher_, OnPostTool(_, _)).Times(0); |
| EXPECT_FALSE(Act(GURL(chrome::kChromeUIVersionURL), |
| MakeClickCallback(kFakeContentNodeId))); |
| } |
| |
| TEST_F(ExecutionEngineTest, UiOnPreFirstActFails) { |
| EXPECT_CALL(*mock_ui_event_dispatcher_, OnPreFirstAct(_, _)) |
| .WillOnce( |
| Invoke(UiEventDispatcherCallback<ui::UiEventDispatcher::FirstActInfo>( |
| base::BindRepeating(MakeErrorResult)))); |
| EXPECT_CALL(*mock_ui_event_dispatcher_, OnPreTool(_, _)).Times(0); |
| EXPECT_CALL(*mock_ui_event_dispatcher_, OnPostTool(_, _)).Times(0); |
| EXPECT_FALSE( |
| Act(GURL("http://localhost/"), MakeClickCallback(kFakeContentNodeId))); |
| histograms_.ExpectUniqueSample(kActionResultHistogram, |
| mojom::ActionResultCode::kError, 1); |
| } |
| |
| TEST_F(ExecutionEngineTest, UiOnPreToolFails) { |
| EXPECT_CALL(*mock_ui_event_dispatcher_, OnPreFirstAct(_, _)).Times(1); |
| EXPECT_CALL(*mock_ui_event_dispatcher_, OnPreTool(_, _)) |
| .WillOnce(Invoke(UiEventDispatcherCallback<ToolRequest>( |
| base::BindRepeating(MakeErrorResult)))); |
| EXPECT_CALL(*mock_ui_event_dispatcher_, OnPostTool(_, _)).Times(0); |
| EXPECT_FALSE( |
| Act(GURL("http://localhost/"), MakeClickCallback(kFakeContentNodeId))); |
| histograms_.ExpectUniqueSample(kActionResultHistogram, |
| mojom::ActionResultCode::kError, 1); |
| } |
| |
| TEST_F(ExecutionEngineTest, UiOnPostToolFails) { |
| EXPECT_CALL(*mock_ui_event_dispatcher_, OnPreFirstAct(_, _)).Times(1); |
| EXPECT_CALL(*mock_ui_event_dispatcher_, OnPreTool(_, _)).Times(1); |
| EXPECT_CALL(*mock_ui_event_dispatcher_, OnPostTool(_, _)) |
| .WillOnce(Invoke(UiEventDispatcherCallback<ToolRequest>( |
| base::BindRepeating(MakeErrorResult)))); |
| EXPECT_FALSE( |
| Act(GURL("http://localhost/"), MakeClickCallback(kFakeContentNodeId))); |
| histograms_.ExpectUniqueSample(kActionResultHistogram, |
| mojom::ActionResultCode::kError, 1); |
| } |
| |
| TEST_F(ExecutionEngineTest, ActFailsWhenAddTabFails) { |
| EXPECT_CALL(*task_mock_ui_event_dispatcher_, |
| OnActorTaskAsyncChange(VariantWith<AddTab>(_), _)) |
| .WillOnce(Invoke(UiEventDispatcherCallback< |
| ui::UiEventDispatcher::ActorTaskAsyncChange>( |
| base::BindRepeating(MakeErrorResult)))); |
| EXPECT_FALSE( |
| Act(GURL("http://localhost/"), MakeClickCallback(kFakeContentNodeId))); |
| histograms_.ExpectUniqueSample(kActionResultHistogram, |
| mojom::ActionResultCode::kError, 1); |
| } |
| |
| TEST_F(ExecutionEngineTest, ActFailsWhenTabDestroyed) { |
| content::NavigationSimulator::NavigateAndCommitFromBrowser( |
| web_contents(), GURL("http://localhost/")); |
| |
| ActResultFuture result; |
| |
| FakeChromeRenderFrame fake_chrome_render_frame; |
| fake_chrome_render_frame.OverrideBinder(main_rfh()); |
| |
| std::unique_ptr<ToolRequest> action = |
| MakeClickCallback(kFakeContentNodeId).Run(); |
| task_->Act(ToRequestList(action), result.GetCallback()); |
| |
| ClearTabInterface(); |
| DeleteContents(); |
| |
| ExpectErrorResult(result, mojom::ActionResultCode::kTabWentAway); |
| histograms_.ExpectUniqueSample(kActionResultHistogram, |
| mojom::ActionResultCode::kTabWentAway, 1); |
| } |
| |
| TEST_F(ExecutionEngineTest, CrossOriginNavigationBeforeAction) { |
| content::NavigationSimulator::NavigateAndCommitFromBrowser( |
| web_contents(), GURL("http://localhost/")); |
| |
| FakeChromeRenderFrame fake_chrome_render_frame; |
| fake_chrome_render_frame.OverrideBinder(main_rfh()); |
| |
| ActResultFuture result; |
| auto execution_engine = std::make_unique<ExecutionEngine>(profile()); |
| ActorTask task(profile(), std::move(execution_engine), |
| ui::NewMockUiEventDispatcher()); |
| std::unique_ptr<ToolRequest> action = |
| MakeClickCallback(kFakeContentNodeId).Run(); |
| task_->Act(ToRequestList(std::move(action)), result.GetCallback()); |
| |
| // Before the action happens, commit a cross-origin navigation. |
| ASSERT_FALSE(result.IsReady()); |
| content::NavigationSimulator::NavigateAndCommitFromBrowser( |
| web_contents(), GURL("http://localhost:8000/")); |
| |
| // TODO(mcnee): We currently just fail, but this should do something more |
| // graceful. |
| ExpectErrorResult(result, mojom::ActionResultCode::kCrossOriginNavigation); |
| histograms_.ExpectUniqueSample( |
| kActionResultHistogram, mojom::ActionResultCode::kCrossOriginNavigation, |
| 1); |
| } |
| |
| TEST_F(ExecutionEngineTest, CompletedHistogram) { |
| content::NavigationSimulator::NavigateAndCommitFromBrowser( |
| web_contents(), GURL("http://localhost/")); |
| |
| ActResultFuture result; |
| |
| FakeChromeRenderFrame fake_chrome_render_frame; |
| fake_chrome_render_frame.OverrideBinder(main_rfh()); |
| |
| std::unique_ptr<ToolRequest> action = |
| MakeClickCallback(kFakeContentNodeId).Run(); |
| task_->Act(ToRequestList(action), result.GetCallback()); |
| |
| // Simulate time passing before the task stops |
| const base::TimeDelta task_duration = base::Milliseconds(123); |
| task_environment()->FastForwardBy(task_duration); |
| |
| task_->Stop(/*success=*/true); |
| histograms_.ExpectTimeBucketCount(kActorTaskDurationCompletedHistogram, |
| task_duration, 1); |
| } |
| |
| TEST_F(ExecutionEngineTest, CompletedWithPauseHistogram) { |
| content::NavigationSimulator::NavigateAndCommitFromBrowser( |
| web_contents(), GURL("http://localhost/")); |
| |
| ActResultFuture result; |
| |
| FakeChromeRenderFrame fake_chrome_render_frame; |
| fake_chrome_render_frame.OverrideBinder(main_rfh()); |
| |
| std::unique_ptr<ToolRequest> action = |
| MakeClickCallback(kFakeContentNodeId).Run(); |
| task_->Act(ToRequestList(action), result.GetCallback()); |
| |
| // Simulate the first active period |
| const base::TimeDelta active_duration1 = base::Milliseconds(100); |
| task_environment()->FastForwardBy(active_duration1); |
| |
| task_->Pause(/*from_actor=*/true); |
| |
| // Time that passes while paused should not be counted. |
| task_environment()->FastForwardBy(base::Milliseconds(500)); |
| |
| task_->Resume(); |
| |
| // Simulate the second active period |
| const base::TimeDelta active_duration2 = base::Milliseconds(50); |
| task_environment()->FastForwardBy(active_duration2); |
| |
| task_->Stop(/*success=*/true); |
| histograms_.ExpectTimeBucketCount(kActorTaskDurationCompletedHistogram, |
| active_duration1 + active_duration2, 1); |
| } |
| |
| TEST_F(ExecutionEngineTest, CancelledHistogram) { |
| content::NavigationSimulator::NavigateAndCommitFromBrowser( |
| web_contents(), GURL("http://localhost/")); |
| |
| ActResultFuture result; |
| |
| FakeChromeRenderFrame fake_chrome_render_frame; |
| fake_chrome_render_frame.OverrideBinder(main_rfh()); |
| |
| std::unique_ptr<ToolRequest> action = |
| MakeClickCallback(kFakeContentNodeId).Run(); |
| task_->Act(ToRequestList(action), result.GetCallback()); |
| |
| // Simulate time passing before the task is cancelled |
| const base::TimeDelta task_duration = base::Milliseconds(456); |
| task_environment()->FastForwardBy(task_duration); |
| |
| task_->Stop(/*success=*/false); |
| histograms_.ExpectTimeBucketCount(kActorTaskDurationCancelledHistogram, |
| task_duration, 1); |
| } |
| |
| } // namespace |
| |
| } // namespace actor |