blob: df7570e5754daea412f06caac88129d9a460e172 [file] [log] [blame]
// 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 <string_view>
#include "base/files/scoped_temp_dir.h"
#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 "chrome/browser/actor/actor_features.h"
#include "chrome/browser/actor/actor_keyed_service.h"
#include "chrome/browser/actor/actor_test_util.h"
#include "chrome/browser/actor/tools/click_tool_request.h"
#include "chrome/browser/actor/tools/tab_management_tool_request.h"
#include "chrome/browser/actor/ui/event_dispatcher.h"
#include "chrome/browser/chrome_content_browser_client.h"
#include "chrome/browser/glic/public/glic_keyed_service.h"
#include "chrome/browser/optimization_guide/browser_test_util.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/ui_features.h"
#include "chrome/common/actor.mojom.h"
#include "chrome/common/actor/action_result.h"
#include "chrome/common/chrome_features.h"
#include "chrome/test/base/chrome_test_utils.h"
#include "chrome/test/base/in_process_browser_test.h"
#include "chrome/test/base/ui_test_utils.h"
#include "components/optimization_guide/content/browser/page_content_proto_provider.h"
#include "components/optimization_guide/core/filters/optimization_hints_component_update_listener.h"
#include "components/optimization_guide/proto/features/actions_data.pb.h"
#include "content/public/browser/render_frame_host.h"
#include "content/public/common/content_client.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
#include "content/public/test/prerender_test_util.h"
#include "content/public/test/test_frame_navigation_observer.h"
#include "net/dns/mock_host_resolver.h"
using ::base::test::TestFuture;
using ::optimization_guide::proto::ClickAction;
namespace actor {
namespace {
class FakeChromeContentBrowserClient : public ChromeContentBrowserClient {
public:
bool HandleExternalProtocol(
const GURL& url,
content::WebContents::Getter web_contents_getter,
content::FrameTreeNodeId frame_tree_node_id,
content::NavigationUIData* navigation_data,
bool is_primary_main_frame,
bool is_in_fenced_frame_tree,
network::mojom::WebSandboxFlags sandbox_flags,
::ui::PageTransition page_transition,
bool has_user_gesture,
const std::optional<url::Origin>& initiating_origin,
content::RenderFrameHost* initiator_document,
const net::IsolationInfo& isolation_info,
mojo::PendingRemote<network::mojom::URLLoaderFactory>* out_factory)
override {
external_protocol_result_ =
ChromeContentBrowserClient::HandleExternalProtocol(
url, web_contents_getter, frame_tree_node_id, navigation_data,
is_primary_main_frame, is_in_fenced_frame_tree, sandbox_flags,
page_transition, has_user_gesture, initiating_origin,
initiator_document, isolation_info, out_factory);
return external_protocol_result_.value();
}
std::optional<bool> external_protocol_result() {
return external_protocol_result_;
}
private:
std::optional<bool> external_protocol_result_;
};
class ExecutionEngineBrowserTest : public InProcessBrowserTest {
public:
ExecutionEngineBrowserTest()
: prerender_helper_(
base::BindRepeating(&ExecutionEngineBrowserTest::web_contents,
base::Unretained(this))) {
scoped_feature_list_.InitWithFeatures(
/*enabled_features=*/{features::kGlic, features::kTabstripComboButton,
features::kGlicActor},
/*disabled_features=*/{features::kGlicWarming});
}
ExecutionEngineBrowserTest(const ExecutionEngineBrowserTest&) = delete;
ExecutionEngineBrowserTest& operator=(const ExecutionEngineBrowserTest&) =
delete;
~ExecutionEngineBrowserTest() override = default;
void SetUpCommandLine(base::CommandLine* command_line) override {
InProcessBrowserTest::SetUpCommandLine(command_line);
SetUpBlocklist(command_line, "blocked.example.com");
}
void SetUpOnMainThread() override {
InProcessBrowserTest::SetUpOnMainThread();
host_resolver()->AddRule("*", "127.0.0.1");
ASSERT_TRUE(embedded_test_server()->Start());
ASSERT_TRUE(embedded_https_test_server().Start());
StartNewTask();
// Optimization guide uses this histogram to signal initialization in tests.
optimization_guide::RetryForHistogramUntilCountReached(
&histogram_tester_for_init_,
"OptimizationGuide.HintsManager.HintCacheInitialized", 1);
// Simulate the component loading, as the implementation checks it, but the
// actual list is set via the command line.
ASSERT_TRUE(temp_dir_.CreateUniqueTempDir());
optimization_guide::OptimizationHintsComponentUpdateListener::GetInstance()
->MaybeUpdateHintsComponent(
{base::Version("123"),
temp_dir_.GetPath().Append(FILE_PATH_LITERAL("dont_care"))});
content::SetBrowserClientForTesting(&mock_browser_client_);
}
protected:
void StartNewTask() {
auto execution_engine =
std::make_unique<ExecutionEngine>(browser()->profile());
ExecutionEngine* raw_execution_engine = execution_engine.get();
auto event_dispatcher = ui::NewUiEventDispatcher(
actor_keyed_service()->GetActorUiStateManager());
auto task = std::make_unique<ActorTask>(
GetProfile(), std::move(execution_engine), std::move(event_dispatcher));
raw_execution_engine->SetOwner(task.get());
task_id_ = actor_keyed_service()->AddActiveTask(std::move(task));
}
tabs::TabInterface* active_tab() {
return browser()->tab_strip_model()->GetActiveTab();
}
content::WebContents* web_contents() { return active_tab()->GetContents(); }
content::RenderFrameHost* main_frame() {
return web_contents()->GetPrimaryMainFrame();
}
ActorKeyedService* actor_keyed_service() {
return ActorKeyedService::Get(browser()->profile());
}
ActorTask& actor_task() { return *actor_keyed_service()->GetTask(task_id_); }
void ClickTarget(
std::string_view query_selector,
mojom::ActionResultCode expected_code = mojom::ActionResultCode::kOk) {
std::optional<int> dom_node_id =
content::GetDOMNodeId(*main_frame(), query_selector);
ASSERT_TRUE(dom_node_id);
std::unique_ptr<ToolRequest> click =
MakeClickRequest(*main_frame(), dom_node_id.value());
ActResultFuture result;
actor_task().Act(ToRequestList(click), result.GetCallback());
if (expected_code == mojom::ActionResultCode::kOk) {
ExpectOkResult(result);
} else {
ExpectErrorResult(result, expected_code);
}
}
content::test::PrerenderTestHelper& prerender_helper() {
return prerender_helper_;
}
FakeChromeContentBrowserClient& browser_client() {
return mock_browser_client_;
}
private:
TaskId task_id_;
content::test::PrerenderTestHelper prerender_helper_;
base::HistogramTester histogram_tester_for_init_;
base::test::ScopedFeatureList scoped_feature_list_;
FakeChromeContentBrowserClient mock_browser_client_;
base::ScopedTempDir temp_dir_;
};
// The coordinator does not yet handle multi-tab cases. For now,
// while acting on a tab, we override attempts by the page to create new
// tabs, and instead navigate the existing tab.
IN_PROC_BROWSER_TEST_F(ExecutionEngineBrowserTest, ForceSameTabNavigation) {
const GURL url =
embedded_test_server()->GetURL("/actor/target_blank_links.html");
ASSERT_TRUE(content::NavigateToURL(web_contents(), url));
// Check specifically that it's the existing frame that navigates.
content::TestFrameNavigationObserver frame_nav_observer(main_frame());
ClickTarget("#anchorTarget");
frame_nav_observer.Wait();
}
IN_PROC_BROWSER_TEST_F(ExecutionEngineBrowserTest,
ForceSameTabNavigationByScript) {
const GURL url =
embedded_test_server()->GetURL("/actor/target_blank_links.html");
ASSERT_TRUE(content::NavigateToURL(web_contents(), url));
// Check specifically that it's the existing frame that navigates.
content::TestFrameNavigationObserver frame_nav_observer(main_frame());
ClickTarget("#scriptOpen");
frame_nav_observer.Wait();
}
IN_PROC_BROWSER_TEST_F(ExecutionEngineBrowserTest, TwoClicks) {
const GURL url = embedded_test_server()->GetURL("/actor/two_clicks.html");
ASSERT_TRUE(content::NavigateToURL(web_contents(), url));
// Check initial background color is red
EXPECT_EQ("red", EvalJs(web_contents(), "document.body.bgColor"));
// Create a single BrowserAction with two click actions
std::optional<int> button1_id =
content::GetDOMNodeId(*main_frame(), "#button1");
std::optional<int> button2_id =
content::GetDOMNodeId(*main_frame(), "#button2");
ASSERT_TRUE(button1_id);
ASSERT_TRUE(button2_id);
std::unique_ptr<ToolRequest> click1 =
MakeClickRequest(*main_frame(), button1_id.value());
std::unique_ptr<ToolRequest> click2 =
MakeClickRequest(*main_frame(), button2_id.value());
// Execute the action
ActResultFuture result;
actor_task().Act(ToRequestList(click1, click2), result.GetCallback());
ExpectOkResult(result);
// Check background color changed to green
EXPECT_EQ("green", EvalJs(web_contents(), "document.body.bgColor"));
}
IN_PROC_BROWSER_TEST_F(ExecutionEngineBrowserTest, TwoClicksInBackgroundTab) {
const GURL url = embedded_test_server()->GetURL("/actor/two_clicks.html");
ASSERT_TRUE(content::NavigateToURL(web_contents(), url));
// Check initial background color is red
EXPECT_EQ("red", EvalJs(web_contents(), "document.body.bgColor"));
// Store a pointer to the first tab.
content::WebContents* first_tab_contents = web_contents();
auto* tab = browser()->GetActiveTabInterface();
// Create a second tab, which will be in the foreground.
ui_test_utils::NavigateToURLWithDisposition(
browser(), GURL("about:blank"), WindowOpenDisposition::NEW_FOREGROUND_TAB,
ui_test_utils::BROWSER_TEST_WAIT_FOR_LOAD_STOP);
// The first tab should now be in the background.
ASSERT_TRUE(!tab->IsVisible());
// Create a single Actions proto with two click actions on the background tab.
std::optional<int> button1_id = content::GetDOMNodeId(
*first_tab_contents->GetPrimaryMainFrame(), "#button1");
std::optional<int> button2_id = content::GetDOMNodeId(
*first_tab_contents->GetPrimaryMainFrame(), "#button2");
ASSERT_TRUE(button1_id);
ASSERT_TRUE(button2_id);
std::unique_ptr<ToolRequest> click1 = MakeClickRequest(
*first_tab_contents->GetPrimaryMainFrame(), button1_id.value());
std::unique_ptr<ToolRequest> click2 = MakeClickRequest(
*first_tab_contents->GetPrimaryMainFrame(), button2_id.value());
// Execute the actions.
ActResultFuture result;
actor_task().Act(ToRequestList(click1, click2), result.GetCallback());
// Check that the action succeeded.
ExpectOkResult(*result.Get<0>());
// Check background color changed to green in the background tab.
EXPECT_EQ("green", EvalJs(tab->GetContents(), "document.body.bgColor"));
}
IN_PROC_BROWSER_TEST_F(ExecutionEngineBrowserTest, ClickLinkToBlockedSite) {
const GURL start_url = embedded_https_test_server().GetURL(
"example.com", "/actor/blocked_links.html");
const GURL blocked_url = embedded_https_test_server().GetURL(
"blocked.example.com", "/actor/blank.html");
ASSERT_TRUE(content::NavigateToURL(web_contents(), start_url));
EXPECT_TRUE(content::ExecJs(
web_contents(), content::JsReplace("setBlockedSite($1);", blocked_url)));
ClickTarget("#directToBlocked",
mojom::ActionResultCode::kTriggeredNavigationBlocked);
}
// Ensure that the block list is only active while the actor task is in
// progress.
IN_PROC_BROWSER_TEST_F(ExecutionEngineBrowserTest, AllowBlockedSiteWhenPaused) {
const GURL start_url = embedded_https_test_server().GetURL(
"example.com", "/actor/blocked_links.html");
const GURL blocked_url = embedded_https_test_server().GetURL(
"blocked.example.com", "/actor/blank.html");
ASSERT_TRUE(content::NavigateToURL(web_contents(), start_url));
// Arbitrary click to add the tab to the ActorTask.
ClickTarget("h1");
EXPECT_TRUE(content::ExecJs(
web_contents(), content::JsReplace("setBlockedSite($1);", blocked_url)));
// Pause the task as if the user took over. Blocked links should now be
// allowed.
actor_task().Pause(true);
content::TestNavigationManager main_manager(web_contents(), blocked_url);
EXPECT_TRUE(content::ExecJs(
web_contents(), "document.getElementById('directToBlocked').click()"));
ASSERT_TRUE(main_manager.WaitForNavigationFinished());
EXPECT_TRUE(main_manager.was_committed());
EXPECT_TRUE(main_manager.was_successful());
EXPECT_EQ(web_contents()->GetURL(), blocked_url);
}
IN_PROC_BROWSER_TEST_F(ExecutionEngineBrowserTest,
ClickLinkToBlockedSiteWithRedirect) {
const GURL start_url = embedded_https_test_server().GetURL(
"example.com", "/actor/blocked_links.html");
const GURL blocked_url = embedded_https_test_server().GetURL(
"blocked.example.com", "/actor/blank.html");
ASSERT_TRUE(content::NavigateToURL(web_contents(), start_url));
EXPECT_TRUE(content::ExecJs(
web_contents(), content::JsReplace("setBlockedSite($1);", blocked_url)));
ClickTarget("#redirectToBlocked",
mojom::ActionResultCode::kTriggeredNavigationBlocked);
}
IN_PROC_BROWSER_TEST_F(ExecutionEngineBrowserTest, FirstActionOnBlockedSite) {
const GURL start_url = embedded_https_test_server().GetURL(
"blocked.example.com", "/actor/link.html");
const GURL second_url =
embedded_https_test_server().GetURL("example.com", "/actor/blank.html");
ASSERT_TRUE(content::NavigateToURL(web_contents(), start_url));
EXPECT_TRUE(content::ExecJs(web_contents(),
content::JsReplace("setLink($1);", second_url)));
ClickTarget("#link", mojom::ActionResultCode::kUrlBlocked);
// Even though the first action failed, the tab should still be associated
// with the task.
EXPECT_TRUE(
actor_task().GetLastActedTabs().contains(active_tab()->GetHandle()));
}
IN_PROC_BROWSER_TEST_F(ExecutionEngineBrowserTest, PrerenderBlockedSite) {
const GURL start_url = embedded_https_test_server().GetURL(
"example.com", "/actor/blocked_links.html");
const GURL blocked_url = embedded_https_test_server().GetURL(
"blocked.example.com", "/actor/blank.html");
ASSERT_TRUE(content::NavigateToURL(web_contents(), start_url));
EXPECT_TRUE(content::ExecJs(
web_contents(), content::JsReplace("setBlockedSite($1);", blocked_url)));
base::RunLoop loop;
actor_task().AddTab(
active_tab()->GetHandle(),
base::BindLambdaForTesting([&](mojom::ActionResultPtr result) {
EXPECT_TRUE(IsOk(*result));
loop.Quit();
}));
loop.Run();
// While we have an active task, cancel any prerenders which would be to a
// blocked site.
content::test::PrerenderHostObserver prerender_observer(*web_contents(),
blocked_url);
prerender_helper().AddPrerenderAsync(blocked_url);
prerender_observer.WaitForDestroyed();
ClickTarget("#directToBlocked",
mojom::ActionResultCode::kTriggeredNavigationBlocked);
}
IN_PROC_BROWSER_TEST_F(ExecutionEngineBrowserTest,
ExternalProtocolLinkBlocked) {
const GURL start_url = embedded_https_test_server().GetURL(
"example.com", "/actor/external_protocol_links.html");
ASSERT_TRUE(content::NavigateToURL(web_contents(), start_url));
ClickTarget("#mailto", mojom::ActionResultCode::kTriggeredNavigationBlocked);
}
// We need to follow a link which then spawns the external protocol request in
// an iframe to test this. If we launch click the external protocol link
// directly, its caught by the network throttler as seen in the test above. If
// we click a button that creates the iframe request directly, the actor will
// finish the task before ChromeContentBrowserClient has a chance to check for
// the actor task.
IN_PROC_BROWSER_TEST_F(ExecutionEngineBrowserTest,
BackgroundExternalProtocolBlocked) {
const GURL start_url =
embedded_https_test_server().GetURL("example.com", "/actor/link.html");
const GURL second_url = embedded_https_test_server().GetURL(
"example.com", "/actor/external_protocol.html");
ASSERT_TRUE(content::NavigateToURL(web_contents(), start_url));
EXPECT_TRUE(content::ExecJs(web_contents(),
content::JsReplace("setLink($1);", second_url)));
ClickTarget("#link", mojom::ActionResultCode::kOk);
EXPECT_FALSE(browser_client().external_protocol_result().value());
}
IN_PROC_BROWSER_TEST_F(ExecutionEngineBrowserTest, PromptToConfirmDownload) {
ActorKeyedService* actor_service = actor_keyed_service();
int32_t download_id = 123;
// Mocking IPC for browsertest.
// We will run it with a test response from the web client in a UI test.
base::CallbackListSubscription user_confirmation_dialog_subscription =
actor_service->AddRequestToShowUserConfirmationDialogSubscriberCallback(
base::BindLambdaForTesting(
[&](const std::optional<url::Origin>& got_navigation_origin,
const std::optional<int32_t> got_download_id,
ActorKeyedService::UserConfirmationDialogCallback callback) {
// Verify the request is what the IPC expects.
EXPECT_FALSE(got_navigation_origin);
EXPECT_TRUE(got_download_id);
EXPECT_EQ(got_download_id, download_id);
// Send a mock IPC response.
std::move(callback).Run(
webui::mojom::UserConfirmationDialogResponse::New(
webui::mojom::UserConfirmationDialogResult::
NewPermissionGranted(true)));
}));
base::test::TestFuture<webui::mojom::UserConfirmationDialogResponsePtr>
future;
actor_task().GetExecutionEngine()->PromptToConfirmDownload(
download_id, future.GetCallback());
// Verify response was forwarded to the callback correctly.
auto response = future.Take();
EXPECT_FALSE(response->result->is_error_reason());
EXPECT_TRUE(response->result->is_permission_granted());
EXPECT_TRUE(response->result->get_permission_granted());
}
class ExecutionEngineOriginGatingBrowserTest
: public ExecutionEngineBrowserTest,
public testing::WithParamInterface<bool> {
public:
ExecutionEngineOriginGatingBrowserTest() {
scoped_feature_list_.InitWithFeatureState(kGlicCrossOriginNavigationGating,
origin_gating_enabled());
}
bool origin_gating_enabled() { return GetParam(); }
void CreateMockPromptIPCResponse(
std::optional<url::Origin> expected_navigation_origin,
bool permission_granted) {
user_confirmation_dialog_subscription_ =
actor_keyed_service()
->AddRequestToShowUserConfirmationDialogSubscriberCallback(
base::BindLambdaForTesting(
[expected_navigation_origin, permission_granted](
const std::optional<url::Origin>& got_navigation_origin,
const std::optional<int32_t> got_download_id,
ActorKeyedService::UserConfirmationDialogCallback
callback) {
EXPECT_EQ(got_navigation_origin,
expected_navigation_origin);
EXPECT_FALSE(got_download_id);
// Send a mock IPC response.
std::move(callback).Run(
webui::mojom::UserConfirmationDialogResponse::New(
webui::mojom::UserConfirmationDialogResult::
NewPermissionGranted(permission_granted)));
}));
}
private:
base::test::ScopedFeatureList scoped_feature_list_;
base::CallbackListSubscription user_confirmation_dialog_subscription_;
};
std::string EncodeURI(const std::string& component) {
url::RawCanonOutputT<char> encoded;
url::EncodeURIComponent(component, &encoded);
return std::string(encoded.view());
}
IN_PROC_BROWSER_TEST_P(ExecutionEngineOriginGatingBrowserTest,
GateCrossOriginNavigations_Denied) {
const GURL start_url =
embedded_https_test_server().GetURL("example.com", "/actor/link.html");
const GURL second_url =
embedded_https_test_server().GetURL("foo.com", "/actor/blank.html");
CreateMockPromptIPCResponse(url::Origin::Create(second_url),
/*permission_granted=*/false);
ASSERT_TRUE(content::NavigateToURL(web_contents(), start_url));
EXPECT_TRUE(content::ExecJs(web_contents(),
content::JsReplace("setLink($1);", start_url)));
ClickTarget("#link", mojom::ActionResultCode::kOk);
EXPECT_TRUE(content::ExecJs(web_contents(),
content::JsReplace("setLink($1);", second_url)));
ClickTarget("#link",
origin_gating_enabled()
? mojom::ActionResultCode::kTriggeredNavigationBlocked
: mojom::ActionResultCode::kOk);
}
IN_PROC_BROWSER_TEST_P(ExecutionEngineOriginGatingBrowserTest,
GateCrossOriginNavigations_Granted) {
const GURL start_url =
embedded_https_test_server().GetURL("example.com", "/actor/link.html");
const GURL second_url =
embedded_https_test_server().GetURL("foo.com", "/actor/blank.html");
CreateMockPromptIPCResponse(url::Origin::Create(second_url),
/*permission_granted=*/true);
ASSERT_TRUE(content::NavigateToURL(web_contents(), start_url));
EXPECT_TRUE(content::ExecJs(web_contents(),
content::JsReplace("setLink($1);", start_url)));
ClickTarget("#link", mojom::ActionResultCode::kOk);
EXPECT_TRUE(content::ExecJs(web_contents(),
content::JsReplace("setLink($1);", second_url)));
ClickTarget("#link", mojom::ActionResultCode::kOk);
}
IN_PROC_BROWSER_TEST_P(ExecutionEngineOriginGatingBrowserTest,
OriginGatingNavigateAction) {
// This test is not meaningful if origin gating is disabled.
if (!origin_gating_enabled()) {
return;
}
const GURL start_url =
embedded_https_test_server().GetURL("foo.com", "/actor/blank.html");
const GURL cross_origin_url =
embedded_https_test_server().GetURL("bar.com", "/actor/blank.html");
const GURL link_page_url = embedded_https_test_server().GetURL(
"foo.com", base::StrCat({"/actor/link_full_page.html?href=",
EncodeURI(cross_origin_url.spec())}));
// Mock IPC response waill always reject navigation.
CreateMockPromptIPCResponse(url::Origin::Create(cross_origin_url),
/*permission_granted=*/false);
// Start on foo.com.
ASSERT_TRUE(content::NavigateToURL(web_contents(), start_url));
// Navigate to bar.com.
std::unique_ptr<ToolRequest> navigate_x_origin =
MakeNavigateRequest(*active_tab(), cross_origin_url.spec());
// Navigate to foo.com page with a link to bar.com.
std::unique_ptr<ToolRequest> navigate_to_link_page =
MakeNavigateRequest(*active_tab(), link_page_url.spec());
// Clicks on full-page link to bar.com.
std::unique_ptr<ToolRequest> click_link =
MakeClickRequest(*active_tab(), gfx::Point(1, 1));
ActResultFuture result1;
actor_task().Act(
ToRequestList(navigate_x_origin, navigate_to_link_page, click_link),
result1.GetCallback());
ExpectOkResult(result1);
// Test that navigation allowlist is not persisted across separate tasks.
auto previous_id = actor_task().id();
actor_keyed_service()->ResetForTesting();
StartNewTask();
ASSERT_NE(previous_id, actor_task().id());
// Start on link page on foo.com.
ASSERT_TRUE(content::NavigateToURL(web_contents(), link_page_url));
// Click on full-page link to bar.com only.
std::unique_ptr<ToolRequest> click_link_only =
MakeClickRequest(*active_tab(), gfx::Point(1, 1));
ActResultFuture result2;
actor_task().Act(ToRequestList(click_link_only), result2.GetCallback());
// Expect the navigation to be blocked by origin gating.
ExpectErrorResult(result2,
mojom::ActionResultCode::kTriggeredNavigationBlocked);
}
INSTANTIATE_TEST_SUITE_P(All,
ExecutionEngineOriginGatingBrowserTest,
testing::Bool());
} // namespace
} // namespace actor