#include <string_view>
#include "base/base64.h"
#include "base/json/json_writer.h"
#include "base/strings/string_number_conversions.h"
#include "base/test/bind.h"
#include "base/test/gmock_expected_support.h"
#include "base/test/run_until.h"
#include "base/test/test_future.h"
#include "base/types/expected_macros.h"
#include "chrome/browser/actor/actor_features.h"
#include "chrome/browser/actor/actor_keyed_service.h"
#include "chrome/browser/actor/actor_task.h"
#include "chrome/browser/actor/actor_test_util.h"
#include "chrome/browser/actor/browser_action_util.h"
#include "chrome/browser/glic/host/glic.mojom.h"
#include "chrome/browser/glic/test_support/interactive_glic_test.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/common/actor_webui.mojom.h"
#include "chrome/common/chrome_features.h"
#include "chrome/common/webui_url_constants.h"
#include "components/optimization_guide/proto/features/actions_data.pb.h"
#include "components/sessions/core/session_id.h"
#include "content/public/browser/render_frame_host.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
using ::base::test::TestFuture;
using ::optimization_guide::proto::ClickAction;
using ::optimization_guide::proto::TabObservation;
using ::testing::Property;
namespace mojo {
template <>
struct TypeConverter<base::Value, glic::mojom::GetTabContextOptions> {
static base::Value Convert(const glic::mojom::GetTabContextOptions in) {
base::Value raw_out(base::Value::Type::DICT);
base::Value::Dict& out = raw_out.GetDict();
out.Set("includeInnerText", in.include_inner_text);
out.Set("innerTextBytesLimit", static_cast<int>(in.inner_text_bytes_limit));
out.Set("includeViewportScreenshot", in.include_viewport_screenshot);
out.Set("includeAnnotatedPageContent", in.include_annotated_page_content);
out.Set("maxMetaTags", static_cast<int>(in.max_meta_tags));
out.Set("includePdf", in.include_pdf);
out.Set("pdfSizeLimit", static_cast<int>(in.pdf_size_limit));
out.Set("annotatedPageContentMode",
static_cast<int>(in.annotated_page_content_mode));
return raw_out;
}
};
}
namespace actor {
namespace {
class ScopedMockTabObservationResult {
public:
explicit ScopedMockTabObservationResult(
base::RepeatingCallback<TabObservation::TabObservationResult()>
callback) {
SetTabObservationResultOverrideForTesting(callback);
}
~ScopedMockTabObservationResult() {
SetTabObservationResultOverrideForTesting(
base::RepeatingCallback<TabObservation::TabObservationResult()>());
}
};
MATCHER_P(HasResultCode, expected_code, "") {
return arg.action_result() == static_cast<int32_t>(expected_code);
}
MATCHER_P(ErrorHasSubstr, expected_substring, "") {
return testing::Matches(
base::test::ErrorIs(testing::HasSubstr(expected_substring)))(arg);
}
base::expected<base::Value, std::string> ToExpected(
content::EvalJsResult result) {
if (!result.is_ok()) {
return base::unexpected(result.ExtractError());
}
return base::ok(std::move(result).TakeValue());
}
class ActorFunctionalBrowserTest : public glic::test::InteractiveGlicTest {
public:
ActorFunctionalBrowserTest() {
scoped_feature_list_.InitWithFeatures(
{actor::kActorBindCreatedTabToTask},
{features::kGlicMultiInstance});
}
~ActorFunctionalBrowserTest() override = default;
protected:
void SetUpOnMainThread() override {
glic::test::InteractiveGlicTest::SetUpOnMainThread();
actor_keyed_service()->GetPolicyChecker().SetActOnWebForTesting(true);
RunTestSequence(OpenGlicWindow(GlicWindowMode::kDetached));
}
content::WebContents* web_contents() {
return browser()->tab_strip_model()->GetActiveWebContents();
}
tabs::TabInterface* active_tab() {
return browser()->tab_strip_model()->GetActiveTab();
}
actor::ActorKeyedService* actor_keyed_service() {
return actor::ActorKeyedService::Get(browser()->profile());
}
base::CallbackListSubscription CreateTaskCompletionSubscription(
TaskId for_task_id,
TestFuture<ActorTask::State>& future) {
return actor_keyed_service()->AddTaskStateChangedCallback(
base::BindLambdaForTesting([&future, for_task_id](
TaskId task_id, ActorTask::State state) {
if (task_id == for_task_id && ActorTask::IsCompletedState(state)) {
future.SetValue(state);
}
}));
}
base::expected<base::Value, std::string> EvalJsInGlic(
const std::string_view script) {
return ToExpected(content::EvalJs(FindGlicGuestMainFrame(), script));
}
base::expected<int, std::string> EvalJsInGlicForInt(
const std::string_view script) {
ASSIGN_OR_RETURN(base::Value js_result, EvalJsInGlic(script));
if (std::optional<int> result = js_result.GetIfInt()) {
return *result;
}
return base::unexpected("Expected an integer value from JavaScript.");
}
base::expected<std::string, std::string> EvalJsInGlicForString(
const std::string_view script) {
ASSIGN_OR_RETURN(base::Value js_result, EvalJsInGlic(script));
if (std::string* result = js_result.GetIfString()) {
return base::ok(*result);
}
return base::unexpected("Expected a string value from JavaScript.");
}
template <typename ProtoType>
base::expected<ProtoType, std::string> EvalJsInGlicForBase64Proto(
std::string_view script,
std::string_view proto_name) {
ASSIGN_OR_RETURN(base::Value js_result, EvalJsInGlic(script));
const std::string* result_base64 = js_result.GetIfString();
if (!result_base64) {
return base::unexpected("Expected a string value from JavaScript.");
}
std::string decoded_result;
ProtoType proto_result;
if (!base::Base64Decode(*result_base64, &decoded_result)) {
return base::unexpected(
base::StrCat({"Failed to Base64-decode the result for ", proto_name,
" from JavaScript."}));
}
if (!proto_result.ParseFromString(decoded_result)) {
return base::unexpected(base::StrCat(
{"Failed to parse ", proto_name, " proto from the decoded result."}));
}
return base::ok(proto_result);
}
base::expected<TaskId, std::string> CreateTask(
webui::mojom::TaskOptionsPtr options = nullptr) {
std::string title;
if (options && options->title) {
title = content::JsReplace("{title: $1}", *options->title);
}
std::string script =
base::StrCat({"window.client.browser.createTask(", title, ");"});
ASSIGN_OR_RETURN(int task_id_int, EvalJsInGlicForInt(script));
return base::ok(actor::TaskId(task_id_int));
}
[[nodiscard]] optimization_guide::proto::ActionsResult PerformActions(
const optimization_guide::proto::Actions& actions) {
std::string serialized_actions;
CHECK(actions.SerializeToString(&serialized_actions));
const std::string proto_base64 = base::Base64Encode(serialized_actions);
const std::string script = R"(
(async (protoAsBase64) => {
// Manually decode the base64 string into a Uint8Array.
const binaryString = atob(protoAsBase64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// Call performActions with the ArrayBuffer.
const resultBuffer =
await window.client.browser.performActions(bytes.buffer);
// Manually encode the resulting ArrayBuffer back to a base64 string
// to return to C++.
const resultBytes = new Uint8Array(resultBuffer);
let resultBinaryString = '';
for (let i = 0; i < resultBytes.length; i++) {
resultBinaryString += String.fromCharCode(resultBytes[i]);
}
return btoa(resultBinaryString);
})($1)
)";
const std::string full_script = content::JsReplace(script, proto_base64);
base::expected<optimization_guide::proto::ActionsResult, std::string>
result_expected = EvalJsInGlicForBase64Proto<
optimization_guide::proto::ActionsResult>(
full_script, "optimization_guide::proto::ActionsResult");
CHECK(result_expected.has_value()) << result_expected.error();
return result_expected.value();
}
void StopActorTask(TaskId task_id,
glic::mojom::ActorTaskStopReason stop_reason) {
std::string script = R"(
(async (taskId, stopReason) => {
await window.client.browser.stopActorTask(taskId, stopReason);
})($1, $2)
)";
const std::string full_script = content::JsReplace(
script, task_id.value(), static_cast<int>(stop_reason));
EXPECT_OK(EvalJsInGlic(full_script));
}
void PauseActorTask(TaskId task_id,
glic::mojom::ActorTaskPauseReason pause_reason =
glic::mojom::ActorTaskPauseReason::kPausedByModel,
tabs::TabHandle tab_handle = tabs::TabHandle::Null()) {
base::expected<base::Value, std::string> pause_task_js_result = [&]() {
if (tab_handle == tabs::TabHandle::Null()) {
std::string script = "window.client.browser.pauseActorTask($1, $2);";
return EvalJsInGlic(content::JsReplace(script, task_id.value(),
static_cast<int>(pause_reason)));
} else {
std::string script =
"window.client.browser.pauseActorTask($1, $2, $3);";
return EvalJsInGlic(content::JsReplace(
script, task_id.value(), static_cast<int>(pause_reason),
base::NumberToString(tab_handle.raw_value())));
}
}();
EXPECT_TRUE(pause_task_js_result.has_value())
<< "pauseActorTask() failed: " << pause_task_js_result.error();
}
base::expected<mojom::ActionResultCode, std::string> ResumeActorTask(
TaskId task_id,
base::Value context_options) {
ASSIGN_OR_RETURN(
std::string context_options_json,
base::WriteJson(context_options.GetDict()), [&]() {
return std::string("Failed to serialize context options to JSON.");
});
std::string script = base::StringPrintf(
"(async () => {"
" const result = await window.client.browser.resumeActorTask(%d, %s);"
" return result.actionResult;"
"})()",
task_id.value(), context_options_json.c_str());
ASSIGN_OR_RETURN(int action_result_int, EvalJsInGlicForInt(script));
return base::ok(static_cast<mojom::ActionResultCode>(action_result_int));
}
base::expected<tabs::TabHandle, std::string> CreateActorTab(
TaskId task_id,
std::optional<bool> open_in_background,
std::optional<std::string> initiator_tab_id,
std::optional<std::string> initiator_window_id) {
static constexpr std::string_view kCreateActorTabScript = R"(
(async (taskId, openInBackground, initiatorTabId, initiatorWindowId) => {
const options = {};
if (openInBackground !== null) {
options.openInBackground = openInBackground;
}
if (initiatorTabId !== null) {
options.initiatorTabId = initiatorTabId;
}
if (initiatorWindowId !== null) {
options.initiatorWindowId = initiatorWindowId;
}
const tabData = await window.client.browser.createActorTab(
taskId, options);
// "NO_TAB_ID" triggers the parsing error on C++ side.
return tabData ? tabData.tabId : "NO_TAB_ID";
})($1, $2, $3, $4)
)";
base::expected<std::string, std::string> result =
EvalJsInGlicForString(content::JsReplace(
kCreateActorTabScript, task_id.value(),
open_in_background ? base::Value(*open_in_background)
: base::Value(),
initiator_tab_id ? base::Value(*initiator_tab_id) : base::Value(),
initiator_window_id ? base::Value(*initiator_window_id)
: base::Value()));
if (!result.has_value()) {
return base::unexpected(result.error());
}
int tab_id;
if (!base::StringToInt(result.value(), &tab_id)) {
return base::unexpected(base::StringPrintf(
"Failed to parse tab ID %s from TS API.", result.value().c_str()));
}
return tabs::TabHandle(tab_id);
}
private:
base::test::ScopedFeatureList scoped_feature_list_;
};
IN_PROC_BROWSER_TEST_F(ActorFunctionalBrowserTest,
CreateTask_Navigate_StopTask) {
ASSERT_OK_AND_ASSIGN(TaskId task_id, CreateTask());
EXPECT_NE(task_id, TaskId());
TestFuture<ActorTask::State> task_completion_state;
base::CallbackListSubscription subscription =
CreateTaskCompletionSubscription(task_id, task_completion_state);
const GURL target_url =
embedded_test_server()->GetURL("/actor/blank.html?target");
optimization_guide::proto::Actions action =
MakeNavigate(active_tab()->GetHandle(), target_url.spec());
action.set_task_id(task_id.value());
EXPECT_THAT(PerformActions(action),
HasResultCode(mojom::ActionResultCode::kOk));
EXPECT_EQ(target_url, web_contents()->GetURL());
StopActorTask(task_id, glic::mojom::ActorTaskStopReason::kTaskComplete);
EXPECT_EQ(ActorTask::State::kFinished, task_completion_state.Get())
<< "Task " << task_id << " did not reach kFinished state.";
}
IN_PROC_BROWSER_TEST_F(ActorFunctionalBrowserTest, CreateTask_Click_StopTask) {
const GURL initial_url = embedded_test_server()->GetURL("/actor/link.html");
const GURL target_url = embedded_test_server()->GetURL("/actor/blank.html");
ASSERT_TRUE(content::NavigateToURL(web_contents(), initial_url));
EXPECT_TRUE(content::ExecJs(web_contents(),
content::JsReplace("setLink($1);", target_url)));
ASSERT_OK_AND_ASSIGN(TaskId task_id, CreateTask());
EXPECT_NE(task_id, TaskId());
TestFuture<ActorTask::State> task_completion_state;
base::CallbackListSubscription subscription =
CreateTaskCompletionSubscription(task_id, task_completion_state);
std::optional<int> link_node_id =
content::GetDOMNodeId(*web_contents()->GetPrimaryMainFrame(), "#link");
optimization_guide::proto::Actions actions =
MakeClick(*web_contents()->GetPrimaryMainFrame(), link_node_id.value(),
ClickAction::LEFT, ClickAction::SINGLE);
actions.set_task_id(task_id.value());
EXPECT_THAT(PerformActions(actions),
HasResultCode(mojom::ActionResultCode::kOk));
EXPECT_EQ(target_url, web_contents()->GetURL());
StopActorTask(task_id, glic::mojom::ActorTaskStopReason::kTaskComplete);
EXPECT_EQ(ActorTask::State::kFinished, task_completion_state.Get())
<< "Task " << task_id << " did not reach kFinished state.";
}
IN_PROC_BROWSER_TEST_F(ActorFunctionalBrowserTest, PauseAndResumeCreatedTask) {
ASSERT_OK_AND_ASSIGN(TaskId task_id, CreateTask());
EXPECT_NE(task_id, TaskId());
TestFuture<ActorTask::State> task_completion_state;
base::CallbackListSubscription completion_subscription =
CreateTaskCompletionSubscription(task_id, task_completion_state);
PauseActorTask(task_id, glic::mojom::ActorTaskPauseReason::kPausedByUser,
active_tab()->GetHandle());
ASSERT_TRUE(base::test::RunUntil([&]() {
return actor_keyed_service()->GetTask(task_id)->GetState() ==
ActorTask::State::kPausedByUser;
})) << "Timed out waiting for task "
<< task_id << " to pause.";
const GURL target_url =
embedded_test_server()->GetURL("/actor/blank.html?target");
optimization_guide::proto::Actions action =
MakeNavigate(active_tab()->GetHandle(), target_url.spec());
action.set_task_id(task_id.value());
EXPECT_THAT(PerformActions(action),
HasResultCode(mojom::ActionResultCode::kTaskPaused));
EXPECT_NE(target_url, web_contents()->GetURL());
EXPECT_THAT(
ResumeActorTask(task_id,
glic::mojom::GetTabContextOptions().To<base::Value>())
.value(),
testing::Eq(mojom::ActionResultCode::kOk));
EXPECT_EQ(ActorTask::State::kReflecting,
actor_keyed_service()->GetTask(task_id)->GetState());
EXPECT_THAT(PerformActions(action),
HasResultCode(mojom::ActionResultCode::kOk));
EXPECT_EQ(target_url, web_contents()->GetURL());
StopActorTask(task_id, glic::mojom::ActorTaskStopReason::kTaskComplete);
EXPECT_EQ(ActorTask::State::kFinished, task_completion_state.Get())
<< "Task " << task_id << " did not reach kFinished state.";
}
IN_PROC_BROWSER_TEST_F(ActorFunctionalBrowserTest, PauseAndResumeInvalidTask) {
TaskId invalid_task_id = TaskId(12345);
ASSERT_EQ(actor_keyed_service()->GetTask(invalid_task_id), nullptr);
PauseActorTask(invalid_task_id,
glic::mojom::ActorTaskPauseReason::kPausedByUser,
active_tab()->GetHandle());
EXPECT_THAT(
ResumeActorTask(invalid_task_id,
glic::mojom::GetTabContextOptions().To<base::Value>()),
ErrorHasSubstr("resumeActorTask failed: No such task"));
}
IN_PROC_BROWSER_TEST_F(ActorFunctionalBrowserTest, PauseAndResumeInactiveTask) {
ASSERT_OK_AND_ASSIGN(TaskId task_id, CreateTask());
EXPECT_NE(task_id, TaskId());
TestFuture<ActorTask::State> task_completion_state;
base::CallbackListSubscription completion_subscription =
CreateTaskCompletionSubscription(task_id, task_completion_state);
StopActorTask(task_id, glic::mojom::ActorTaskStopReason::kTaskComplete);
EXPECT_EQ(ActorTask::State::kFinished, task_completion_state.Get())
<< "Task " << task_id << " did not reach kFinished state.";
PauseActorTask(task_id, glic::mojom::ActorTaskPauseReason::kPausedByUser,
active_tab()->GetHandle());
EXPECT_THAT(
ResumeActorTask(task_id,
glic::mojom::GetTabContextOptions().To<base::Value>()),
ErrorHasSubstr("resumeActorTask failed: No such task"));
}
IN_PROC_BROWSER_TEST_F(ActorFunctionalBrowserTest,
PerformActionsOnCrashedTabReloadsTab) {
const GURL& initial_url = web_contents()->GetLastCommittedURL();
ASSERT_OK_AND_ASSIGN(TaskId task_id, CreateTask());
ASSERT_NE(task_id, TaskId());
TestFuture<ActorTask::State> task_completion_state;
base::CallbackListSubscription subscription =
CreateTaskCompletionSubscription(task_id, task_completion_state);
content::CrashTab(web_contents());
::optimization_guide::proto::Actions action =
MakeClick(active_tab()->GetHandle(), gfx::Point(1, 1),
::optimization_guide::proto::ClickAction::LEFT,
::optimization_guide::proto::ClickAction::SINGLE);
action.set_task_id(task_id.value());
content::TestNavigationManager reload_observer(web_contents(), initial_url);
EXPECT_THAT(PerformActions(action),
HasResultCode(mojom::ActionResultCode::kRendererCrashed));
EXPECT_TRUE(reload_observer.WaitForNavigationFinished());
EXPECT_FALSE(web_contents()->IsCrashed());
}
IN_PROC_BROWSER_TEST_F(ActorFunctionalBrowserTest,
RetryFailedContextFetchAfterPerformActions) {
ASSERT_OK_AND_ASSIGN(TaskId task_id, CreateTask());
ASSERT_NE(task_id, TaskId());
::optimization_guide::proto::Actions action =
MakeClick(active_tab()->GetHandle(), gfx::Point(1, 1),
::optimization_guide::proto::ClickAction::LEFT,
::optimization_guide::proto::ClickAction::SINGLE);
action.set_task_id(task_id.value());
int num_calls = 0;
ScopedMockTabObservationResult mock_result(base::BindLambdaForTesting([&]() {
++num_calls;
if (num_calls == 1) {
return TabObservation::TAB_OBSERVATION_PAGE_CONTEXT_NOT_ELIGIBLE;
}
return TabObservation::TAB_OBSERVATION_OK;
}));
EXPECT_THAT(PerformActions(action),
HasResultCode(mojom::ActionResultCode::kOk));
EXPECT_EQ(num_calls, 2);
}
IN_PROC_BROWSER_TEST_F(ActorFunctionalBrowserTest,
FailedContextFetchOnlyRetriesOnce) {
ASSERT_OK_AND_ASSIGN(TaskId task_id, CreateTask());
ASSERT_NE(task_id, TaskId());
::optimization_guide::proto::Actions action =
MakeClick(active_tab()->GetHandle(), gfx::Point(1, 1),
::optimization_guide::proto::ClickAction::LEFT,
::optimization_guide::proto::ClickAction::SINGLE);
action.set_task_id(task_id.value());
int num_calls = 0;
ScopedMockTabObservationResult mock_result(base::BindLambdaForTesting([&]() {
++num_calls;
return TabObservation::TAB_OBSERVATION_PAGE_CONTEXT_NOT_ELIGIBLE;
}));
optimization_guide::proto::ActionsResult result = PerformActions(action);
EXPECT_THAT(result, HasResultCode(mojom::ActionResultCode::kOk));
ASSERT_EQ(result.tabs_size(), 1);
ASSERT_TRUE(result.tabs().at(0).has_result());
EXPECT_EQ(result.tabs().at(0).result(),
TabObservation::TAB_OBSERVATION_PAGE_CONTEXT_NOT_ELIGIBLE);
EXPECT_EQ(num_calls, 2);
}
class ActorFunctionalBrowserTestCreateActorTab
: public ActorFunctionalBrowserTest,
public ::testing::WithParamInterface<GURL> {
public:
ActorFunctionalBrowserTestCreateActorTab() = default;
~ActorFunctionalBrowserTestCreateActorTab() override = default;
GURL GetInitiatorTabUrl() { return GetParam(); }
};
IN_PROC_BROWSER_TEST_P(ActorFunctionalBrowserTestCreateActorTab,
CreateActorTab) {
ASSERT_TRUE(content::NavigateToURL(web_contents(), GetInitiatorTabUrl()));
ASSERT_EQ(browser()->tab_strip_model()->count(), 1u);
SessionID initiator_window_id = browser()->session_id();
tabs::TabHandle initiator_tab = active_tab()->GetHandle();
base::expected<TaskId, std::string> task_id = CreateTask();
ASSERT_TRUE(task_id.has_value()) << task_id.error();
base::expected<tabs::TabHandle, std::string> new_tab_handler =
CreateActorTab(task_id.value(), false,
base::ToString(initiator_tab.raw_value()),
base::ToString(initiator_window_id.id()));
ASSERT_TRUE(new_tab_handler.has_value()) << new_tab_handler.error();
EXPECT_TRUE(actor_keyed_service()
->GetTask(task_id.value())
->GetTabs()
.contains(new_tab_handler.value()));
}
INSTANTIATE_TEST_SUITE_P(
,
ActorFunctionalBrowserTestCreateActorTab,
::testing::Values(GURL(chrome::kChromeUINewTabURL),
GURL(url::kAboutBlankURL)));
}
}