blob: d1c85e4a2ba660f47ab66088ae4bed0485096168 [file] [edit]
// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "third_party/blink/renderer/modules/content_extraction/ai_page_content_agent.h"
#include <cstddef>
#include <initializer_list>
#include <limits>
#include <optional>
#include <string>
#include "base/functional/bind.h"
#include "base/logging.h"
#include "base/run_loop.h"
#include "base/strings/stringprintf.h"
#include "base/test/bind.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/test/mock_log.h"
#include "base/trace_event/trace_event.h"
#include "mojo/public/cpp/base/big_buffer.h"
#include "mojo/public/cpp/test_support/test_utils.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/common/frame/frame_ad_evidence.h"
#include "third_party/blink/public/common/input/web_mouse_event.h"
#include "third_party/blink/public/mojom/content_extraction/ai_page_content.mojom.h"
#include "third_party/blink/public/web/web_script_source.h"
#include "third_party/blink/renderer/bindings/core/v8/v8_binding_for_testing.h"
#include "third_party/blink/renderer/core/accessibility/ax_context.h"
#include "third_party/blink/renderer/core/accessibility/ax_object_cache.h"
#include "third_party/blink/renderer/core/css/style_engine.h"
#include "third_party/blink/renderer/core/dom/document.h"
#include "third_party/blink/renderer/core/dom/document_lifecycle.h"
#include "third_party/blink/renderer/core/dom/dom_node_ids.h"
#include "third_party/blink/renderer/core/editing/editing_utilities.h"
#include "third_party/blink/renderer/core/frame/frame_test_helpers.h"
#include "third_party/blink/renderer/core/frame/local_frame.h"
#include "third_party/blink/renderer/core/frame/local_frame_view.h"
#include "third_party/blink/renderer/core/frame/settings.h"
#include "third_party/blink/renderer/core/frame/visual_viewport.h"
#include "third_party/blink/renderer/core/html/forms/html_input_element.h"
#include "third_party/blink/renderer/core/html/html_collection.h"
#include "third_party/blink/renderer/core/html/html_iframe_element.h"
#include "third_party/blink/renderer/core/html/html_image_element.h"
#include "third_party/blink/renderer/core/input/event_handler.h"
#include "third_party/blink/renderer/core/layout/hit_test_location.h"
#include "third_party/blink/renderer/core/layout/hit_test_request.h"
#include "third_party/blink/renderer/core/layout/hit_test_result.h"
#include "third_party/blink/renderer/core/layout/layout_box_model_object.h"
#include "third_party/blink/renderer/core/layout/layout_object.h"
#include "third_party/blink/renderer/core/layout/layout_view.h"
#include "third_party/blink/renderer/core/layout/map_coordinates_flags.h"
#include "third_party/blink/renderer/core/page/chrome_client.h"
#include "third_party/blink/renderer/core/scroll/scrollable_area.h"
#include "third_party/blink/renderer/core/style/computed_style.h"
#include "third_party/blink/renderer/core/testing/page_test_base.h"
#include "third_party/blink/renderer/core/view_transition/view_transition_supplement.h"
#include "third_party/blink/renderer/modules/accessibility/ax_object_cache_impl.h"
#include "third_party/blink/renderer/platform/bindings/exception_state.h"
#include "third_party/blink/renderer/platform/bindings/script_state.h"
#include "third_party/blink/renderer/platform/geometry/physical_offset.h"
#include "third_party/blink/renderer/platform/graphics/visual_rect_flags.h"
#include "third_party/blink/renderer/platform/testing/runtime_enabled_features_test_helpers.h"
#include "third_party/blink/renderer/platform/testing/task_environment.h"
#include "third_party/blink/renderer/platform/testing/unit_test_helpers.h"
#include "third_party/blink/renderer/platform/testing/url_test_helpers.h"
#include "third_party/blink/renderer/platform/wtf/text/atomic_string.h"
#include "third_party/blink/renderer/platform/wtf/vector.h"
#include "ui/accessibility/ax_action_data.h"
#include "ui/accessibility/ax_mode.h"
#include "ui/display/screen_info.h"
#include "ui/gfx/geometry/point_f.h"
#include "ui/gfx/geometry/quad_f.h"
#include "ui/gfx/geometry/rect_conversions.h"
namespace blink {
using ClickabilityReason = mojom::blink::AIPageContentClickabilityReason;
using InteractionDisabledReason =
mojom::blink::AIPageContentInteractionDisabledReason;
namespace {
constexpr gfx::Size kWindowSize{1000, 1000};
constexpr char kSmallImage[] =
"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/"
"2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEB"
"EQCgwSExIQEw8QEBD/"
"2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB"
"AQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/"
"8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/"
"8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2J"
"yggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDh"
"IWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+"
"Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/"
"8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnL"
"RChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g"
"oOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uP"
"k5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD+/iiiigD/2Q==";
class AIPageContentAgentTest : public testing::Test {
public:
AIPageContentAgentTest() = default;
~AIPageContentAgentTest() override = default;
void SetUp() override {
helper_.InitializeWithSettings(&UpdateWebSettings);
helper_.Resize(kWindowSize);
helper_.LoadAhem();
ASSERT_TRUE(helper_.LocalMainFrame());
}
void TearDown() override {
url_test_helpers::UnregisterAllURLsAndClearMemoryCache();
}
void LoadAhem() {
helper_.LoadAhem();
test::RunPendingTasks();
helper_.LocalMainFrame()
->GetFrame()
->GetDocument()
->View()
->UpdateAllLifecyclePhasesForTest();
}
void CheckListItemWithText(const mojom::blink::AIPageContentNode& node,
const String& expected_text) {
const auto& attributes = *node.content_attributes;
EXPECT_EQ(attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kListItem);
EXPECT_EQ(node.children_nodes.size(), 1u);
CheckTextNode(*node.children_nodes[0], expected_text);
}
void CheckTextNode(const mojom::blink::AIPageContentNode& node,
const String& expected_text) {
const auto& attributes = *node.content_attributes;
EXPECT_EQ(attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kText);
ASSERT_TRUE(attributes.text_info);
EXPECT_EQ(attributes.text_info->text_content, expected_text);
}
bool TreeContainsTextSubstring(const mojom::blink::AIPageContentNode& node,
const String& substring) {
// Many tests only care that sensitive content does (or does not) appear
// anywhere in the extracted tree. Implement a small recursive helper to
// reduce coupling to the exact tree structure.
//
// Note: we intentionally check substrings rather than exact matches because
// extracted text nodes sometimes include newlines inserted by layout.
const auto& attributes = *node.content_attributes;
if (attributes.attribute_type ==
mojom::blink::AIPageContentAttributeType::kText &&
attributes.text_info &&
attributes.text_info->text_content.contains(substring)) {
return true;
}
for (const auto& child : node.children_nodes) {
if (TreeContainsTextSubstring(*child, substring)) {
return true;
}
}
return false;
}
const mojom::blink::AIPageContentNode* FindFormControlNodeWithTextSubstring(
const mojom::blink::AIPageContentNode& node,
const String& substring) {
const auto& attributes = *node.content_attributes;
if (attributes.attribute_type ==
mojom::blink::AIPageContentAttributeType::kFormControl &&
TreeContainsTextSubstring(node, substring)) {
return &node;
}
for (const auto& child : node.children_nodes) {
if (const auto* match =
FindFormControlNodeWithTextSubstring(*child, substring)) {
return match;
}
}
return nullptr;
}
void CheckTextSize(const mojom::blink::AIPageContentNode& node,
mojom::blink::AIPageContentTextSize expected_text_size) {
const auto& attributes = *node.content_attributes;
ASSERT_TRUE(attributes.text_info);
EXPECT_EQ(attributes.text_info->text_style->text_size, expected_text_size);
}
void CheckTextEmphasis(const mojom::blink::AIPageContentNode& node,
bool expected_has_emphasis) {
const auto& attributes = *node.content_attributes;
ASSERT_TRUE(attributes.text_info);
EXPECT_EQ(attributes.text_info->text_style->has_emphasis,
expected_has_emphasis);
}
void CheckTextColor(const mojom::blink::AIPageContentNode& node,
RGBA32 expected_color) {
const auto& attributes = *node.content_attributes;
ASSERT_TRUE(attributes.text_info);
EXPECT_EQ(attributes.text_info->text_style->color, expected_color);
}
void CheckImageNode(const mojom::blink::AIPageContentNode& node,
const String& expected_caption,
const KURL& expected_url) {
const auto& attributes = *node.content_attributes;
EXPECT_EQ(attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kImage);
ASSERT_TRUE(attributes.image_info);
EXPECT_EQ(attributes.image_info->image_caption, expected_caption);
EXPECT_EQ(attributes.image_info->url, expected_url);
}
void CheckAnchorNode(
const mojom::blink::AIPageContentNode& node,
const blink::KURL& expected_url,
const Vector<mojom::blink::AIPageContentAnchorRel>& expected_rels) {
const auto& attributes = *node.content_attributes;
EXPECT_EQ(attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kAnchor);
ASSERT_TRUE(attributes.anchor_data);
EXPECT_EQ(attributes.anchor_data->url, expected_url);
ASSERT_EQ(attributes.anchor_data->rel.size(), expected_rels.size());
for (wtf_size_t i = 0; i < expected_rels.size(); ++i) {
EXPECT_EQ(attributes.anchor_data->rel[i], expected_rels[i]);
}
}
void CheckTableNode(const mojom::blink::AIPageContentNode& node,
String expected_table_name = String()) {
const auto& attributes = *node.content_attributes;
EXPECT_EQ(attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kTable);
ASSERT_TRUE(attributes.table_data);
if (!expected_table_name.IsNull()) {
EXPECT_EQ(attributes.table_data->table_name, expected_table_name);
}
}
void CheckTableCellNode(const mojom::blink::AIPageContentNode& node) {
const auto& attributes = *node.content_attributes;
EXPECT_EQ(attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kTableCell);
}
void CheckTableRowNode(
const mojom::blink::AIPageContentNode& node,
const mojom::blink::AIPageContentTableRowType& expected_row_type) {
const auto& attributes = *node.content_attributes;
EXPECT_EQ(attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kTableRow);
ASSERT_TRUE(attributes.table_row_data);
EXPECT_EQ(attributes.table_row_data->row_type, expected_row_type);
}
void CheckContainerNode(const mojom::blink::AIPageContentNode& node) {
const auto& attributes = *node.content_attributes;
EXPECT_EQ(attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kContainer);
}
void CheckModalDialogNode(const mojom::blink::AIPageContentNode& node) {
const auto& attributes = *node.content_attributes;
EXPECT_EQ(attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kDialogModal);
}
void CheckModelessDialogNode(const mojom::blink::AIPageContentNode& node) {
const auto& attributes = *node.content_attributes;
EXPECT_EQ(attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kDialogModeless);
}
void CheckHeadingNode(const mojom::blink::AIPageContentNode& node) {
const auto& attributes = *node.content_attributes;
EXPECT_EQ(attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kHeading);
}
void CheckParagraphNode(const mojom::blink::AIPageContentNode& node) {
const auto& attributes = *node.content_attributes;
EXPECT_EQ(attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kParagraph);
}
void CheckUnorderedListNode(const mojom::blink::AIPageContentNode& node) {
const auto& attributes = *node.content_attributes;
EXPECT_EQ(attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kUnorderedList);
}
void CheckOrderedListNode(const mojom::blink::AIPageContentNode& node) {
const auto& attributes = *node.content_attributes;
EXPECT_EQ(attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kOrderedList);
}
void CheckAnnotatedRole(
const mojom::blink::AIPageContentNode& node,
const mojom::blink::AIPageContentAnnotatedRole& expected_role) {
const auto& attributes = *node.content_attributes;
ASSERT_EQ(attributes.annotated_roles.size(), 1u);
EXPECT_EQ(attributes.annotated_roles[0], expected_role);
}
void CheckAnnotatedRoles(
const mojom::blink::AIPageContentNode& node,
const std::vector<mojom::blink::AIPageContentAnnotatedRole>&
expected_roles) {
const auto& attributes = *node.content_attributes;
ASSERT_EQ(attributes.annotated_roles.size(), expected_roles.size());
EXPECT_THAT(attributes.annotated_roles,
testing::UnorderedElementsAreArray(expected_roles));
}
void CheckGeometry(const mojom::blink::AIPageContentNode& node,
const gfx::Rect& expected_outer_bounding_box,
const gfx::Rect& expected_visible_bounding_box) {
const auto& geometry = *node.content_attributes->geometry;
EXPECT_EQ(geometry.outer_bounding_box, expected_outer_bounding_box);
EXPECT_EQ(geometry.visible_bounding_box, expected_visible_bounding_box);
}
void CheckFormControlNode(
const mojom::blink::AIPageContentNode& node,
const mojom::blink::FormControlType& expected_form_control_type) {
const auto& attributes = *node.content_attributes;
EXPECT_EQ(attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kFormControl);
EXPECT_EQ(attributes.form_control_data->form_control_type,
expected_form_control_type);
}
const mojom::blink::AIPageContentNode& GetSingleTableCell(
const mojom::blink::AIPageContentNode& table) {
CheckTableNode(table);
EXPECT_EQ(table.children_nodes.size(), 1u);
const auto& table_row = *table.children_nodes[0];
CheckTableRowNode(table_row,
mojom::blink::AIPageContentTableRowType::kBody);
EXPECT_EQ(table_row.children_nodes.size(), 1u);
const auto& table_cell = *table_row.children_nodes[0];
CheckTableCellNode(table_cell);
return table_cell;
}
void CheckIframeNode(const mojom::blink::AIPageContentNode& node) {
const auto& attributes = *node.content_attributes;
EXPECT_EQ(attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kIframe);
}
void CheckRootNode(const mojom::blink::AIPageContentNode& node) {
const auto& attributes = *node.content_attributes;
EXPECT_EQ(attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kRoot);
}
void CheckAlmostEquals(const gfx::Point& actual, const gfx::Point& expected) {
// Allow 1px difference for rounding.
const int kTolerance = 1;
EXPECT_LE(abs(actual.x() - expected.x()), kTolerance)
<< "actual : " << actual.ToString()
<< ", expected: " << expected.ToString();
EXPECT_LE(abs(actual.y() - expected.y()), kTolerance)
<< "actual : " << actual.ToString()
<< ", expected: " << expected.ToString();
}
void GetAIPageContentWithActionableElements() {
auto options = GetAIPageContentOptionsForTest();
options->mode = mojom::blink::AIPageContentMode::kActionableElements;
GetAIPageContent(std::move(options));
}
static mojom::blink::AIPageContentOptionsPtr
GetAIPageContentOptionsForTest() {
auto options = mojom::blink::AIPageContentOptions::New();
options->on_critical_path = true;
options->mode = mojom::blink::AIPageContentMode::kDefault;
return options;
}
static void SetNodeIdAllowlistForTest(
mojom::blink::AIPageContentOptionsPtr& options,
std::initializer_list<mojom::blink::AIPageContentAttributeType>
allowlisted_attribute_types) {
// Centralize node-id options setup so test bodies focus on policy intent.
// `emplace()` means the policy is explicitly requested for this test.
options->node_id_allowlist.emplace();
for (const auto attribute_type : allowlisted_attribute_types) {
options->node_id_allowlist->push_back(attribute_type);
}
}
static mojom::blink::AIPageContentOptionsPtr CreateNodeIdPolicyOptionsForTest(
std::initializer_list<mojom::blink::AIPageContentAttributeType>
allowlisted_attribute_types = {},
mojom::blink::AIPageContentMode mode =
mojom::blink::AIPageContentMode::kDefault) {
auto options = GetAIPageContentOptionsForTest();
options->mode = mode;
SetNodeIdAllowlistForTest(options, allowlisted_attribute_types);
return options;
}
static mojom::blink::AIPageContentOptionsPtr CreateNodeIdPolicyOptionsForTest(
mojom::blink::AIPageContentMode mode) {
return CreateNodeIdPolicyOptionsForTest(
/*allowlisted_attribute_types=*/{}, mode);
}
void LoadNodeIdPolicyParagraphFixture() {
// Shared fixture for default-mode type-policy tests. Keeping this in one
// helper makes test intent clearer and avoids repeated HTML boilerplate.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <p id='paragraph'>visible paragraph text</p>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
}
void GetAIPageContent(
mojom::blink::AIPageContentOptionsPtr options = nullptr) {
auto* agent = AIPageContentAgent::GetOrCreateForTesting(
*helper_.LocalMainFrame()->GetFrame()->GetDocument());
EXPECT_TRUE(agent);
last_options_ = options ? std::move(options) : default_options_->Clone();
auto content = agent->GetAIPageContentInternal(*last_options_);
CHECK(content);
CHECK(content->root_node);
// Always validate serialization.
mojom::blink::AIPageContentPtr output;
EXPECT_TRUE(
mojo::test::SerializeAndDeserialize<mojom::blink::AIPageContent>(
content, output));
last_content_ = std::move(content);
}
void FireMouseMoveEvent(const gfx::PointF& point) {
EventHandler& event_handler =
helper_.LocalMainFrame()->GetFrame()->GetEventHandler();
WebMouseEvent event(WebInputEvent::Type::kMouseMove, point, point,
WebPointerProperties::Button::kLeft, 0,
WebInputEvent::kLeftButtonDown,
WebInputEvent::GetStaticTimeStampForTests());
event.SetFrameScale(1);
event_handler.HandleMouseMoveEvent(event, Vector<WebMouseEvent>(),
Vector<WebMouseEvent>());
}
const mojom::blink::AIPageContentNode& ContentRootNode() {
CHECK(last_content_);
EXPECT_TRUE(last_content_->root_node);
if (last_options_->mode !=
mojom::blink::AIPageContentMode::kActionableElements) {
return *last_content_->root_node;
}
EXPECT_EQ(last_content_->root_node->children_nodes.size(), 1u);
const auto& html = *last_content_->root_node->children_nodes[0];
EXPECT_EQ(html.children_nodes.size(), 1u);
return *html.children_nodes[0];
}
const mojom::blink::AIPageContentNode* FindNodeByDomNodeId(
const mojom::blink::AIPageContentNode& root,
DOMNodeId dom_node_id) {
Vector<const mojom::blink::AIPageContentNode*> stack;
stack.push_back(&root);
while (!stack.empty()) {
const auto* node = stack.back();
stack.pop_back();
if (node->content_attributes &&
node->content_attributes->dom_node_id.has_value() &&
*node->content_attributes->dom_node_id == dom_node_id) {
return node;
}
for (wtf_size_t i = node->children_nodes.size(); i > 0; --i) {
stack.push_back(node->children_nodes[i - 1].get());
}
}
return nullptr;
}
const mojom::blink::AIPageContentNode* FindNodeByDomNodeId(
DOMNodeId dom_node_id) {
return FindNodeByDomNodeId(ContentRootNode(), dom_node_id);
}
DOMNodeId GetDomNodeIdForSelector(String selector) {
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
EXPECT_TRUE(document);
if (!document) {
return kInvalidDOMNodeId;
}
Element* element = document->QuerySelector(AtomicString(selector));
EXPECT_TRUE(element) << "Couldn't find element with selector = "
<< selector;
if (!element) {
return kInvalidDOMNodeId;
}
DOMNodeId dom_node_id = DOMNodeIds::IdForNode(element);
EXPECT_GT(dom_node_id, kInvalidDOMNodeId);
return dom_node_id;
}
const mojom::blink::AIPageContentNode* FindNodeBySelector(String selector) {
const DOMNodeId dom_node_id = GetDomNodeIdForSelector(selector);
if (dom_node_id == kInvalidDOMNodeId) {
return nullptr;
}
return FindNodeByDomNodeId(dom_node_id);
}
void ExpectSelectorNotInApcOutput(String selector) {
// Validate that the selector resolves in the live DOM and has a valid
// DOMNodeId, then assert that APC output intentionally omitted it.
const DOMNodeId dom_node_id = GetDomNodeIdForSelector(selector);
ASSERT_GT(dom_node_id, kInvalidDOMNodeId);
EXPECT_FALSE(FindNodeByDomNodeId(dom_node_id));
}
void CheckHitTestableButNotInteractive(
const mojom::blink::AIPageContentNode& node) {
CHECK(node.content_attributes->node_interaction_info);
EXPECT_TRUE(node.content_attributes->node_interaction_info
->document_scoped_z_order);
EXPECT_TRUE(node.content_attributes->node_interaction_info
->clickability_reasons.empty());
}
void CheckHitTestableAndInteractive(
const mojom::blink::AIPageContentNode& node,
base::span<const ClickabilityReason> expected_reasons) {
CHECK(node.content_attributes->node_interaction_info);
EXPECT_TRUE(node.content_attributes->node_interaction_info
->document_scoped_z_order);
EXPECT_THAT(
node.content_attributes->node_interaction_info->clickability_reasons,
testing::UnorderedElementsAreArray(expected_reasons));
}
const mojom::blink::AIPageContentPtr& Content() { return last_content_; }
protected:
const mojom::blink::AIPageContentOptionsPtr default_options_ =
GetAIPageContentOptionsForTest();
test::TaskEnvironment task_environment_;
frame_test_helpers::WebViewHelper helper_;
private:
static void UpdateWebSettings(WebSettings* settings) {
settings->SetTextAreasAreResizable(true);
}
mojom::blink::AIPageContentPtr last_content_;
mojom::blink::AIPageContentOptionsPtr last_options_;
};
TEST_F(AIPageContentAgentTest, Basic) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style>"
" div {"
" position: absolute;"
" top: -10px;"
" left: -20px;"
" }"
" </style>"
" <div>text</div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = *Content()->root_node;
EXPECT_EQ(root.children_nodes.size(), 1u);
const auto& attributes = *root.content_attributes;
EXPECT_TRUE(attributes.dom_node_id.has_value());
EXPECT_EQ(attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kRoot);
CheckGeometry(root, gfx::Rect(kWindowSize), gfx::Rect(kWindowSize));
const auto& text_node = *ContentRootNode().children_nodes[0];
CheckTextNode(text_node, "text");
const auto& text_attributes = *text_node.content_attributes;
ASSERT_TRUE(text_attributes.geometry);
EXPECT_FALSE(text_attributes.node_interaction_info);
EXPECT_EQ(text_attributes.geometry->outer_bounding_box.x(), -20);
EXPECT_EQ(text_attributes.geometry->outer_bounding_box.y(), -10);
}
TEST_F(AIPageContentAgentTest, VisibleBoundingBoxClampsToVisualViewport) {
// Keep this regression test pinned to the runtime-flagged clamp behavior.
ScopedAIPageContentVisualViewportClampForTest enable_viewport_clamp(true);
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style>"
" body {"
" background-color: cornflowerblue;"
" margin: 0;"
" }"
" #wideElement {"
" position: absolute;"
" top: 20px;"
" left: 0;"
" width: 800px;"
" height: 100px;"
" background-color: limegreen;"
" }"
" .corner {"
" position: fixed;"
" width: 100px;"
" height: 100px;"
" background-color: coral;"
" }"
" ::view-transition-group(*),"
" ::view-transition-new(*),"
" ::view-transition-old(*) {"
" animation-play-state: paused;"
" }"
" ::view-transition-old(*) {"
" display: none;"
" }"
" ::view-transition-new(*) {"
" animation-name: none;"
" opacity: 1;"
" }"
" </style>"
" <div class='corner'"
" style='top: 0; right: 0; view-transition-name: topright;'></div>"
" <div id='wideElement'></div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
LocalFrameView* view = document->View();
ASSERT_TRUE(view);
// Establish a stable layout baseline before moving the visual viewport. This
// keeps the test focused on viewport coordinate mapping rather than pending
// layout or style work.
view->UpdateAllLifecyclePhasesForTest();
Page* page = document->GetPage();
ASSERT_TRUE(page);
// Simulate a visual viewport offset via browser controls. On mobile, the
// top controls can partially hide/show to reclaim screen space; that shifts
// the visual viewport origin without changing layout viewport coordinates.
// View transitions create a view-transition root layer that is positioned
// relative to the visual viewport, which is the same setup as the failing
// web test.
//
// Simple mental model (not to scale):
//
// Layout viewport coordinates (used for layout):
// y=0 +----------------------+
// | <div id=target> |
// | height=50 |
// +----------------------+
//
// Visual viewport (what is actually visible):
// y=0 +----------------------+
// | (starts 20px lower) |
// | so top 20px hidden |
// +----------------------+
//
// The browser-controls adjustment is a renderer-side representation of that
// shift. It should result in negative visual-viewport coordinates after
// mapping, which we clamp.
const int kVisualViewportOffset = 20;
document->GetPage()->GetChromeClient().SetBrowserControlsState(
kVisualViewportOffset, 0, /*shrinks_layout=*/true);
ScriptState* script_state =
ToScriptStateForMainWorld(helper_.LocalMainFrame()->GetFrame());
ScriptState::Scope scope(script_state);
ViewTransitionSupplement::startViewTransition(script_state, *document,
IGNORE_EXCEPTION_FOR_TESTING);
test::RunPendingTasks();
auto* view_transitions = document->GetViewTransitionsIfExists();
ASSERT_TRUE(view_transitions);
ASSERT_TRUE(view_transitions->GetTransition());
// Recompute layout and paint properties with the new visual viewport state
// so the geometry mapping observes the updated visual-viewport transform.
view->UpdateAllLifecyclePhasesForTest();
GetAIPageContentWithActionableElements();
int min_visible_y = std::numeric_limits<int>::max();
bool saw_geometry = false;
auto visit_nodes = [&](auto& self,
const mojom::blink::AIPageContentNode* node) -> void {
if (!node) {
return;
}
if (node->content_attributes && node->content_attributes->geometry) {
const auto& geometry = *node->content_attributes->geometry;
if (!geometry.visible_bounding_box.IsEmpty()) {
min_visible_y =
std::min(min_visible_y, geometry.visible_bounding_box.y());
saw_geometry = true;
}
}
for (const auto& child : node->children_nodes) {
self(self, child.get());
}
};
visit_nodes(visit_nodes, Content()->root_node.get());
ASSERT_TRUE(saw_geometry);
EXPECT_EQ(min_visible_y, 0);
}
TEST_F(AIPageContentAgentTest,
VisibleBoundingBoxClampUsesConsistentUnitsOnHighDPI) {
// Keep this regression test pinned to the runtime-flagged clamp behavior.
ScopedAIPageContentVisualViewportClampForTest enable_viewport_clamp(true);
// This is a regression test for a subtle unit mismatch in
// ComputeVisibleBoundingBox().
//
// Background:
// - APC's visible_bounding_box is consumed by browser-side hit-testing
// (e.g. Actor TOCTOU validation) in device pixels (BlinkSpace).
// - The visible-bounding-box calculation maps LayoutObject geometry using
// MapToVisualRectInAncestorSpace(), which produces values in the same
// coordinate space that the browser expects (BlinkSpace/device pixels).
//
// The geo-fixes branch introduced a clamp intended to normalize negative
// coordinates that can occur when the visual viewport is offset relative to
// the layout viewport (browser controls, pinch zoom). That clamp must use a
// viewport rect in the *same* coordinate space as the mapped object rect.
//
// If the clamp instead uses unscaled CSS/layout pixels (DIPs), HighDPI pages
// (device_scale_factor > 1) will have their geometry truncated by ~DSF. This
// makes APC look DIP-bounded and causes browser-side hit-testing at scaled
// points to miss.
// Configure the test WebView to behave like a HighDPI display.
//
// Important: The widget's ScreenInfo must be set during widget creation, not
// after initialization. Many coordinate conversions (including
// DIPsToBlinkSpace) cache the screen info early, and updating it late won't
// affect geometry computations.
frame_test_helpers::WebViewHelper high_dpi_helper(base::BindRepeating(
[](base::PassKey<WebLocalFrame> pass_key,
CrossVariantMojoAssociatedRemote<
mojom::blink::FrameWidgetHostInterfaceBase> frame_widget_host,
CrossVariantMojoAssociatedReceiver<
mojom::blink::FrameWidgetInterfaceBase> frame_widget,
CrossVariantMojoAssociatedRemote<mojom::blink::WidgetHostInterfaceBase>
widget_host,
CrossVariantMojoAssociatedReceiver<mojom::blink::WidgetInterfaceBase>
widget,
scoped_refptr<base::SingleThreadTaskRunner> task_runner,
const viz::FrameSinkId& frame_sink_id, bool hidden,
bool never_composited, bool is_for_child_local_root,
bool is_for_nested_main_frame,
bool is_for_scalable_page) -> frame_test_helpers::TestWebFrameWidget* {
auto* test_web_frame_widget =
MakeGarbageCollected<frame_test_helpers::TestWebFrameWidget>(
std::move(pass_key), std::move(frame_widget_host),
std::move(frame_widget), std::move(widget_host),
std::move(widget), std::move(task_runner), frame_sink_id,
hidden, never_composited, is_for_child_local_root,
is_for_nested_main_frame, is_for_scalable_page);
display::ScreenInfo screen_info;
screen_info.device_scale_factor = 2.f;
test_web_frame_widget->SetInitialScreenInfo(screen_info);
return test_web_frame_widget;
}));
// Keep WebSettings consistent with the main fixture's setup. We can't reuse
// AIPageContentAgentTest::UpdateWebSettings here because it's private to the
// fixture base class.
high_dpi_helper.InitializeWithSettings(
[](WebSettings* settings) { settings->SetTextAreasAreResizable(true); });
high_dpi_helper.LoadAhem();
ASSERT_TRUE(high_dpi_helper.LocalMainFrame());
// Use a small viewport size to make it easy to see clamping errors in the
// resulting bounding boxes.
//
// Note: WebViewHelper::Resize() takes the widget size in *device pixels*.
// With device_scale_factor=2, a 464x828 DIP viewport corresponds to a
// 928x1656 device-pixel widget size.
constexpr gfx::Size kDipViewportSize{464, 828};
constexpr int kDeviceScaleFactor = 2;
high_dpi_helper.Resize(
gfx::Size(kDipViewportSize.width() * kDeviceScaleFactor,
kDipViewportSize.height() * kDeviceScaleFactor));
frame_test_helpers::LoadHTMLString(
high_dpi_helper.LocalMainFrame(),
"<body>"
" <style>"
" body {"
" margin: 0;"
" padding: 0;"
" }"
" #target {"
" position: absolute;"
" left: 0;"
" top: 0;"
" width: 100%;"
" height: 100%;"
" background: limegreen;"
" }"
" ::view-transition-group(*),"
" ::view-transition-new(*),"
" ::view-transition-old(*) {"
" animation-play-state: paused;"
" }"
" ::view-transition-old(*) {"
" display: none;"
" }"
" ::view-transition-new(*) {"
" animation-name: none;"
" opacity: 1;"
" }"
" </style>"
" <div id='target'></div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
Document* document =
high_dpi_helper.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
LocalFrameView* view = document->View();
ASSERT_TRUE(view);
// Establish a stable baseline before applying the visual viewport shift.
view->UpdateAllLifecyclePhasesForTest();
// Create a visual-viewport offset (same mechanism as the clamp test above).
constexpr int kVisualViewportOffsetDip = 20;
document->GetPage()->GetChromeClient().SetBrowserControlsState(
kVisualViewportOffsetDip, 0, /*shrinks_layout=*/true);
ScriptState* script_state =
ToScriptStateForMainWorld(high_dpi_helper.LocalMainFrame()->GetFrame());
ScriptState::Scope scope(script_state);
ViewTransitionSupplement::startViewTransition(script_state, *document,
IGNORE_EXCEPTION_FOR_TESTING);
test::RunPendingTasks();
// Recompute layout and paint properties so mapping sees the new viewport
// transform.
view->UpdateAllLifecyclePhasesForTest();
// Extract APC using the HighDPI document. We intentionally don't use the
// AIPageContentAgentTest fixture helpers here because those are wired to the
// fixture's `helper_` WebView, not the HighDPI helper created above.
mojom::blink::AIPageContentOptionsPtr options =
AIPageContentAgentTest::GetAIPageContentOptionsForTest();
options->mode = mojom::blink::AIPageContentMode::kActionableElements;
auto* agent = AIPageContentAgent::GetOrCreateForTesting(*document);
ASSERT_TRUE(agent);
auto content = agent->GetAIPageContentInternal(*options);
ASSERT_TRUE(content);
ASSERT_TRUE(content->root_node);
// We don't rely on a specific node ordering here. Instead, collect the
// maximum extents over all visible_bounding_box values and verify they match
// the device-pixel viewport size (BlinkSpace), not the DIP viewport size.
int max_right = 0;
int max_bottom = 0;
bool saw_geometry = false;
auto visit_nodes = [&](auto& self,
const mojom::blink::AIPageContentNode* node) -> void {
if (!node) {
return;
}
if (node->content_attributes && node->content_attributes->geometry) {
const auto& geometry = *node->content_attributes->geometry;
if (!geometry.visible_bounding_box.IsEmpty()) {
saw_geometry = true;
max_right = std::max(max_right, geometry.visible_bounding_box.right());
max_bottom =
std::max(max_bottom, geometry.visible_bounding_box.bottom());
}
}
for (const auto& child : node->children_nodes) {
self(self, child.get());
}
};
visit_nodes(visit_nodes, content->root_node.get());
ASSERT_TRUE(saw_geometry);
// On HighDPI, the device-pixel viewport should be (layout viewport in CSS
// pixels) * DSF.
//
// Note: When browser controls are involved, the layout viewport height can be
// reduced by an implementation-defined amount (it is *not* guaranteed to be
// exactly kVisualViewportOffsetDip). Use LocalFrameView's post-adjustment
// viewport size to compute the expected BlinkSpace/device-pixel extents.
EXPECT_EQ(max_right, view->ViewportWidth() * kDeviceScaleFactor);
EXPECT_EQ(max_bottom, view->ViewportHeight() * kDeviceScaleFactor);
}
TEST_F(AIPageContentAgentTest, Image) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style>"
" img {"
" position: absolute;"
" top: -10px;"
" left: -20px;"
" width: 30px;"
" height: 40px;"
" }"
" </style>"
" <img alt=missing></img>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
auto& document = *helper_.LocalMainFrame()->GetFrame()->GetDocument();
document.getElementsByTagName(AtomicString("img"))
->item(0)
->setAttribute(html_names::kSrcAttr, AtomicString(kSmallImage));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
auto& image_node = *root.children_nodes[0];
CheckImageNode(image_node, "missing", KURL());
CheckGeometry(image_node, gfx::Rect(-20, -10, 30, 40),
gfx::Rect(0, 0, 10, 30));
ASSERT_TRUE(image_node.content_attributes->image_info);
ASSERT_TRUE(image_node.content_attributes->image_info->source_origin);
EXPECT_TRUE(
image_node.content_attributes->image_info->source_origin->IsOpaque());
}
TEST_F(AIPageContentAgentTest, ImageWithUrl) {
url_test_helpers::RegisterMockedURLLoad(
url_test_helpers::ToKURL("https://example.com/image.jpg"),
test::CoreTestDataPath("white-1x1.png"), "image/png");
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <img id='img' src='https://example.com/image.jpg' alt='hello alt "
"text'></img>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
auto& image_node = *root.children_nodes[0];
CheckImageNode(image_node, "hello alt text",
blink::KURL("https://example.com/image.jpg"));
ASSERT_TRUE(image_node.content_attributes->image_info);
ASSERT_TRUE(image_node.content_attributes->image_info->source_origin);
EXPECT_EQ(
image_node.content_attributes->image_info->source_origin->ToString(),
"https://example.com");
url_test_helpers::RegisterMockedURLUnregister(
url_test_helpers::ToKURL("https://example.com/image.jpg"));
}
TEST_F(AIPageContentAgentTest, ImageWithAriaLabel) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <img aria-label='hello'></img>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
auto& image_node = *root.children_nodes[0];
EXPECT_EQ("hello", image_node.content_attributes->label);
}
TEST_F(AIPageContentAgentTest, ImageIsAdRelated) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <img id='ads'></img>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
auto& document = *helper_.LocalMainFrame()->GetFrame()->GetDocument();
To<HTMLImageElement>(document.getElementById(AtomicString("ads")))
->SetIsAdRelated(AdProvenance{});
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
auto& image_node = *root.children_nodes[0];
EXPECT_TRUE(image_node.content_attributes->is_ad_related);
}
TEST_F(AIPageContentAgentTest, ImageIsAdRelatedExcluded) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <img id='ads'></img>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
auto& document = *helper_.LocalMainFrame()->GetFrame()->GetDocument();
To<HTMLImageElement>(document.getElementById(AtomicString("ads")))
->SetIsAdRelated(AdProvenance{});
auto options = GetAIPageContentOptionsForTest();
options->non_salient_content_config =
mojom::blink::NonSalientContentConfig::New();
options->non_salient_content_config->exclude_ad_related = true;
GetAIPageContent(std::move(options));
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 0u);
}
TEST_F(AIPageContentAgentTest, ImageNoAltText) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
base::StringPrintf("<body>"
" <style>"
" div::before {"
" content: url(%s);"
" }"
" </style>"
" <div>text</div>"
"</body>",
kSmallImage),
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
}
TEST_F(AIPageContentAgentTest, Video) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <video src='https://example.com/video.mp4'></video>"
" <video "
"src='https://example.com/video.mp4?param1=value1&param2=value2'></video>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 2u);
const auto& video1 = *root.children_nodes[0];
EXPECT_EQ(video1.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kVideo);
ASSERT_TRUE(video1.content_attributes->video_data);
EXPECT_EQ(video1.content_attributes->video_data->url,
blink::KURL("https://example.com/video.mp4"));
ASSERT_TRUE(video1.content_attributes->video_data->source_origin);
EXPECT_EQ(video1.content_attributes->video_data->source_origin->ToString(),
"https://example.com");
const auto& video2 = *root.children_nodes[1];
EXPECT_EQ(video2.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kVideo);
ASSERT_TRUE(video2.content_attributes->video_data);
EXPECT_EQ(
video2.content_attributes->video_data->url,
blink::KURL("https://example.com/video.mp4?param1=value1&param2=value2"));
ASSERT_TRUE(video2.content_attributes->video_data->source_origin);
EXPECT_EQ(video2.content_attributes->video_data->source_origin->ToString(),
"https://example.com");
}
TEST_F(AIPageContentAgentTest, Headings) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <h1>Heading 1</h1>"
" <h2>Heading 2</h2>"
" <h3>Heading 3</h3>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 3u);
const auto& heading1 = *root.children_nodes[0];
CheckHeadingNode(heading1);
ASSERT_EQ(heading1.children_nodes.size(), 1u);
const auto& heading1_text_node = *heading1.children_nodes[0];
CheckTextNode(heading1_text_node, "Heading 1");
const auto& heading2 = *root.children_nodes[1];
CheckHeadingNode(heading2);
ASSERT_EQ(heading2.children_nodes.size(), 1u);
const auto& heading2_text_node = *heading2.children_nodes[0];
CheckTextNode(heading2_text_node, "Heading 2");
const auto& heading3 = *root.children_nodes[2];
CheckHeadingNode(heading3);
ASSERT_EQ(heading3.children_nodes.size(), 1u);
const auto& heading3_text_node = *heading3.children_nodes[0];
CheckTextNode(heading3_text_node, "Heading 3");
}
TEST_F(AIPageContentAgentTest, Paragraph) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style>"
" p {"
" position: fixed;"
" top: -10px;"
" left: -20px;"
" width: 200px;"
" height: 40px;"
" margin: 0;"
" }"
" </style>"
" <p>text inside paragraph</p>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& paragraph = *root.children_nodes[0];
CheckParagraphNode(paragraph);
CheckGeometry(paragraph, gfx::Rect(-20, -10, 200, 40),
gfx::Rect(0, 0, 180, 30));
ASSERT_EQ(paragraph.children_nodes.size(), 1u);
const auto& paragraph_text_node = *paragraph.children_nodes[0];
CheckTextNode(paragraph_text_node, "text inside paragraph");
}
TEST_F(AIPageContentAgentTest, Lists) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <ul>"
" <li>Item 1</li>"
" <li>Item 2</li>"
" </ul>"
" <ol>"
" <li>Step 1</li>"
" <li>Step 2</li>"
" </ol>"
" <dl>"
" <dt>Detail 1 title</dt>"
" <dd>Detail 1 description</dd>"
" <dt>Detail 2 title</dt>"
" <dd>Detail 2 description</dd>"
" </dl>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 3u);
const auto& ul = *root.children_nodes[0];
CheckUnorderedListNode(ul);
ASSERT_EQ(ul.children_nodes.size(), 2u);
CheckListItemWithText(*ul.children_nodes[0], "Item 1");
CheckListItemWithText(*ul.children_nodes[1], "Item 2");
const auto& ol = *root.children_nodes[1];
CheckOrderedListNode(ol);
ASSERT_EQ(ol.children_nodes.size(), 2u);
CheckListItemWithText(*ol.children_nodes[0], "Step 1");
CheckListItemWithText(*ol.children_nodes[1], "Step 2");
const auto& dl = *root.children_nodes[2];
CheckUnorderedListNode(dl);
ASSERT_EQ(dl.children_nodes.size(), 4u);
CheckListItemWithText(*dl.children_nodes[0], "Detail 1 title");
CheckListItemWithText(*dl.children_nodes[1], "Detail 1 description");
CheckListItemWithText(*dl.children_nodes[2], "Detail 2 title");
CheckListItemWithText(*dl.children_nodes[3], "Detail 2 description");
}
TEST_F(AIPageContentAgentTest, IFrameWithContent) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <iframe src='about:blank'></iframe>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
auto* iframe_element =
To<HTMLIFrameElement>(helper_.LocalMainFrame()
->GetFrame()
->GetDocument()
->getElementsByTagName(AtomicString("iframe"))
->item(0));
ASSERT_TRUE(iframe_element);
// Access the iframe's document and set some content
auto* iframe_doc = iframe_element->contentDocument();
ASSERT_TRUE(iframe_doc);
iframe_doc->body()->SetInnerHTMLWithoutTrustedTypes(
"<body>inside iframe</body>");
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& iframe = *root.children_nodes[0];
const auto& iframe_attributes = *iframe.content_attributes;
EXPECT_EQ(iframe_attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kIframe);
EXPECT_FALSE(iframe_attributes.is_ad_related);
const auto& iframe_root = *iframe.children_nodes[0];
CheckTextNode(*iframe_root.children_nodes[0], "inside iframe");
}
TEST_F(AIPageContentAgentTest, IFrameAds) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <iframe src='about:blank'></iframe>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
auto* iframe_element =
To<HTMLIFrameElement>(helper_.LocalMainFrame()
->GetFrame()
->GetDocument()
->getElementsByTagName(AtomicString("iframe"))
->item(0));
ASSERT_TRUE(iframe_element);
// Mark iframe's ad evidence.
blink::FrameAdEvidence ad_evidence;
ad_evidence.set_created_by_ad_script(
blink::mojom::FrameCreationStackEvidence::kCreatedByAdScript);
ad_evidence.set_is_complete();
To<LocalFrame>(iframe_element->ContentFrame())->SetAdEvidence(ad_evidence);
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& iframe = *root.children_nodes[0];
const auto& iframe_attributes = *iframe.content_attributes;
EXPECT_EQ(iframe_attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kIframe);
EXPECT_TRUE(iframe_attributes.is_ad_related);
}
TEST_F(AIPageContentAgentTest, IFrameAdsExcluded) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <iframe src='about:blank'></iframe>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
auto* iframe_element =
To<HTMLIFrameElement>(helper_.LocalMainFrame()
->GetFrame()
->GetDocument()
->getElementsByTagName(AtomicString("iframe"))
->item(0));
ASSERT_TRUE(iframe_element);
// Mark iframe's ad evidence.
blink::FrameAdEvidence ad_evidence;
ad_evidence.set_created_by_ad_script(
blink::mojom::FrameCreationStackEvidence::kCreatedByAdScript);
ad_evidence.set_is_complete();
To<LocalFrame>(iframe_element->ContentFrame())->SetAdEvidence(ad_evidence);
auto options = GetAIPageContentOptionsForTest();
options->non_salient_content_config =
mojom::blink::NonSalientContentConfig::New();
options->non_salient_content_config->exclude_ad_related = true;
GetAIPageContent(std::move(options));
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 0u);
}
TEST_F(AIPageContentAgentTest, RootFrameAdExcluded) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <iframe src='about:blank'></iframe>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
auto* iframe_element =
To<HTMLIFrameElement>(helper_.LocalMainFrame()
->GetFrame()
->GetDocument()
->getElementsByTagName(AtomicString("iframe"))
->item(0));
ASSERT_TRUE(iframe_element);
// Mark iframe's ad evidence.
blink::FrameAdEvidence ad_evidence;
ad_evidence.set_created_by_ad_script(
blink::mojom::FrameCreationStackEvidence::kCreatedByAdScript);
ad_evidence.set_is_complete();
auto* child_frame = To<LocalFrame>(iframe_element->ContentFrame());
child_frame->SetAdEvidence(ad_evidence);
ASSERT_TRUE(child_frame->IsAdFrame());
auto* agent =
AIPageContentAgent::GetOrCreateForTesting(*child_frame->GetDocument());
ASSERT_TRUE(agent);
auto options = GetAIPageContentOptionsForTest();
options->non_salient_content_config =
mojom::blink::NonSalientContentConfig::New();
options->non_salient_content_config->exclude_ad_related = true;
auto content = agent->GetAIPageContentInternal(*options);
// The entire content should be null because the frame is an ad.
ASSERT_FALSE(content);
}
TEST_F(AIPageContentAgentTest, CrossSiteIframeIncluded) {
KURL main_url = url_test_helpers::ToKURL("http://example.com/main.html");
KURL cross_origin_url =
url_test_helpers::ToKURL("http://www.example.com/frame.html");
KURL cross_site_url =
url_test_helpers::ToKURL("http://altostrat.com/frame_another.html");
// Mock the cross origin, same-site iframe's content.
url_test_helpers::RegisterMockedURLLoadFromBase(
WebString("http://www.example.com/"), test::CoreTestDataPath(),
WebString("frame.html"));
// Mock the cross-site iframe's content.
url_test_helpers::RegisterMockedURLLoadFromBase(
WebString("http://altostrat.com/"), test::CoreTestDataPath(),
WebString("frame_another.html"));
// Load the main page which contains the same-site iframe and the cross-origin
// iframe.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
"<iframe src='http://www.example.com/frame.html'></iframe>"
"<iframe src='http://altostrat.com/frame_another.html'></iframe>"
"</body>",
main_url);
// Let the iframe load.
test::RunPendingTasks();
auto options = GetAIPageContentOptionsForTest();
options->include_same_site_only = false;
GetAIPageContent(std::move(options));
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 2u);
// Both nodes should be present.
const auto& same_site_iframe_node = *root.children_nodes[0];
const auto& cross_site_iframe_node = *root.children_nodes[1];
CheckIframeNode(same_site_iframe_node);
CheckIframeNode(cross_site_iframe_node);
// The contents of both nodes should be present as well.
ASSERT_EQ(same_site_iframe_node.children_nodes.size(), 1u);
ASSERT_EQ(cross_site_iframe_node.children_nodes.size(), 1u);
const auto& same_site_iframe_root = *same_site_iframe_node.children_nodes[0];
const auto& cross_site_iframe_root =
*cross_site_iframe_node.children_nodes[0];
CheckRootNode(same_site_iframe_root);
CheckRootNode(cross_site_iframe_root);
ASSERT_EQ(same_site_iframe_root.children_nodes.size(), 1u);
ASSERT_EQ(cross_site_iframe_root.children_nodes.size(), 1u);
CheckTextNode(*same_site_iframe_root.children_nodes[0], "I am an iframe\n");
CheckTextNode(*cross_site_iframe_root.children_nodes[0],
"I am another iframe\n");
}
TEST_F(AIPageContentAgentTest, CrossSiteIframeExcluded) {
KURL main_url = url_test_helpers::ToKURL("http://example.com/main.html");
KURL cross_origin_url =
url_test_helpers::ToKURL("http://www.example.com/frame.html");
KURL cross_site_url =
url_test_helpers::ToKURL("http://altostrat.com/frame_another.html");
// Mock the cross origin, same-site iframe's content.
url_test_helpers::RegisterMockedURLLoadFromBase(
WebString("http://www.example.com/"), test::CoreTestDataPath(),
WebString("frame.html"));
// Mock the cross-site iframe's content.
url_test_helpers::RegisterMockedURLLoadFromBase(
WebString("http://altostrat.com/"), test::CoreTestDataPath(),
WebString("frame_another.html"));
// Load the main page which contains the same-site iframe and the cross-origin
// iframe.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
"<iframe src='http://www.example.com/frame.html'></iframe>"
"<iframe src='http://altostrat.com/frame_another.html'></iframe>"
"</body>",
main_url);
// Let the iframe load.
test::RunPendingTasks();
auto options = GetAIPageContentOptionsForTest();
options->include_same_site_only = true;
GetAIPageContent(std::move(options));
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 2u);
// Both nodes should be present.
const auto& same_site_iframe_node = *root.children_nodes[0];
const auto& cross_site_iframe_node = *root.children_nodes[1];
CheckIframeNode(same_site_iframe_node);
CheckIframeNode(cross_site_iframe_node);
// Only the contents of the same-site iframe should be present.
ASSERT_EQ(same_site_iframe_node.children_nodes.size(), 1u);
ASSERT_TRUE(cross_site_iframe_node.children_nodes.empty());
ASSERT_EQ(cross_site_iframe_node.content_attributes->iframe_data->content
->get_redacted_frame_metadata()
->reason,
blink::mojom::RedactedFrameMetadata_Reason::kCrossSite);
const auto& same_site_iframe_root = *same_site_iframe_node.children_nodes[0];
CheckRootNode(same_site_iframe_root);
ASSERT_EQ(same_site_iframe_root.children_nodes.size(), 1u);
CheckTextNode(*same_site_iframe_root.children_nodes[0], "I am an iframe\n");
}
TEST_F(AIPageContentAgentTest, NoLayoutElement) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div style='display: none;'>Hidden Content</div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_TRUE(root.children_nodes.empty());
}
TEST_F(AIPageContentAgentTest, VisibilityHidden) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div style='visibility: hidden;'>Hidden Content</div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_TRUE(root.children_nodes.empty());
}
TEST_F(AIPageContentAgentTest, TextSize) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <h1>Extra large text</h1>"
" <h2>Large text</h2>"
" <p>Regular text</p>"
" <h6>Small text</h6>"
" <p style='font-size: 0.25em;'>Extra small text</p>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 5u);
const auto& xl_text = *root.children_nodes[0];
CheckHeadingNode(xl_text);
CheckTextNode(*xl_text.children_nodes[0], "Extra large text");
CheckTextSize(*xl_text.children_nodes[0],
mojom::blink::AIPageContentTextSize::kXL);
const auto& l_text = *root.children_nodes[1];
CheckHeadingNode(l_text);
CheckTextNode(*l_text.children_nodes[0], "Large text");
CheckTextSize(*l_text.children_nodes[0],
mojom::blink::AIPageContentTextSize::kL);
const auto& m_text = *root.children_nodes[2];
CheckParagraphNode(m_text);
CheckTextNode(*m_text.children_nodes[0], "Regular text");
CheckTextSize(*m_text.children_nodes[0],
mojom::blink::AIPageContentTextSize::kM);
const auto& s_text = *root.children_nodes[3];
CheckHeadingNode(s_text);
CheckTextNode(*s_text.children_nodes[0], "Small text");
CheckTextSize(*s_text.children_nodes[0],
mojom::blink::AIPageContentTextSize::kS);
const auto& xs_text = *root.children_nodes[4];
CheckParagraphNode(xs_text);
CheckTextNode(*xs_text.children_nodes[0], "Extra small text");
CheckTextSize(*xs_text.children_nodes[0],
mojom::blink::AIPageContentTextSize::kXS);
}
TEST_F(AIPageContentAgentTest, TextEmphasis) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
"<p>Regular text"
"<b>Bolded text</b>"
"<i>Italicized text</i>"
"<u>Underlined text</u>"
"<sub>Subscript text</sub>"
"<sup>Superscript text</sup>"
"<em>Emphasized text</em>"
"<strong>Strong text</strong>"
"</p>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& paragraph = *root.children_nodes[0];
CheckParagraphNode(paragraph);
ASSERT_EQ(paragraph.children_nodes.size(), 8u);
const auto& regular_text = *paragraph.children_nodes[0];
CheckTextNode(regular_text, "Regular text");
CheckTextEmphasis(regular_text, false);
const auto& bolded_text = *paragraph.children_nodes[1];
CheckTextNode(bolded_text, "Bolded text");
CheckTextEmphasis(bolded_text, true);
const auto& italicized_text = *paragraph.children_nodes[2];
CheckTextNode(italicized_text, "Italicized text");
CheckTextEmphasis(italicized_text, true);
const auto& underlined_text = *paragraph.children_nodes[3];
CheckTextNode(underlined_text, "Underlined text");
CheckTextEmphasis(underlined_text, true);
const auto& subscript_text = *paragraph.children_nodes[4];
CheckTextNode(subscript_text, "Subscript text");
CheckTextEmphasis(subscript_text, true);
const auto& superscript_text = *paragraph.children_nodes[5];
CheckTextNode(superscript_text, "Superscript text");
CheckTextEmphasis(superscript_text, true);
const auto& emphasized_text = *paragraph.children_nodes[6];
CheckTextNode(emphasized_text, "Emphasized text");
CheckTextEmphasis(emphasized_text, true);
const auto& strong_text = *paragraph.children_nodes[7];
CheckTextNode(strong_text, "Strong text");
CheckTextEmphasis(strong_text, true);
}
TEST_F(AIPageContentAgentTest, TextColor) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
"<p>Regular text</p>"
"<p style='color: red'>Red text</p>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 2u);
const auto& paragraph = *root.children_nodes[0];
CheckParagraphNode(paragraph);
ASSERT_EQ(paragraph.children_nodes.size(), 1u);
const auto& regular_text = *paragraph.children_nodes[0];
CheckTextNode(regular_text, "Regular text");
CheckTextColor(regular_text, Color(0, 0, 0).Rgb());
const auto& red_paragraph = *root.children_nodes[1];
CheckParagraphNode(paragraph);
ASSERT_EQ(paragraph.children_nodes.size(), 1u);
const auto& bolded_text = *red_paragraph.children_nodes[0];
CheckTextNode(bolded_text, "Red text");
CheckTextColor(bolded_text, Color(255, 0, 0).Rgb());
}
TEST_F(AIPageContentAgentTest, Table) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <table>"
" <caption>Table caption</caption>"
" <thead>"
" <th colspan='2'>Header</th>"
" </thead>"
" <tr>"
" <td>Row 1 Column 1</td>"
" <td>Row 1 Column 2</td>"
" </tr>"
" <tr>"
" <td>Row 2 Column 1</td>"
" <td>Row 2 Column 2</td>"
" </tr>"
" <tfoot>"
" <td>Footer 1</td>"
" <td>Footer 2</td>"
" </tfoot>"
" </table>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& table = *root.children_nodes[0];
CheckTableNode(table, "Table caption");
ASSERT_EQ(table.children_nodes.size(), 5u);
const auto& caption_text = *table.children_nodes[0];
CheckTextNode(caption_text, "Table caption");
const auto& header1 = *table.children_nodes[1];
CheckTableRowNode(header1, mojom::blink::AIPageContentTableRowType::kHeader);
ASSERT_EQ(header1.children_nodes.size(), 1u);
const auto& header1_cell1 = *header1.children_nodes[0];
CheckTableCellNode(header1_cell1);
CheckTextNode(*header1_cell1.children_nodes[0], "Header");
const auto& row1 = *table.children_nodes[2];
CheckTableRowNode(row1, mojom::blink::AIPageContentTableRowType::kBody);
ASSERT_EQ(row1.children_nodes.size(), 2u);
const auto& row1_cell1 = *row1.children_nodes[0];
CheckTableCellNode(row1_cell1);
CheckTextNode(*row1_cell1.children_nodes[0], "Row 1 Column 1");
const auto& row1_cell2 = *row1.children_nodes[1];
CheckTableCellNode(row1_cell2);
CheckTextNode(*row1_cell2.children_nodes[0], "Row 1 Column 2");
const auto& row2 = *table.children_nodes[3];
CheckTableRowNode(row2, mojom::blink::AIPageContentTableRowType::kBody);
ASSERT_EQ(row2.children_nodes.size(), 2u);
const auto& row2_cell1 = *row2.children_nodes[0];
CheckTableCellNode(row2_cell1);
CheckTextNode(*row2_cell1.children_nodes[0], "Row 2 Column 1");
const auto& row2_cell2 = *row2.children_nodes[1];
CheckTableCellNode(row2_cell2);
CheckTextNode(*row2_cell2.children_nodes[0], "Row 2 Column 2");
const auto& footer = *table.children_nodes[4];
CheckTableRowNode(footer, mojom::blink::AIPageContentTableRowType::kFooter);
ASSERT_EQ(footer.children_nodes.size(), 2u);
const auto& footer_cell1 = *footer.children_nodes[0];
CheckTableCellNode(footer_cell1);
CheckTextNode(*footer_cell1.children_nodes[0], "Footer 1");
const auto& footer_cell2 = *footer.children_nodes[1];
CheckTableCellNode(footer_cell2);
CheckTextNode(*footer_cell2.children_nodes[0], "Footer 2");
}
TEST_F(AIPageContentAgentTest, TableMadeWithCss) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style>"
" .table {"
" display: table;"
" border-collapse: collapse;"
" width: 100%;"
" }"
" .row {"
" display: table-row;"
" }"
" .cell {"
" display: table-cell;"
" border: 1px solid #000;"
" padding: 8px;"
" text-align: center;"
" }"
" .header {"
" background-color: #f4f4f4;"
" font-weight: bold;"
" }"
" </style>"
" <div class='table'>"
// Header Rows
" <div class='row header'>"
" <div class='cell' colspan='2'>Personal Info</div>"
" <div class='cell' colspan='2'>Contact Info</div>"
" </div>"
" <div class='row header'>"
" <div class='cell'>Name</div>"
" <div class='cell'>Age</div>"
" <div class='cell'>Email</div>"
" <div class='cell'>Phone</div>"
" </div>"
// Body Rows
" <div class='row'>"
" <div class='cell'>John Doe</div>"
" <div class='cell'>30</div>"
" <div class='cell'>john.doe@example.com</div>"
" <div class='cell'>123-456-7890</div>"
" </div>"
" <div class='row'>"
" <div class='cell'>Jane Smith</div>"
" <div class='cell'>28</div>"
" <div class='cell'>jane.smith@example.com</div>"
" <div class='cell'>987-654-3210</div>"
" </div>"
" </div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& table = *root.children_nodes[0];
CheckTableNode(table);
ASSERT_EQ(table.children_nodes.size(), 4u);
const auto& row1 = *table.children_nodes[0];
CheckTableRowNode(row1, mojom::blink::AIPageContentTableRowType::kBody);
ASSERT_EQ(row1.children_nodes.size(), 2u);
const auto& row1_cell1 = *row1.children_nodes[0];
CheckTableCellNode(row1_cell1);
CheckTextNode(*row1_cell1.children_nodes[0], "Personal Info");
const auto& row1_cell2 = *row1.children_nodes[1];
CheckTableCellNode(row1_cell2);
CheckTextNode(*row1_cell2.children_nodes[0], "Contact Info");
const auto& row2 = *table.children_nodes[1];
CheckTableRowNode(row2, mojom::blink::AIPageContentTableRowType::kBody);
ASSERT_EQ(row2.children_nodes.size(), 4u);
const auto& row2_cell1 = *row2.children_nodes[0];
CheckTableCellNode(row2_cell1);
CheckTextNode(*row2_cell1.children_nodes[0], "Name");
const auto& row2_cell2 = *row2.children_nodes[1];
CheckTableCellNode(row2_cell2);
CheckTextNode(*row2_cell2.children_nodes[0], "Age");
const auto& row2_cell3 = *row2.children_nodes[2];
CheckTableCellNode(row2_cell3);
CheckTextNode(*row2_cell3.children_nodes[0], "Email");
const auto& row2_cell4 = *row2.children_nodes[3];
CheckTableCellNode(row2_cell4);
CheckTextNode(*row2_cell4.children_nodes[0], "Phone");
const auto& row3 = *table.children_nodes[2];
CheckTableRowNode(row3, mojom::blink::AIPageContentTableRowType::kBody);
ASSERT_EQ(row3.children_nodes.size(), 4u);
const auto& row3_cell1 = *row3.children_nodes[0];
CheckTableCellNode(row3_cell1);
CheckTextNode(*row3_cell1.children_nodes[0], "John Doe");
const auto& row3_cell2 = *row3.children_nodes[1];
CheckTableCellNode(row3_cell2);
CheckTextNode(*row3_cell2.children_nodes[0], "30");
const auto& row3_cell3 = *row3.children_nodes[2];
CheckTableCellNode(row3_cell3);
CheckTextNode(*row3_cell3.children_nodes[0], "john.doe@example.com");
const auto& row3_cell4 = *row3.children_nodes[3];
CheckTableCellNode(row3_cell4);
CheckTextNode(*row3_cell4.children_nodes[0], "123-456-7890");
const auto& row4 = *table.children_nodes[3];
CheckTableRowNode(row4, mojom::blink::AIPageContentTableRowType::kBody);
ASSERT_EQ(row4.children_nodes.size(), 4u);
const auto& row4_cell1 = *row4.children_nodes[0];
CheckTableCellNode(row4_cell1);
CheckTextNode(*row4_cell1.children_nodes[0], "Jane Smith");
const auto& row4_cell2 = *row4.children_nodes[1];
CheckTableCellNode(row4_cell2);
CheckTextNode(*row4_cell2.children_nodes[0], "28");
const auto& row4_cell3 = *row4.children_nodes[2];
CheckTableCellNode(row4_cell3);
CheckTextNode(*row4_cell3.children_nodes[0], "jane.smith@example.com");
const auto& row4_cell4 = *row4.children_nodes[3];
CheckTableCellNode(row4_cell4);
CheckTextNode(*row4_cell4.children_nodes[0], "987-654-3210");
}
TEST_F(AIPageContentAgentTest, FigureCaptionDisplayAsTableCaption) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body>
<figure style='display:table'>
<figcaption style='display:table-caption'>
<a href='https://www.youtube.com/'>Youtube</a>
</figcaption>
</figure>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& figure = *root.children_nodes.at(0);
ASSERT_TRUE(figure.content_attributes->node_interaction_info);
EXPECT_EQ(figure.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kTable);
ASSERT_EQ(figure.children_nodes.size(), 1u);
const auto& fig_caption = *figure.children_nodes.at(0);
ASSERT_TRUE(fig_caption.content_attributes->node_interaction_info);
EXPECT_EQ(fig_caption.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kContainer);
ASSERT_EQ(fig_caption.children_nodes.size(), 1u);
const auto& anchor = *fig_caption.children_nodes.at(0);
CheckAnchorNode(anchor, blink::KURL("https://www.youtube.com/"), {});
}
TEST_F(AIPageContentAgentTest, LandmarkSections) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <header>Header</header>"
" <nav>Navigation</nav>"
" <search>Search</search>"
" <main>Main content</main>"
" <article>Article</article>"
" <section>Section</section>"
" <aside>Aside</aside>"
" <footer>Footer</footer>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 8u);
const auto& header = *root.children_nodes[0];
CheckContainerNode(header);
CheckAnnotatedRole(header, mojom::blink::AIPageContentAnnotatedRole::kHeader);
CheckTextNode(*header.children_nodes[0], "Header");
const auto& nav = *root.children_nodes[1];
CheckContainerNode(nav);
CheckAnnotatedRole(nav, mojom::blink::AIPageContentAnnotatedRole::kNav);
CheckTextNode(*nav.children_nodes[0], "Navigation");
const auto& search = *root.children_nodes[2];
CheckContainerNode(search);
CheckAnnotatedRole(search, mojom::blink::AIPageContentAnnotatedRole::kSearch);
CheckTextNode(*search.children_nodes[0], "Search");
const auto& main = *root.children_nodes[3];
CheckContainerNode(main);
CheckAnnotatedRole(main, mojom::blink::AIPageContentAnnotatedRole::kMain);
CheckTextNode(*main.children_nodes[0], "Main content");
const auto& article = *root.children_nodes[4];
CheckContainerNode(article);
CheckAnnotatedRole(article,
mojom::blink::AIPageContentAnnotatedRole::kArticle);
CheckTextNode(*article.children_nodes[0], "Article");
const auto& section = *root.children_nodes[5];
CheckContainerNode(section);
CheckAnnotatedRole(section,
mojom::blink::AIPageContentAnnotatedRole::kSection);
CheckTextNode(*section.children_nodes[0], "Section");
const auto& aside = *root.children_nodes[6];
CheckContainerNode(aside);
CheckAnnotatedRole(aside, mojom::blink::AIPageContentAnnotatedRole::kAside);
CheckTextNode(*aside.children_nodes[0], "Aside");
const auto& footer = *root.children_nodes[7];
CheckContainerNode(footer);
CheckAnnotatedRole(footer, mojom::blink::AIPageContentAnnotatedRole::kFooter);
CheckTextNode(*footer.children_nodes[0], "Footer");
}
TEST_F(AIPageContentAgentTest, LandmarkSectionsWithAriaRoles) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div role='banner'>Header</div>"
" <div role='navigation'>Navigation</div>"
" <div role='search'>Search</div>"
" <div role='main'>Main content</div>"
" <div role='article'>Article</div>"
" <div role='region'>Section</div>"
" <div role='complementary'>Aside</div>"
" <div role='contentinfo'>Footer</div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 8u);
const auto& header = *root.children_nodes[0];
CheckContainerNode(header);
CheckAnnotatedRole(header, mojom::blink::AIPageContentAnnotatedRole::kHeader);
CheckTextNode(*header.children_nodes[0], "Header");
const auto& nav = *root.children_nodes[1];
CheckContainerNode(nav);
CheckAnnotatedRole(nav, mojom::blink::AIPageContentAnnotatedRole::kNav);
CheckTextNode(*nav.children_nodes[0], "Navigation");
const auto& search = *root.children_nodes[2];
CheckContainerNode(search);
CheckAnnotatedRole(search, mojom::blink::AIPageContentAnnotatedRole::kSearch);
CheckTextNode(*search.children_nodes[0], "Search");
const auto& main = *root.children_nodes[3];
CheckContainerNode(main);
CheckAnnotatedRole(main, mojom::blink::AIPageContentAnnotatedRole::kMain);
CheckTextNode(*main.children_nodes[0], "Main content");
const auto& article = *root.children_nodes[4];
CheckContainerNode(article);
CheckAnnotatedRole(article,
mojom::blink::AIPageContentAnnotatedRole::kArticle);
CheckTextNode(*article.children_nodes[0], "Article");
const auto& section = *root.children_nodes[5];
CheckContainerNode(section);
CheckAnnotatedRole(section,
mojom::blink::AIPageContentAnnotatedRole::kSection);
CheckTextNode(*section.children_nodes[0], "Section");
const auto& aside = *root.children_nodes[6];
CheckContainerNode(aside);
CheckAnnotatedRole(aside, mojom::blink::AIPageContentAnnotatedRole::kAside);
CheckTextNode(*aside.children_nodes[0], "Aside");
const auto& footer = *root.children_nodes[7];
CheckContainerNode(footer);
CheckAnnotatedRole(footer, mojom::blink::AIPageContentAnnotatedRole::kFooter);
CheckTextNode(*footer.children_nodes[0], "Footer");
}
TEST_F(AIPageContentAgentTest, LandmarkSectionsWithUppercaseAriaRoles) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div id='banner' role='BANNER'>Header</div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// ARIA role tokens are ASCII case-insensitive; APC should still annotate the
// landmark role even if authors use uppercase role names.
GetAIPageContent();
const auto* banner = FindNodeBySelector("#banner");
ASSERT_TRUE(banner);
CheckAnnotatedRoles(*banner,
{mojom::blink::AIPageContentAnnotatedRole::kHeader});
}
TEST_F(AIPageContentAgentTest, RootScroller) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style='margin: 0px;'>
<div style='width: 200vw; height: 300vh; background: grey;'></div>
<script>
document.scrollingElement.scrollTop=100;
document.scrollingElement.scrollLeft=200;
</script>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_TRUE(root.content_attributes->node_interaction_info);
ASSERT_TRUE(root.content_attributes->node_interaction_info->scroller_info);
const auto& root_scroller =
*root.content_attributes->node_interaction_info->scroller_info;
EXPECT_EQ(root_scroller.scrolling_bounds.width(), 2 * kWindowSize.width());
EXPECT_EQ(root_scroller.scrolling_bounds.height(), 3 * kWindowSize.height());
EXPECT_EQ(root_scroller.visible_area,
gfx::Rect(200, 100, kWindowSize.width(), kWindowSize.height()));
}
class AIPageContentAgentTestWithSubScroller
: public AIPageContentAgentTest,
public testing::WithParamInterface<std::string> {};
TEST_P(AIPageContentAgentTestWithSubScroller, Overflow) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
base::StringPrintf(
R"HTML(
<body style='margin: 0px;'>
<style>
#scroller {
overflow:%s; width: 100vw; height:100vh;
position:relative; top: 30px; left:50px;
}
</style>
<div id='scroller'>
<div style='width: 200vw; height: 300vh; background: grey;'></div>
</div>
<script>
let scroller = document.getElementById('scroller');
scroller.scrollTop=100;
scroller.scrollLeft=200;
</script>
</body>
)HTML",
GetParam()),
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_TRUE(root.content_attributes->node_interaction_info);
ASSERT_TRUE(root.content_attributes->node_interaction_info->scroller_info);
const auto& root_scroller =
*root.content_attributes->node_interaction_info->scroller_info;
EXPECT_EQ(root_scroller.scrolling_bounds.width(), kWindowSize.width() + 50);
EXPECT_EQ(root_scroller.scrolling_bounds.height(), kWindowSize.height() + 30);
EXPECT_EQ(root_scroller.visible_area, gfx::Rect(kWindowSize));
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& child = *root.children_nodes.at(0);
ASSERT_TRUE(child.content_attributes->node_interaction_info);
ASSERT_TRUE(child.content_attributes->node_interaction_info->scroller_info);
const auto& sub_scroller =
*child.content_attributes->node_interaction_info->scroller_info;
EXPECT_EQ(sub_scroller.scrolling_bounds.width(), 2 * kWindowSize.width());
EXPECT_EQ(sub_scroller.scrolling_bounds.height(), 3 * kWindowSize.height());
EXPECT_EQ(sub_scroller.visible_area,
gfx::Rect(200, 100, kWindowSize.width(), kWindowSize.height()));
bool user_scrollable = GetParam() != "hidden";
EXPECT_EQ(sub_scroller.user_scrollable_horizontal, user_scrollable);
EXPECT_EQ(sub_scroller.user_scrollable_vertical, user_scrollable);
}
INSTANTIATE_TEST_SUITE_P(AIPageContentAgentTestWithSubScroller,
AIPageContentAgentTestWithSubScroller,
::testing::Values("auto", "scroll", "hidden"));
TEST_F(AIPageContentAgentTest, OverflowVisible) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style='margin: 0px;'>
<style>
#scroller {
overflow:visible; width: 100vw; height:100vh;
position:relative; top: 30px; left:50px;
}
</style>
<div id='scroller'>
<div style='width: 200vw; height: 300vh; background: grey;'></div>
</div>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_TRUE(root.content_attributes->node_interaction_info);
ASSERT_TRUE(root.content_attributes->node_interaction_info->scroller_info);
const auto& root_scroller =
*root.content_attributes->node_interaction_info->scroller_info;
EXPECT_EQ(root_scroller.scrolling_bounds.width(),
kWindowSize.width() * 2 + 50);
EXPECT_EQ(root_scroller.scrolling_bounds.height(),
kWindowSize.height() * 3 + 30);
EXPECT_EQ(root_scroller.visible_area, gfx::Rect(kWindowSize));
EXPECT_EQ(root.children_nodes.size(), 0u);
}
TEST_F(AIPageContentAgentTest, OverflowClip) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style='margin: 0px;'>
<style>
#scroller {
overflow:clip; width: 100vw; height:100vh;
position:relative; top: 30px; left:50px;
}
</style>
<div id='scroller'>
<div style='width: 200vw; height: 300vh; background: grey;'></div>
</div>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_TRUE(root.content_attributes->node_interaction_info);
ASSERT_TRUE(root.content_attributes->node_interaction_info->scroller_info);
const auto& root_scroller =
*root.content_attributes->node_interaction_info->scroller_info;
EXPECT_EQ(root_scroller.scrolling_bounds.width(), kWindowSize.width() + 50);
EXPECT_EQ(root_scroller.scrolling_bounds.height(), kWindowSize.height() + 30);
EXPECT_EQ(root_scroller.visible_area, gfx::Rect(kWindowSize));
EXPECT_EQ(root.children_nodes.size(), 0u);
}
TEST_F(AIPageContentAgentTest, Anchors) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <a href='https://www.google.com'>Google</a>"
" <a href='https://www.youtube.com' rel='noopener "
"noreferrer'>YouTube</a>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 2u);
const auto& link = *root.children_nodes[0];
CheckAnchorNode(link, blink::KURL("https://www.google.com/"), {});
const auto& link_text = *link.children_nodes[0];
CheckTextNode(link_text, "Google");
const auto& link_with_rel = *root.children_nodes[1];
CheckAnchorNode(link_with_rel, blink::KURL("https://www.youtube.com/"),
{mojom::blink::AIPageContentAnchorRel::kRelationNoOpener,
mojom::blink::AIPageContentAnchorRel::kRelationNoReferrer});
const auto& link_with_rel_text = *link_with_rel.children_nodes[0];
CheckTextNode(link_with_rel_text, "YouTube");
}
TEST_F(AIPageContentAgentTest, NativeModalDialog) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <dialog id='welcomeDialog' style='position: absolute; overflow: "
"visible;'>This is a dialog.</dialog>"
" <script>"
" const dialog = document.getElementById('welcomeDialog');"
" dialog.showModal();"
" </script>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
// Two nodes: the dialog and its backdrop.
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 2u);
const auto& backdrop = *root.children_nodes[0];
CheckContainerNode(backdrop);
const auto& dialog = *root.children_nodes[1];
CheckModalDialogNode(dialog);
ASSERT_EQ(dialog.children_nodes.size(), 1u);
const auto& dialog_text = *dialog.children_nodes[0];
CheckTextNode(dialog_text, "This is a dialog.");
}
TEST_F(AIPageContentAgentTest, NativeModelessDialog) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <dialog id='welcomeDialog'>This is a modeless dialog.</dialog>"
" <script>"
" const dialog = document.getElementById('welcomeDialog');"
" dialog.show();"
" </script>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& dialog = *root.children_nodes[0];
CheckModelessDialogNode(dialog);
ASSERT_EQ(dialog.children_nodes.size(), 1u);
const auto& dialog_text = *dialog.children_nodes[0];
CheckTextNode(dialog_text, "This is a modeless dialog.");
}
TEST_F(AIPageContentAgentTest, AriaModalDialog) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div role='dialog' aria-modal='true'>This is an ARIA modal dialog."
"</div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& dialog = *root.children_nodes[0];
CheckModalDialogNode(dialog);
ASSERT_EQ(dialog.children_nodes.size(), 1u);
const auto& dialog_text = *dialog.children_nodes[0];
CheckTextNode(dialog_text, "This is an ARIA modal dialog.");
}
TEST_F(AIPageContentAgentTest, AriaModelessDialog) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div role='dialog'>This is an ARIA modeless dialog.</div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& dialog = *root.children_nodes[0];
CheckModelessDialogNode(dialog);
ASSERT_EQ(dialog.children_nodes.size(), 1u);
const auto& dialog_text = *dialog.children_nodes[0];
CheckTextNode(dialog_text, "This is an ARIA modeless dialog.");
}
TEST_F(AIPageContentAgentTest, AriaAlertDialogDefaultsToModal) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div role='alertdialog'>This is an ARIA alert dialog.</div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& dialog = *root.children_nodes[0];
CheckModalDialogNode(dialog);
ASSERT_EQ(dialog.children_nodes.size(), 1u);
const auto& dialog_text = *dialog.children_nodes[0];
CheckTextNode(dialog_text, "This is an ARIA alert dialog.");
}
TEST_F(AIPageContentAgentTest, TableWithAnonymousCells) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<html>"
" <style>"
" #target {"
" display: table;"
" }"
""
" #target::before {"
" content: 'BEFORE';"
" display: table;"
" }"
" </style>"
" <div id='target'></div>"
"</html>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& outer_table = *root.children_nodes[0];
const auto& outer_table_cell = GetSingleTableCell(outer_table);
ASSERT_EQ(outer_table_cell.children_nodes.size(), 1u);
const auto& inner_table = *outer_table_cell.children_nodes[0];
const auto& inner_table_cell = GetSingleTableCell(inner_table);
EXPECT_EQ(inner_table_cell.children_nodes.size(), 1u);
CheckTextNode(*inner_table_cell.children_nodes[0], "BEFORE");
}
TEST_F(AIPageContentAgentTest, ContentVisibilityHidden) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style>"
" div {"
" content-visibility: hidden"
" }"
" </style>"
" <div>text</div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
const auto& hidden_container = *root.children_nodes[0];
CheckContainerNode(hidden_container);
CheckAnnotatedRole(hidden_container,
mojom::blink::AIPageContentAnnotatedRole::kContentHidden);
EXPECT_TRUE(hidden_container.children_nodes.empty());
}
TEST_F(AIPageContentAgentTest, ContentVisibilityHiddenActionable) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style>"
" #hidden {"
" content-visibility: hidden"
" }"
" </style>"
" <div id=hidden>hidden text</div>visible text"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// Actionable mode exercises geometry and hit-testing paths used in
// production APC requests.
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 2u);
const auto& hidden_container = *root.children_nodes[0];
CheckContainerNode(hidden_container);
CheckAnnotatedRole(hidden_container,
mojom::blink::AIPageContentAnnotatedRole::kContentHidden);
// Content-visibility hidden subtrees are represented but not expanded.
EXPECT_TRUE(hidden_container.children_nodes.empty());
// The container itself is laid out; actionable mode should capture its
// geometry without forcing layout on descendants.
EXPECT_TRUE(hidden_container.content_attributes->geometry);
const auto& visible_text_node = *root.children_nodes[1];
CheckTextNode(visible_text_node, "visible text");
ASSERT_TRUE(visible_text_node.content_attributes->geometry);
EXPECT_FALSE(visible_text_node.content_attributes->geometry
->visible_bounding_box.IsEmpty());
}
TEST_F(AIPageContentAgentTest, ContentVisibilityHiddenIframeActionable) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body><style>iframe { content-visibility: hidden }</style>"
"<iframe srcdoc='<div>hidden iframe text</div>'></iframe>"
" visible text</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// Actionable mode exercises iframe geometry and traversal paths.
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 2u);
const auto& iframe_node = *root.children_nodes[0];
CheckIframeNode(iframe_node);
CheckAnnotatedRole(iframe_node,
mojom::blink::AIPageContentAnnotatedRole::kContentHidden);
// The iframe container is laid out, but its subtree should not be traversed
// when display locks block layout/prepaint on children.
EXPECT_TRUE(iframe_node.children_nodes.empty());
ASSERT_TRUE(iframe_node.content_attributes->geometry);
ASSERT_TRUE(iframe_node.content_attributes->iframe_data->content);
EXPECT_TRUE(iframe_node.content_attributes->iframe_data->content
->is_local_frame_data());
// Display-locked iframes still provide frame data for browser conversion.
EXPECT_GT(iframe_node.content_attributes->iframe_data->content
->get_local_frame_data()
->default_line_height_px,
0u);
const auto& visible_text_node = *root.children_nodes[1];
CheckTextNode(visible_text_node, " visible text");
ASSERT_TRUE(visible_text_node.content_attributes->geometry);
EXPECT_FALSE(visible_text_node.content_attributes->geometry
->visible_bounding_box.IsEmpty());
}
TEST_F(AIPageContentAgentTest, ContentVisibilityAuto) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style>"
" #foo {"
" position: relative;"
" top: 8000px;"
" content-visibility: auto"
" }"
" </style>"
" <div id=foo><div>far text</div></div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
const auto& text_node = *root.children_nodes[0];
CheckTextNode(text_node, "far text");
const auto& attributes = *text_node.content_attributes;
EXPECT_TRUE(attributes.dom_node_id.has_value());
}
TEST_F(AIPageContentAgentTest, HiddenUntilFound) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style>"
" body {"
" margin: 0; font-size: 100px;"
" }"
" </style>"
" <header hidden=until-found>hidden text</header><div>visible text</div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 2u);
const auto& hidden_container = *root.children_nodes[0];
CheckContainerNode(hidden_container);
CheckAnnotatedRoles(
hidden_container,
{mojom::blink::AIPageContentAnnotatedRole::kHeader,
mojom::blink::AIPageContentAnnotatedRole::kContentHidden});
EXPECT_EQ(hidden_container.children_nodes.size(), 1u);
const auto& hidden_text_node = *hidden_container.children_nodes[0];
CheckTextNode(hidden_text_node, "hidden text");
const auto& visible_text_node = *root.children_nodes[1];
CheckTextNode(visible_text_node, "visible text");
}
TEST_F(AIPageContentAgentTest, HiddenUntilFoundNoInvalidationAllowed) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body>
<style>
body {
margin: 0; font-size: 100px;
}
</style>
<header hidden=until-found>hidden text</header><div>visible text</div>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
LocalFrameView::InvalidationDisallowedScope disallow(
*helper_.LocalMainFrame()->GetFrame()->View());
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 2u);
const auto& hidden_container = *root.children_nodes[0];
CheckContainerNode(hidden_container);
CheckAnnotatedRoles(
hidden_container,
{mojom::blink::AIPageContentAnnotatedRole::kHeader,
mojom::blink::AIPageContentAnnotatedRole::kContentHidden});
EXPECT_TRUE(hidden_container.children_nodes.empty());
const auto& visible_text_node = *root.children_nodes[1];
CheckTextNode(visible_text_node, "visible text");
}
TEST_F(AIPageContentAgentTest, HiddenUntilFoundGeometry) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style>"
" body {"
" margin: 0; font-size: 100px;"
" }"
" </style>"
" <header hidden=until-found>hidden text</header>visible text"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 2u);
const auto& hidden_container = *root.children_nodes[0];
CheckContainerNode(hidden_container);
CheckAnnotatedRoles(
hidden_container,
{mojom::blink::AIPageContentAnnotatedRole::kHeader,
mojom::blink::AIPageContentAnnotatedRole::kContentHidden});
EXPECT_EQ(hidden_container.children_nodes.size(), 1u);
ASSERT_TRUE(hidden_container.content_attributes->geometry);
const auto& hidden_container_geometry =
*hidden_container.content_attributes->geometry;
const auto& hidden_text_node = *hidden_container.children_nodes[0];
CheckTextNode(hidden_text_node, "hidden text");
ASSERT_TRUE(hidden_text_node.content_attributes->geometry);
const auto& hidden_text_geometry =
*hidden_text_node.content_attributes->geometry;
CheckAlmostEquals(hidden_text_geometry.outer_bounding_box.origin(),
gfx::Point(0, 0));
EXPECT_FALSE(hidden_text_geometry.outer_bounding_box.IsEmpty());
EXPECT_TRUE(hidden_text_geometry.visible_bounding_box.IsEmpty());
// The container's own layout size is empty. APC repairs its outer box from
// the hidden descendant, but does not give it visible geometry or fragments.
EXPECT_EQ(hidden_container_geometry.outer_bounding_box,
hidden_text_geometry.outer_bounding_box);
EXPECT_TRUE(hidden_container_geometry.visible_bounding_box.IsEmpty());
EXPECT_TRUE(
hidden_container_geometry.fragment_visible_bounding_boxes.empty());
const auto& visible_text_node = *root.children_nodes[1];
CheckTextNode(visible_text_node, "visible text");
EXPECT_TRUE(visible_text_node.content_attributes->geometry);
const auto& visible_text_geometry =
*visible_text_node.content_attributes->geometry;
CheckAlmostEquals(visible_text_geometry.outer_bounding_box.origin(),
gfx::Point(0, 0));
EXPECT_FALSE(visible_text_geometry.outer_bounding_box.IsEmpty());
EXPECT_EQ(visible_text_geometry.outer_bounding_box,
visible_text_geometry.visible_bounding_box);
}
TEST_F(AIPageContentAgentTest, HiddenUntilFoundInsideIframe) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style>"
" body {"
" margin: 0;"
" font-size: 100px;"
" }"
" </style>"
" <iframe srcdoc='<div hidden=until-found>hidden "
"text</div>'></iframe>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
const auto& iframe_node = *root.children_nodes[0];
EXPECT_EQ(iframe_node.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kIframe);
EXPECT_EQ(iframe_node.children_nodes.size(), 1u);
const auto& iframe_root = *iframe_node.children_nodes[0];
EXPECT_EQ(iframe_root.children_nodes.size(), 1u);
const auto& hidden_container = *iframe_root.children_nodes[0];
CheckContainerNode(hidden_container);
CheckAnnotatedRole(hidden_container,
mojom::blink::AIPageContentAnnotatedRole::kContentHidden);
EXPECT_EQ(hidden_container.children_nodes.size(), 1u);
const auto& hidden_text_node = *hidden_container.children_nodes[0];
CheckTextNode(hidden_text_node, "hidden text");
}
TEST_F(AIPageContentAgentTest, HiddenUntilFoundOnIframe) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style>"
" body {"
" margin: 0;"
" font-size: 100px;"
" }"
" </style>"
" <iframe hidden=until-found srcdoc='<div>hidden "
"text</div>'></iframe>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
const auto& iframe_node = *root.children_nodes[0];
EXPECT_EQ(iframe_node.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kIframe);
EXPECT_EQ(iframe_node.children_nodes.size(), 1u);
const auto& iframe_root = *iframe_node.children_nodes[0];
const auto& hidden_text_node = *iframe_root.children_nodes[0];
CheckTextNode(hidden_text_node, "hidden text");
}
#if DCHECK_IS_ON()
TEST_F(AIPageContentAgentTest, AutoBuildRunsDuringDOMContentLoadedDispatch) {
base::test::MockLog log;
// Allow unrelated INFO logs; we only assert on the auto-build dump below.
EXPECT_CALL(log, Log(logging::LOGGING_INFO, testing::_, testing::_,
testing::_, testing::Not(testing::HasSubstr("<Root>"))))
.Times(testing::AnyNumber());
// Start capturing early so we can assert that no auto-build dump appears
// until we invoke RunAutoBuildAfterDOMContentLoadedForTesting().
log.StartCapturingLogs();
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), "<body><div>Auto build content</div></body>",
url_test_helpers::ToKURL("http://example.com"));
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
LocalFrameView* view = document->View();
ASSERT_TRUE(view);
// Establish a stable lifecycle baseline so the auto-build checks are not
// influenced by unrelated pending layout or style updates.
view->UpdateAllLifecyclePhasesForTest();
auto* agent = AIPageContentAgent::GetOrCreateForTesting(*document);
ASSERT_TRUE(agent);
EXPECT_EQ(AIPageContentAgent::From(*document), agent);
// Simulate DOMContentLoaded dispatch still being in progress by forcing the
// parsing state to "in DCL".
document->SetParsingState(Document::kInDOMContentLoaded);
// Auto-build should not run until we explicitly invoke it below. This
// enforces that the unit-test guard in ListenForDOMContentLoadedForAutoBuild
// keeps auto-build from being scheduled implicitly in blink_unittests.
EXPECT_CALL(log, Log(logging::LOGGING_INFO, testing::_, testing::_,
testing::_, testing::HasSubstr("<Root>")))
.Times(0);
test::RunPendingTasks();
log.StopCapturingLogs();
testing::Mock::VerifyAndClearExpectations(&log);
EXPECT_CALL(log, Log(logging::LOGGING_INFO, testing::_, testing::_,
testing::_, testing::Not(testing::HasSubstr("<Root>"))))
.Times(testing::AnyNumber());
// Invoke the auto-build entry point directly to keep the test deterministic;
// it should run while DOMContentLoaded dispatch is in progress.
EXPECT_CALL(log, Log(logging::LOGGING_INFO, testing::_, testing::_,
testing::_, testing::HasSubstr("<Root>")))
.Times(1);
log.StartCapturingLogs();
agent->RunAutoBuildAfterDOMContentLoadedForTesting();
test::RunPendingTasks();
log.StopCapturingLogs();
testing::Mock::VerifyAndClearExpectations(&log);
// Auto-build runs while parsing is still in the DOMContentLoaded phase.
// Once parsing is finished and lifecycle updates complete, auto-build should
// still run without triggering lifecycle DCHECKs.
document->SetParsingState(Document::kFinishedParsing);
view->UpdateAllLifecyclePhasesForTest();
test::RunPendingTasks();
WebViewImpl* web_view = document->GetPage()->GetChromeClient().GetWebView();
ASSERT_TRUE(web_view);
EXPECT_FALSE(web_view->GetPagePopup());
// Re-run the auto-build entry point now that parsing is finished. This should
// synchronously execute the auto-build path without crashing.
EXPECT_CALL(log, Log(logging::LOGGING_INFO, testing::_, testing::_,
testing::_, testing::HasSubstr("<Root>")))
.Times(1);
log.StartCapturingLogs();
agent->RunAutoBuildAfterDOMContentLoadedForTesting();
test::RunPendingTasks();
log.StopCapturingLogs();
}
#endif // #if DCHECK_IS_ON()
TEST_F(AIPageContentAgentTest, LineBreak) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
"<div style=\"width: 100px; height:100px\">"
"Lorem Ipsum is simply dummy text of the printing and "
"typesetting industry.<br>Lorem Ipsum has been the "
"industry's standard dummy text ever since the 1500s, "
"when an unknown printer took a galley of type and "
"scrambled it to make a type specimen book. It has "
"survived not only five centuries, but also the leap "
"into electronic typesetting, remaining essentially "
"unchanged. It was popularised in the 1960s with the "
"release of Letraset sheets containing Lorem Ipsum "
"passages, and more recently with desktop publishing "
"software like Aldus PageMaker including versions of "
"Lorem Ipsum."
"</div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 2u);
CheckTextNode(*root.children_nodes[0],
"Lorem Ipsum is simply dummy text of the printing and "
"typesetting industry.");
CheckTextNode(
*root.children_nodes[1],
"Lorem Ipsum has been the industry's standard dummy text ever since the "
"1500s, when an unknown printer took a galley of type and scrambled it "
"to make a type specimen book. It has survived not only five centuries, "
"but also the leap into electronic typesetting, remaining essentially "
"unchanged. It was popularised in the 1960s with the release of Letraset "
"sheets containing Lorem Ipsum passages, and more recently with desktop "
"publishing software like Aldus PageMaker including versions of Lorem "
"Ipsum.");
}
TEST_F(AIPageContentAgentTest, VisibilityHiddenOnSubtree) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style>"
" header {"
" visibility: hidden"
" }"
" </style>"
" <header>text</header>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 0u);
}
TEST_F(AIPageContentAgentTest, VisibilityHiddenOnParentOnly) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style>"
" #parent {"
" visibility: hidden"
" }"
" #child {"
" visibility: visible"
" }"
" </style>"
" <header id=parent><div id=child>text</div></header>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
const auto& container = *root.children_nodes[0];
CheckContainerNode(container);
EXPECT_EQ(container.children_nodes.size(), 1u);
const auto& text_node = *container.children_nodes[0];
CheckTextNode(text_node, "text");
}
TEST_F(AIPageContentAgentTest, VisibilityHiddenOnIframe) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style>"
" iframe {"
" visibility: hidden;"
" }"
" </style>"
" <iframe srcdoc='<div style='visibility: visible'>hidden "
"text</div>'></iframe>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 0u);
}
TEST_F(AIPageContentAgentTest,
SkipUnclickableFixedOverlaysRespectsFeatureFlag) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style="margin: 0;">
<div id="unclickable"
style="position: fixed; inset: 0; pointer-events: none;">
Readable text
</div>
<div id="clickable" style="position: fixed;"></div>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
{
ScopedAIPageContentSkipUnclickableFixedOverlaysForTest disable_feature(
false);
GetAIPageContentWithActionableElements();
// Preserve the legacy output while the feature is off.
EXPECT_TRUE(FindNodeBySelector("#unclickable"));
}
{
ScopedAIPageContentSkipUnclickableFixedOverlaysForTest enable_feature(true);
GetAIPageContentWithActionableElements();
// Flatten the pointer-transparent wrapper without dropping its text.
ExpectSelectorNotInApcOutput("#unclickable");
EXPECT_TRUE(TreeContainsTextSubstring(ContentRootNode(), "Readable text"));
EXPECT_TRUE(FindNodeBySelector("#clickable"));
}
}
TEST_F(AIPageContentAgentTest, NoGeometry) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div>text</div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_FALSE(root.content_attributes->geometry);
EXPECT_EQ(root.children_nodes.size(), 1u);
const auto& text_node = *root.children_nodes[0];
CheckTextNode(text_node, "text");
EXPECT_FALSE(text_node.content_attributes->geometry);
}
TEST_F(AIPageContentAgentTest, FormWithTextInput) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <form name='myform' action='https://example.com/submit'>"
" <label for='input1'>Lorem Ipsum</label>"
" <input type='text' id='input1' name='LI' value='Lorem'>"
" <label for='input2'>Ipsum Dolor</label>"
" <input type='text' id='input2' name='ID' value='Ipsum' required>"
" </form>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
auto* agent = AIPageContentAgent::GetOrCreateForTesting(
*helper_.LocalMainFrame()->GetFrame()->GetDocument());
ASSERT_TRUE(agent);
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
const auto& form = *root.children_nodes[0];
EXPECT_EQ(form.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kForm);
EXPECT_EQ(form.content_attributes->form_data->form_name, "myform");
// The form metadata must expose the normalized action URL so callers can
// surface the submit destination.
EXPECT_EQ(form.content_attributes->form_data->action_url,
url_test_helpers::ToKURL("https://example.com/submit"));
EXPECT_EQ(form.children_nodes.size(), 4u);
CheckTextNode(*form.children_nodes[0], "Lorem Ipsum");
const auto& text_input1 = *form.children_nodes[1];
CheckFormControlNode(text_input1, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(text_input1.content_attributes->form_control_data->field_name,
"LI");
EXPECT_EQ(text_input1.content_attributes->form_control_data->field_value,
"Lorem");
EXPECT_EQ(text_input1.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kNoRedactionNecessary);
EXPECT_FALSE(text_input1.content_attributes->form_control_data->is_required);
EXPECT_EQ(text_input1.children_nodes.size(), 1u);
CheckContainerNode(*text_input1.children_nodes[0]);
EXPECT_EQ(text_input1.children_nodes[0]->children_nodes.size(), 1u);
CheckTextNode(*text_input1.children_nodes[0]->children_nodes[0], "Lorem");
CheckTextNode(*form.children_nodes[2], "Ipsum Dolor");
const auto& text_input2 = *form.children_nodes[3];
CheckFormControlNode(text_input2, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(text_input2.content_attributes->form_control_data->field_name,
"ID");
EXPECT_EQ(text_input2.content_attributes->form_control_data->field_value,
"Ipsum");
EXPECT_EQ(text_input2.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kNoRedactionNecessary);
EXPECT_TRUE(text_input2.content_attributes->form_control_data->is_required);
EXPECT_EQ(text_input2.children_nodes.size(), 1u);
CheckContainerNode(*text_input2.children_nodes[0]);
EXPECT_EQ(text_input2.children_nodes[0]->children_nodes.size(), 1u);
CheckTextNode(*text_input2.children_nodes[0]->children_nodes[0], "Ipsum");
}
TEST_F(AIPageContentAgentTest, AriaRequiredAndPlaceholderOnTextInput) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <input id='input' type='text' aria-required='true' "
"aria-placeholder='Type here'>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// ARIA required/placeholder should be surfaced for native text inputs when
// the native attributes are not present, so we pick up author-provided ARIA
// metadata without overriding native semantics.
GetAIPageContent();
const auto* input = FindNodeBySelector("#input");
ASSERT_TRUE(input);
CheckFormControlNode(*input, mojom::blink::FormControlType::kInputText);
EXPECT_TRUE(input->content_attributes->form_control_data->is_required);
EXPECT_EQ(input->content_attributes->form_control_data->placeholder,
"Type here");
}
TEST_F(AIPageContentAgentTest, AriaCheckedOnRoleCheckbox) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div id='checkbox' role='checkbox' aria-checked='true'>"
" ARIA Checkbox"
" </div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// ARIA-based controls should produce form control data when aria-checked is
// provided, enabling actionable extraction for custom widgets.
GetAIPageContent();
const auto* checkbox = FindNodeBySelector("#checkbox");
ASSERT_TRUE(checkbox);
CheckFormControlNode(*checkbox,
mojom::blink::FormControlType::kInputCheckbox);
EXPECT_TRUE(checkbox->content_attributes->form_control_data->is_checked);
}
TEST_F(AIPageContentAgentTest, AriaRoleCheckboxWithoutProperties) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div id='checkbox' role='checkbox'>"
" ARIA Checkbox"
" </div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// An explicit ARIA role should be enough to surface form control metadata,
// even when no other ARIA state or property is provided.
GetAIPageContent();
const auto* checkbox = FindNodeBySelector("#checkbox");
ASSERT_TRUE(checkbox);
CheckFormControlNode(*checkbox,
mojom::blink::FormControlType::kInputCheckbox);
}
TEST_F(AIPageContentAgentTest, AriaCheckedMixedOnRoleCheckbox) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div id='checkbox' role='checkbox' aria-checked='mixed'>"
" ARIA Checkbox"
" </div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// Keep aria-checked handling simple by treating "mixed" as a truthy state
// when the attribute is present, matching IsAriaAttributeTrue semantics.
GetAIPageContent();
const auto* checkbox = FindNodeBySelector("#checkbox");
ASSERT_TRUE(checkbox);
CheckFormControlNode(*checkbox,
mojom::blink::FormControlType::kInputCheckbox);
EXPECT_TRUE(checkbox->content_attributes->form_control_data->is_checked);
}
TEST_F(AIPageContentAgentTest, AriaReadOnlyOnRoleTextbox) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div id='textbox' role='textbox' aria-readonly='true'>"
" ARIA Textbox"
" </div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// Readonly state should be exposed for ARIA-based textboxes so consumers can
// avoid actions which would try to edit locked content.
GetAIPageContent();
const auto* textbox = FindNodeBySelector("#textbox");
ASSERT_TRUE(textbox);
CheckFormControlNode(*textbox, mojom::blink::FormControlType::kInputText);
EXPECT_TRUE(textbox->content_attributes->form_control_data->is_readonly);
}
TEST_F(AIPageContentAgentTest, NativeReadOnlyTextInput) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <input id='input' type='text' readonly>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// Native readonly must be surfaced so consumers can avoid editing.
GetAIPageContent();
const auto* input = FindNodeBySelector("#input");
ASSERT_TRUE(input);
CheckFormControlNode(*input, mojom::blink::FormControlType::kInputText);
EXPECT_TRUE(input->content_attributes->form_control_data->is_readonly);
}
TEST_F(AIPageContentAgentTest, FrameDefaultLineHeightUsesCssPixels) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<html style='font-size: 10px; line-height: 24px; zoom: 2'>"
"<body>"
// CSS zoom scales layout values internally, so this verifies APC reports
// the author-facing CSS px value rather than the zoomed layout value.
" <input id='input' type='text' "
"style='font-size: 10px'>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto* input = FindNodeBySelector("#input");
ASSERT_TRUE(input);
CheckFormControlNode(*input, mojom::blink::FormControlType::kInputText);
// The default line height is frame data because popups are frame-owned.
EXPECT_EQ(Content()->frame_data->default_line_height_px, 24u);
}
TEST_F(AIPageContentAgentTest,
FrameDefaultLineHeightFallsBackWithoutDocumentElementLayoutObject) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<html style='display: none'>"
"<body>"
// The <html> element has no LayoutObject when it is display:none.
// APC should still emit frame data instead of crashing the renderer.
" <input id='input' type='text'>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
ASSERT_TRUE(Content()->frame_data);
// Without a <html> LayoutObject, APC uses a fixed fallback row size.
EXPECT_EQ(Content()->frame_data->default_line_height_px, 12u);
}
TEST_F(AIPageContentAgentTest,
FrameDefaultLineHeightFallsBackWithoutDocumentElement) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<html>"
"<body>"
" <input id='input' type='text'>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
ASSERT_TRUE(document->documentElement());
// APC can still serialize from the frame LayoutView after script removes
// the document element, but no author line-height source remains.
document->documentElement()->remove();
GetAIPageContent();
ASSERT_TRUE(Content()->frame_data);
EXPECT_EQ(Content()->frame_data->default_line_height_px, 12u);
}
TEST_F(AIPageContentAgentTest, AriaCheckedDoesNotOverrideNativeCheckbox) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <input id='native' type='checkbox' checked aria-checked='false'>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// Native checked state must remain authoritative over aria-checked, matching
// the HTML-AAM rule that ARIA must not override strong native semantics.
GetAIPageContent();
const auto* native_checkbox = FindNodeBySelector("#native");
ASSERT_TRUE(native_checkbox);
CheckFormControlNode(*native_checkbox,
mojom::blink::FormControlType::kInputCheckbox);
EXPECT_TRUE(
native_checkbox->content_attributes->form_control_data->is_checked);
}
TEST_F(AIPageContentAgentTest, FormWithSelect) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <form name='myform'>"
" <select name='LI'>"
" <option value='Lorem'>Lorem Text</option>"
" <option value='Ipsum'>Ipsum Text</option>"
" </select>"
" </form>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
auto* agent = AIPageContentAgent::GetOrCreateForTesting(
*helper_.LocalMainFrame()->GetFrame()->GetDocument());
ASSERT_TRUE(agent);
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
const auto& form = *root.children_nodes[0];
EXPECT_EQ(form.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kForm);
EXPECT_EQ(form.content_attributes->form_data->form_name, "myform");
EXPECT_EQ(form.children_nodes.size(), 1u);
const auto& select = *form.children_nodes[0];
CheckFormControlNode(select, mojom::blink::FormControlType::kSelectOne);
EXPECT_EQ(select.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kNoRedactionNecessary);
const auto& select_options =
select.content_attributes->form_control_data->select_options;
ASSERT_EQ(select_options.size(), 2u);
EXPECT_EQ(select_options[0]->value, "Lorem");
EXPECT_EQ(select_options[0]->text, "Lorem Text");
EXPECT_TRUE(select_options[0]->is_selected);
EXPECT_EQ(select_options[1]->value, "Ipsum");
EXPECT_EQ(select_options[1]->text, "Ipsum Text");
EXPECT_FALSE(select_options[1]->is_selected);
EXPECT_EQ(select.children_nodes.size(), 1u);
CheckTextNode(*select.children_nodes[0], "Lorem Text");
}
TEST_F(AIPageContentAgentTest, FormWithCheckbox) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <form name='vehicles'>"
" <input type='checkbox' id='vehicle1' name='vehicle1' value='Bike'>"
" <label for='vehicle1'>I have a bike</label><br>"
" <input type='checkbox' id='vehicle2' name='vehicle2' value='Car' "
" checked>"
" <label for='vehicle2'>I have a car</label><br>"
" </form>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
auto* agent = AIPageContentAgent::GetOrCreateForTesting(
*helper_.LocalMainFrame()->GetFrame()->GetDocument());
ASSERT_TRUE(agent);
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
const auto& form = *root.children_nodes[0];
EXPECT_EQ(form.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kForm);
EXPECT_EQ(form.content_attributes->form_data->form_name, "vehicles");
EXPECT_EQ(form.children_nodes.size(), 4u);
const auto& checkbox1 = *form.children_nodes[0];
CheckFormControlNode(checkbox1,
mojom::blink::FormControlType::kInputCheckbox);
EXPECT_EQ(checkbox1.content_attributes->form_control_data->field_name,
"vehicle1");
EXPECT_EQ(checkbox1.content_attributes->form_control_data->field_value,
"Bike");
EXPECT_FALSE(checkbox1.content_attributes->form_control_data->is_checked);
EXPECT_EQ(checkbox1.children_nodes.size(), 0u);
CheckTextNode(*form.children_nodes[1], "I have a bike");
const auto& checkbox2 = *form.children_nodes[2];
CheckFormControlNode(checkbox2,
mojom::blink::FormControlType::kInputCheckbox);
EXPECT_EQ(checkbox2.content_attributes->form_control_data->field_name,
"vehicle2");
EXPECT_EQ(checkbox2.content_attributes->form_control_data->field_value,
"Car");
EXPECT_TRUE(checkbox2.content_attributes->form_control_data->is_checked);
EXPECT_EQ(checkbox2.children_nodes.size(), 0u);
CheckTextNode(*form.children_nodes[3], "I have a car");
}
TEST_F(AIPageContentAgentTest, FormWithRadio) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <form name='vehicles'>"
" <input type='radio' id='vehicle1' name='vehicle1' value='Bike'>"
" <label for='vehicle1'>I have a bike</label><br>"
" <input type='radio' id='vehicle2' name='vehicle2' value='Car' "
" checked>"
" <label for='vehicle2'>I have a car</label><br>"
" </form>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
auto* agent = AIPageContentAgent::GetOrCreateForTesting(
*helper_.LocalMainFrame()->GetFrame()->GetDocument());
ASSERT_TRUE(agent);
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
const auto& form = *root.children_nodes[0];
EXPECT_EQ(form.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kForm);
EXPECT_EQ(form.content_attributes->form_data->form_name, "vehicles");
EXPECT_EQ(form.children_nodes.size(), 4u);
const auto& radio1 = *form.children_nodes[0];
CheckFormControlNode(radio1, mojom::blink::FormControlType::kInputRadio);
EXPECT_EQ(radio1.content_attributes->form_control_data->field_name,
"vehicle1");
EXPECT_EQ(radio1.content_attributes->form_control_data->field_value, "Bike");
EXPECT_EQ(radio1.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kNoRedactionNecessary);
EXPECT_FALSE(radio1.content_attributes->form_control_data->is_checked);
EXPECT_EQ(radio1.children_nodes.size(), 0u);
CheckTextNode(*form.children_nodes[1], "I have a bike");
const auto& radio2 = *form.children_nodes[2];
CheckFormControlNode(radio2, mojom::blink::FormControlType::kInputRadio);
EXPECT_EQ(radio2.content_attributes->form_control_data->field_name,
"vehicle2");
EXPECT_EQ(radio2.content_attributes->form_control_data->field_value, "Car");
EXPECT_TRUE(radio2.content_attributes->form_control_data->is_checked);
EXPECT_EQ(radio2.children_nodes.size(), 0u);
CheckTextNode(*form.children_nodes[3], "I have a car");
}
TEST_F(AIPageContentAgentTest, FormWithPassword) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <form>"
" <input id='pwd' type='password' name='Enter password' "
"value='mypassword'>"
" </form>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
auto* agent = AIPageContentAgent::GetOrCreateForTesting(
*helper_.LocalMainFrame()->GetFrame()->GetDocument());
ASSERT_TRUE(agent);
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
const auto& form = *root.children_nodes[0];
EXPECT_EQ(form.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kForm);
EXPECT_EQ(form.children_nodes.size(), 1u);
const auto& password = *form.children_nodes[0];
CheckFormControlNode(password, mojom::blink::FormControlType::kInputPassword);
EXPECT_EQ(password.content_attributes->form_control_data->field_name,
"Enter password");
EXPECT_EQ(password.content_attributes->form_control_data->field_value,
nullptr);
EXPECT_EQ(password.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kRedacted_HasBeenPassword);
EXPECT_EQ(password.children_nodes.size(), 0u);
// Now reveal the password (simulating clicking the eye icon)
// This mimics JavaScript: passwordInput.type = 'text';
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
auto* input_element =
To<HTMLInputElement>(document->getElementById(AtomicString("pwd")));
ASSERT_TRUE(input_element);
input_element->setType(AtomicString("text"));
// Ensure the DOM is updated
document->UpdateStyleAndLayout(DocumentUpdateReason::kTest);
// Get AI page content again - password should still be hidden
GetAIPageContent();
const auto& root2 = ContentRootNode();
EXPECT_EQ(root2.children_nodes.size(), 1u);
const auto& form2 = *root2.children_nodes[0];
EXPECT_EQ(form2.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kForm);
EXPECT_EQ(form2.children_nodes.size(), 1u);
const auto& revealed_password = *form2.children_nodes[0];
CheckFormControlNode(revealed_password,
mojom::blink::FormControlType::kInputText);
EXPECT_EQ(revealed_password.content_attributes->form_control_data->field_name,
"Enter password");
// Even though the password is revealed, the value should still be hidden
// because HasBeenBeenPasswordField() is true.
EXPECT_EQ(
revealed_password.content_attributes->form_control_data->field_value,
nullptr);
EXPECT_EQ(revealed_password.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kRedacted_HasBeenPassword);
EXPECT_EQ(revealed_password.children_nodes.size(), 0u);
input_element->SetValue("");
// Ensure the DOM is updated
document->UpdateStyleAndLayout(DocumentUpdateReason::kTest);
// Get AI page content again - empty passwords are unredacted.
GetAIPageContent();
const auto& root3 = ContentRootNode();
EXPECT_EQ(root3.children_nodes.size(), 1u);
const auto& form3 = *root3.children_nodes[0];
EXPECT_EQ(form3.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kForm);
EXPECT_EQ(form3.children_nodes.size(), 1u);
const auto& empty_password = *form3.children_nodes[0];
CheckFormControlNode(empty_password,
mojom::blink::FormControlType::kInputText);
EXPECT_EQ(empty_password.content_attributes->form_control_data->field_name,
"Enter password");
EXPECT_EQ(empty_password.content_attributes->form_control_data->field_value,
"");
EXPECT_EQ(empty_password.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kUnredacted_EmptyPassword);
EXPECT_EQ(empty_password.children_nodes.size(), 1u);
}
TEST_F(AIPageContentAgentTest, FormWithWebkitTextSecurityRedactsAndPersists) {
// This test covers a common custom-password pattern where authors use
// `-webkit-text-security` on a normal <input type="text"> to visually mask
// the value without using <input type="password">.
//
// Acceptance criteria:
// - The value is redacted when `-webkit-text-security` is not `none`.
// - After the style is removed (simulating JS toggling the masking), the
// value remains redacted for subsequent extractions.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <form>"
" <input id='masked' type='text' name='Enter secret' "
"style='-webkit-text-security: disc;' value='supersecret'>"
" </form>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& form = *root.children_nodes[0];
ASSERT_EQ(form.children_nodes.size(), 1u);
const auto& field = *form.children_nodes[0];
CheckFormControlNode(field, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(field.content_attributes->form_control_data->field_name,
"Enter secret");
EXPECT_EQ(field.content_attributes->form_control_data->field_value, nullptr);
EXPECT_EQ(
field.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kRedacted_CustomPassword_CSS);
// Now remove the masking style (simulating a "show password" eye icon).
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
auto* input_element =
To<HTMLInputElement>(document->getElementById(AtomicString("masked")));
ASSERT_TRUE(input_element);
input_element->setAttribute(html_names::kStyleAttr,
AtomicString("-webkit-text-security: none;"));
// Ensure the DOM is updated.
document->UpdateStyleAndLayout(DocumentUpdateReason::kTest);
// Extract again: the field should remain redacted since it was previously
// classified as password-like.
GetAIPageContent();
const auto& root2 = ContentRootNode();
ASSERT_EQ(root2.children_nodes.size(), 1u);
const auto& form2 = *root2.children_nodes[0];
ASSERT_EQ(form2.children_nodes.size(), 1u);
const auto& field2 = *form2.children_nodes[0];
CheckFormControlNode(field2, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(field2.content_attributes->form_control_data->field_value, nullptr);
EXPECT_EQ(
field2.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kRedacted_CustomPassword_CSS);
}
TEST_F(AIPageContentAgentTest, FormWithMaskedValuePatternRedactsAndPersists) {
// Some custom controls implement password-style masking in JavaScript by
// writing a value that contains mostly mask characters (e.g. bullets) while
// revealing the last typed character.
//
// Acceptance criteria:
// - A masked-value pattern is treated as password-like and redacted.
// - After the value is changed to an unmasked string, the field remains
// redacted for subsequent extractions.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <form>"
" <input id='jsmasked' type='text' name='Enter secret' "
"value='&#8226;&#8226;&#8226;&#8226;a'>"
" </form>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& form = *root.children_nodes[0];
ASSERT_EQ(form.children_nodes.size(), 1u);
const auto& field = *form.children_nodes[0];
CheckFormControlNode(field, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(field.content_attributes->form_control_data->field_value, nullptr);
EXPECT_EQ(field.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kRedacted_CustomPassword_JS);
// Now set an unmasked value; the field should remain redacted since it was
// previously classified as password-like.
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
auto* input_element =
To<HTMLInputElement>(document->getElementById(AtomicString("jsmasked")));
ASSERT_TRUE(input_element);
input_element->SetValue("notmaskedanymore");
document->UpdateStyleAndLayout(DocumentUpdateReason::kTest);
GetAIPageContent();
const auto& root2 = ContentRootNode();
ASSERT_EQ(root2.children_nodes.size(), 1u);
const auto& form2 = *root2.children_nodes[0];
ASSERT_EQ(form2.children_nodes.size(), 1u);
const auto& field2 = *form2.children_nodes[0];
CheckFormControlNode(field2, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(field2.content_attributes->form_control_data->field_value, nullptr);
EXPECT_EQ(field2.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kRedacted_CustomPassword_JS);
}
TEST_F(AIPageContentAgentTest, FormWithAllMaskCharactersRedacts) {
// Some custom controls always fully mask the value, never revealing a
// character (e.g. "•••••"). This should still be treated as password-like.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <form>"
" <input id='jsmasked' type='text' name='Enter secret' "
"value='&#8226;&#8226;&#8226;&#8226;&#8226;'>"
" </form>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& form = *root.children_nodes[0];
ASSERT_EQ(form.children_nodes.size(), 1u);
const auto& field = *form.children_nodes[0];
CheckFormControlNode(field, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(field.content_attributes->form_control_data->field_value, nullptr);
EXPECT_EQ(field.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kRedacted_CustomPassword_JS);
}
TEST_F(AIPageContentAgentTest, FormWithMaskedValuePatternWithSpaceNotRedacted) {
// Guardrail: if the value contains whitespace, treat it as non-password-like.
// A common rich-text pattern is a bullet used as a list marker.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <form>"
" <input id='maybe' type='text' name='Enter secret' "
"value='&#8226;&#8226; secret'>"
" </form>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& form = *root.children_nodes[0];
ASSERT_EQ(form.children_nodes.size(), 1u);
const auto& field = *form.children_nodes[0];
CheckFormControlNode(field, mojom::blink::FormControlType::kInputText);
EXPECT_NE(field.content_attributes->form_control_data->field_value, nullptr);
EXPECT_EQ(field.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kNoRedactionNecessary);
}
TEST_F(AIPageContentAgentTest, FormWithEarlyJSMaskedValueRedacts) {
// Guard against leaking initial characters as the user types into a custom
// JS-masked password field. Some implementations show "•b" for a 2-character
// password while the last typed character remains visible.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <form>"
" <input id='early' type='text' name='Enter secret' value='&#8226;b'>"
" </form>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& form = *root.children_nodes[0];
ASSERT_EQ(form.children_nodes.size(), 1u);
const auto& field = *form.children_nodes[0];
CheckFormControlNode(field, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(field.content_attributes->form_control_data->field_value, nullptr);
EXPECT_EQ(field.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kRedacted_CustomPassword_JS);
}
TEST_F(AIPageContentAgentTest, CustomPasswordJSBecomesEmptyThenRedactsAgain) {
// Regression coverage: once a field is classified as a JS custom password
// field, it stays classified even if the value becomes empty and later
// becomes non-empty again.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <form>"
" <input id='js' type='text' name='Enter secret' "
"value='&#8226;&#8226;&#8226;&#8226;a'>"
" </form>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& form = *root.children_nodes[0];
ASSERT_EQ(form.children_nodes.size(), 1u);
const auto& field = *form.children_nodes[0];
CheckFormControlNode(field, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(field.content_attributes->form_control_data->field_value, nullptr);
EXPECT_EQ(field.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kRedacted_CustomPassword_JS);
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
auto* input_element =
To<HTMLInputElement>(document->getElementById(AtomicString("js")));
ASSERT_TRUE(input_element);
input_element->SetValue("");
document->UpdateStyleAndLayout(DocumentUpdateReason::kTest);
GetAIPageContent();
const auto& root2 = ContentRootNode();
ASSERT_EQ(root2.children_nodes.size(), 1u);
const auto& form2 = *root2.children_nodes[0];
ASSERT_EQ(form2.children_nodes.size(), 1u);
const auto& field2 = *form2.children_nodes[0];
CheckFormControlNode(field2, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(field2.content_attributes->form_control_data->field_value, "");
EXPECT_EQ(
field2.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kUnredacted_EmptyCustomPassword);
input_element->SetValue("notmaskedanymore");
document->UpdateStyleAndLayout(DocumentUpdateReason::kTest);
GetAIPageContent();
const auto& root3 = ContentRootNode();
ASSERT_EQ(root3.children_nodes.size(), 1u);
const auto& form3 = *root3.children_nodes[0];
ASSERT_EQ(form3.children_nodes.size(), 1u);
const auto& field3 = *form3.children_nodes[0];
CheckFormControlNode(field3, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(field3.content_attributes->form_control_data->field_value, nullptr);
EXPECT_EQ(field3.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kRedacted_CustomPassword_JS);
}
TEST_F(AIPageContentAgentTest, CustomPasswordJSPlainTextThenMaskedAgain) {
// Regression coverage: a JS custom password field may temporarily replace its
// value with plain text (e.g. "show password") and later resume masking. The
// value must remain redacted throughout.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <form>"
" <input id='js' type='text' name='Enter secret' "
"value='&#8226;&#8226;&#8226;&#8226;a'>"
" </form>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
auto* input_element =
To<HTMLInputElement>(document->getElementById(AtomicString("js")));
ASSERT_TRUE(input_element);
GetAIPageContent();
input_element->SetValue("plain-text-temporary");
document->UpdateStyleAndLayout(DocumentUpdateReason::kTest);
GetAIPageContent();
const auto& root2 = ContentRootNode();
ASSERT_EQ(root2.children_nodes.size(), 1u);
const auto& form2 = *root2.children_nodes[0];
ASSERT_EQ(form2.children_nodes.size(), 1u);
const auto& field2 = *form2.children_nodes[0];
CheckFormControlNode(field2, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(field2.content_attributes->form_control_data->field_value, nullptr);
EXPECT_EQ(field2.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kRedacted_CustomPassword_JS);
input_element->SetValue(String::FromUtf8("••••z"));
document->UpdateStyleAndLayout(DocumentUpdateReason::kTest);
GetAIPageContent();
const auto& root3 = ContentRootNode();
ASSERT_EQ(root3.children_nodes.size(), 1u);
const auto& form3 = *root3.children_nodes[0];
ASSERT_EQ(form3.children_nodes.size(), 1u);
const auto& field3 = *form3.children_nodes[0];
CheckFormControlNode(field3, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(field3.content_attributes->form_control_data->field_value, nullptr);
EXPECT_EQ(field3.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kRedacted_CustomPassword_JS);
}
TEST_F(AIPageContentAgentTest, CustomPasswordCSSBecomesEmptyThenRedactsAgain) {
// Regression coverage: once a field is classified as a CSS custom password
// field, it stays classified even if the value becomes empty and later
// becomes non-empty again.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <form>"
" <input id='css' type='text' name='Enter secret' "
"style='-webkit-text-security: disc;' value='supersecret'>"
" </form>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& form = *root.children_nodes[0];
ASSERT_EQ(form.children_nodes.size(), 1u);
const auto& field = *form.children_nodes[0];
CheckFormControlNode(field, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(field.content_attributes->form_control_data->field_value, nullptr);
EXPECT_EQ(
field.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kRedacted_CustomPassword_CSS);
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
auto* input_element =
To<HTMLInputElement>(document->getElementById(AtomicString("css")));
ASSERT_TRUE(input_element);
input_element->SetValue("");
document->UpdateStyleAndLayout(DocumentUpdateReason::kTest);
GetAIPageContent();
const auto& root2 = ContentRootNode();
ASSERT_EQ(root2.children_nodes.size(), 1u);
const auto& form2 = *root2.children_nodes[0];
ASSERT_EQ(form2.children_nodes.size(), 1u);
const auto& field2 = *form2.children_nodes[0];
CheckFormControlNode(field2, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(field2.content_attributes->form_control_data->field_value, "");
EXPECT_EQ(
field2.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kUnredacted_EmptyCustomPassword);
input_element->setAttribute(html_names::kStyleAttr,
AtomicString("-webkit-text-security: none;"));
input_element->SetValue("plain-text-temporary");
document->UpdateStyleAndLayout(DocumentUpdateReason::kTest);
GetAIPageContent();
const auto& root3 = ContentRootNode();
ASSERT_EQ(root3.children_nodes.size(), 1u);
const auto& form3 = *root3.children_nodes[0];
ASSERT_EQ(form3.children_nodes.size(), 1u);
const auto& field3 = *form3.children_nodes[0];
CheckFormControlNode(field3, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(field3.content_attributes->form_control_data->field_value, nullptr);
EXPECT_EQ(
field3.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kRedacted_CustomPassword_CSS);
}
TEST_F(AIPageContentAgentTest, CustomPasswordCSSPlainTextThenMaskedAgain) {
// Regression coverage: a CSS custom password field may temporarily disable
// masking (e.g. "show password") and later re-enable it. The value must
// remain redacted throughout.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <form>"
" <input id='css' type='text' name='Enter secret' "
"style='-webkit-text-security: disc;' value='supersecret'>"
" </form>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
auto* input_element =
To<HTMLInputElement>(document->getElementById(AtomicString("css")));
ASSERT_TRUE(input_element);
GetAIPageContent();
input_element->setAttribute(html_names::kStyleAttr,
AtomicString("-webkit-text-security: none;"));
input_element->SetValue("plain-text-temporary");
document->UpdateStyleAndLayout(DocumentUpdateReason::kTest);
GetAIPageContent();
const auto& root2 = ContentRootNode();
ASSERT_EQ(root2.children_nodes.size(), 1u);
const auto& form2 = *root2.children_nodes[0];
ASSERT_EQ(form2.children_nodes.size(), 1u);
const auto& field2 = *form2.children_nodes[0];
CheckFormControlNode(field2, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(field2.content_attributes->form_control_data->field_value, nullptr);
EXPECT_EQ(
field2.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kRedacted_CustomPassword_CSS);
input_element->setAttribute(html_names::kStyleAttr,
AtomicString("-webkit-text-security: disc;"));
input_element->SetValue("supersecret2");
document->UpdateStyleAndLayout(DocumentUpdateReason::kTest);
GetAIPageContent();
const auto& root3 = ContentRootNode();
ASSERT_EQ(root3.children_nodes.size(), 1u);
const auto& form3 = *root3.children_nodes[0];
ASSERT_EQ(form3.children_nodes.size(), 1u);
const auto& field3 = *form3.children_nodes[0];
CheckFormControlNode(field3, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(field3.content_attributes->form_control_data->field_value, nullptr);
EXPECT_EQ(
field3.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kRedacted_CustomPassword_CSS);
}
TEST_F(
AIPageContentAgentTest,
DISABLED_ContentEditableWithMaskedValuePatternSingleLineRedactsAndPersists) {
// TODO(crbug.com/480179556): Treat contenteditable as a form control (or add
// a first-class editable field node) so we can attach structured redaction
// decisions and consistently skip editor/shadow subtrees for password-like
// contenteditables. This test covers a custom password control implemented
// with a contenteditable element. A common JS approach is to write mask
// characters (e.g. bullets) and reveal only the most recently typed
// character.
//
// Acceptance criteria:
// - The extracted content does not include the masked string.
// - After the field has been classified as password-like, it stays redacted
// even if the page later writes an unmasked value.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div id='ce' "
"contenteditable='true'>&#8226;&#8226;&#8226;&#8226;a</div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
auto* editable =
To<HTMLElement>(document->getElementById(AtomicString("ce")));
ASSERT_TRUE(editable);
ASSERT_TRUE(IsRichlyEditable(*editable));
ASSERT_EQ(RootEditableElement(*editable), editable);
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_FALSE(TreeContainsTextSubstring(root, "a"));
editable->setInnerText("notmaskedanymore");
document->UpdateStyleAndLayout(DocumentUpdateReason::kTest);
GetAIPageContent();
const auto& root2 = ContentRootNode();
EXPECT_FALSE(TreeContainsTextSubstring(root2, "notmaskedanymore"));
}
TEST_F(
AIPageContentAgentTest,
DISABLED_ContentEditableMultilineWithBulletIsNotClassifiedAsCustomPassword) {
// TODO(crbug.com/480179556): When adding contenteditable custom password
// redaction, keep a single-line guardrail to avoid false positives for rich
// text editors. Guardrail against false positives: rich text editors often
// use bullet characters (•) as list markers in normal prose, and we must not
// treat these as password-like fields. We require contenteditable masking
// heuristics to apply only to single-line fields.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div id='editor' contenteditable='true'>"
" <div>&#8226; item</div>"
" <div>more</div>"
" </div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_TRUE(TreeContainsTextSubstring(root, "item"));
EXPECT_TRUE(TreeContainsTextSubstring(root, "more"));
}
TEST_F(AIPageContentAgentTest, InteractiveElementsTextArea) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <textarea>text</textarea>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
const auto& text_area = *root.children_nodes[0];
CheckFormControlNode(text_area, mojom::blink::FormControlType::kTextArea);
EXPECT_EQ(text_area.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kNoRedactionNecessary);
CheckHitTestableAndInteractive(text_area,
{ClickabilityReason::kClickableControl});
// Text area uses a UA shadow DOM internally to create an editable box.
const auto& shadow_div = *text_area.children_nodes[0];
EXPECT_EQ(shadow_div.children_nodes.size(), 1u);
CheckContainerNode(shadow_div);
CheckHitTestableAndInteractive(shadow_div, {ClickabilityReason::kEditable});
EXPECT_EQ(shadow_div.children_nodes.size(), 1u);
const auto& text_area_text = *shadow_div.children_nodes[0];
CheckTextNode(text_area_text, "text");
CheckHitTestableButNotInteractive(text_area_text);
}
TEST_F(AIPageContentAgentTest, InteractiveElementsButton) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <button>button</button>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
const auto& button = *root.children_nodes[0];
CheckFormControlNode(button, mojom::blink::FormControlType::kButtonSubmit);
EXPECT_EQ(button.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kNoRedactionNecessary);
CheckHitTestableAndInteractive(button,
{ClickabilityReason::kClickableControl,
ClickabilityReason::kHoverPseudoClass});
EXPECT_TRUE(button.content_attributes->node_interaction_info->is_focusable);
ASSERT_EQ(button.children_nodes.size(), 1u);
const auto& button_text = *button.children_nodes[0];
CheckTextNode(button_text, "button");
EXPECT_TRUE(button_text.content_attributes->node_interaction_info);
CheckHitTestableButNotInteractive(button_text);
}
TEST_F(AIPageContentAgentTest, InteractiveElementsResizableDiv) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style>"
" div {"
" resize: both;"
" overflow: auto;"
" border: 1px solid black;"
" width: 200px;"
" }"
" </style>"
" <div>resize</div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& resize = *ContentRootNode().children_nodes[0];
CheckContainerNode(resize);
ASSERT_TRUE(resize.content_attributes->node_interaction_info);
EXPECT_FALSE(resize.content_attributes->node_interaction_info->scroller_info);
CheckHitTestableButNotInteractive(resize);
ASSERT_EQ(resize.children_nodes.size(), 1u);
const auto& resize_text = *resize.children_nodes[0];
CheckTextNode(resize_text, "resize");
EXPECT_TRUE(resize_text.content_attributes->node_interaction_info);
CheckHitTestableButNotInteractive(resize_text);
}
TEST_F(AIPageContentAgentTest, Selection) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <p id='p1'>Paragraph 1</p>"
" <p id='p2'>Paragraph 2</p>"
" <p id='p3'>Paragraph 3</p>"
" <script>"
" const p1 = document.getElementById('p1');"
" const p2 = document.getElementById('p2');"
" const range = new Range();"
" range.setStart(p1.childNodes[0], 10);"
" range.setEnd(p2.childNodes[0], 9);"
" const selection = window.getSelection();"
" selection.removeAllRanges();"
" selection.addRange(range);"
" </script>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 3u);
const auto& paragraph1 = *root.children_nodes[0];
CheckTextNode(*paragraph1.children_nodes[0], "Paragraph 1");
const auto& paragraph2 = *root.children_nodes[1];
CheckTextNode(*paragraph2.children_nodes[0], "Paragraph 2");
const auto& paragraph3 = *root.children_nodes[2];
CheckTextNode(*paragraph3.children_nodes[0], "Paragraph 3");
const auto& frame_interaction_info =
Content()->frame_data->frame_interaction_info;
ASSERT_TRUE(frame_interaction_info->selection);
const auto& selection = *frame_interaction_info->selection;
EXPECT_EQ(selection.selected_text, "1\n\nParagraph");
EXPECT_EQ(selection.start_dom_node_id,
paragraph1.children_nodes[0]->content_attributes->dom_node_id);
EXPECT_EQ(selection.end_dom_node_id,
paragraph2.children_nodes[0]->content_attributes->dom_node_id);
EXPECT_EQ(selection.start_offset, 10);
EXPECT_EQ(selection.end_offset, 9);
}
TEST_F(AIPageContentAgentTest, SelectionInIframe) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <iframe srcdoc='"
" <p id=\"p1\">Paragraph 1</p>"
" <p id=\"p2\">Paragraph 2</p>"
" <p id=\"p3\">Paragraph 3</p>"
" <script>"
" const p1 = document.getElementById(\"p1\");"
" const p2 = document.getElementById(\"p2\");"
" const range = new Range();"
" range.setStart(p1.childNodes[0], 10);"
" range.setEnd(p2.childNodes[0], 9);"
" const selection = window.getSelection();"
" selection.removeAllRanges();"
" selection.addRange(range);"
" </script>"
" '></iframe>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
const auto& iframe = *root.children_nodes[0];
CheckIframeNode(iframe);
EXPECT_EQ(iframe.children_nodes.size(), 1u);
const auto& iframe_root = *iframe.children_nodes[0];
CheckRootNode(iframe_root);
EXPECT_EQ(iframe_root.children_nodes.size(), 3u);
const auto& paragraph1 = *iframe_root.children_nodes[0];
CheckTextNode(*paragraph1.children_nodes[0], "Paragraph 1");
const auto& paragraph2 = *iframe_root.children_nodes[1];
CheckTextNode(*paragraph2.children_nodes[0], "Paragraph 2");
const auto& paragraph3 = *iframe_root.children_nodes[2];
CheckTextNode(*paragraph3.children_nodes[0], "Paragraph 3");
const auto& frame_interaction_info =
Content()->frame_data->frame_interaction_info;
ASSERT_FALSE(frame_interaction_info->selection);
EXPECT_TRUE(iframe.content_attributes->iframe_data->content);
const auto& iframe_interaction_info =
iframe.content_attributes->iframe_data->content->get_local_frame_data()
->frame_interaction_info;
ASSERT_TRUE(iframe_interaction_info->selection);
const auto& selection = *iframe_interaction_info->selection;
EXPECT_EQ(selection.selected_text, "1\n\nParagraph");
EXPECT_EQ(selection.start_dom_node_id,
paragraph1.children_nodes[0]->content_attributes->dom_node_id);
EXPECT_EQ(selection.end_dom_node_id,
paragraph2.children_nodes[0]->content_attributes->dom_node_id);
EXPECT_EQ(selection.start_offset, 10);
EXPECT_EQ(selection.end_offset, 9);
}
TEST_F(AIPageContentAgentTest, Focus) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <button id='button'>button</button>"
" <script>"
" const button = document.getElementById('button');"
" button.focus();"
" </script>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
const auto& button = *root.children_nodes[0];
const auto& page_interaction_info = Content()->page_interaction_info;
EXPECT_EQ(page_interaction_info->focused_dom_node_id,
button.content_attributes->dom_node_id);
const auto& frame_interaction_info =
Content()->frame_data->frame_interaction_info;
EXPECT_EQ(frame_interaction_info->focused_dom_node_id,
button.content_attributes->dom_node_id);
}
TEST_F(AIPageContentAgentTest, AccessibilityFocus) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style>"
" #button1 {"
" position: absolute;"
" top: -10px;"
" left: -20px;"
" width: 30px;"
" height: 40px;"
" }"
" </style>"
" <button id='button1'>button1</button>"
" <div id='div2'>div2</div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// Enable accessibility.
ui::AXMode ax_mode = ui::kAXModeComplete;
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
auto context = std::make_unique<AXContext>(*document, ax_mode);
EXPECT_TRUE(document->ExistingAXObjectCache());
auto* ax_object_cache =
To<AXObjectCacheImpl>(document->ExistingAXObjectCache());
EXPECT_EQ(ax_mode, ax_object_cache->GetAXMode());
ax_object_cache->UpdateAXForAllDocuments();
// Set accessibility focus to the button.
auto* button_element = document->getElementById(AtomicString("button1"));
auto* button_ax_object = ax_object_cache->Get(button_element);
ui::AXActionData action_data;
action_data.action = ax::mojom::blink::Action::kSetAccessibilityFocus;
button_ax_object->PerformAction(action_data);
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 2u);
const auto& button = *root.children_nodes[0];
const auto& div2 = *root.children_nodes[1];
const auto& page_interaction_info = Content()->page_interaction_info;
EXPECT_EQ(page_interaction_info->accessibility_focused_dom_node_id,
button.content_attributes->dom_node_id);
CheckGeometry(button, gfx::Rect(-20, -10, 30, 40), gfx::Rect(0, 0, 10, 30));
EXPECT_FALSE(div2.content_attributes->geometry);
const auto& frame_interaction_info =
Content()->frame_data->frame_interaction_info;
EXPECT_EQ(frame_interaction_info->accessibility_focused_dom_node_id,
button.content_attributes->dom_node_id);
}
TEST_F(AIPageContentAgentTest, MousePosition) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style>"
" div {"
" position: absolute;"
" top: 100px;"
" left: 200px;"
" }"
" </style>"
" <div>text</div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// Move the mouse to the middle of the page.
FireMouseMoveEvent(gfx::PointF(150, 50));
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
const auto& text = *root.children_nodes[0];
CheckTextNode(text, "text");
EXPECT_EQ(Content()->page_interaction_info->mouse_position->x(), 150);
EXPECT_EQ(Content()->page_interaction_info->mouse_position->y(), 50);
}
TEST_F(AIPageContentAgentTest, MetaTags) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<head>"
" <meta charset='UTF-8'>"
" <meta content='ignored'>"
" <meta name='author' content='George'>"
" <meta name='keywords' content='HTML, CSS, JavaScript'>"
" <meta name='nocontent'>"
" <meta name='emptycontent' content=''>"
" <meta id='nullcontent' name='nullcontent'>"
"</head>"
"<body>"
" <meta name='ignored'>"
" <iframe srcdoc=\""
" <head>"
" <meta charset='UTF-8'>"
" <meta name='author' content='Gary'>"
" <meta name='keywords' content='HTML, CSS, JavaScript'>"
" </head>"
" <body>child frame</body>"
" \""
" </iframe>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// Explicitly set the content of the nullcontent meta tag to the null atom to
// test this case.
auto& document = *helper_.LocalMainFrame()->GetFrame()->GetDocument();
document.getElementById(AtomicString("nullcontent"))
->setAttribute(html_names::kContentAttr, g_null_atom);
auto options = mojom::blink::AIPageContentOptions::New();
options->max_meta_elements = 32;
GetAIPageContent(std::move(options));
EXPECT_EQ(Content()->frame_data->meta_data.size(), 5u);
EXPECT_EQ(Content()->frame_data->meta_data[0]->name, "author");
EXPECT_EQ(Content()->frame_data->meta_data[0]->content, "George");
EXPECT_EQ(Content()->frame_data->meta_data[1]->name, "keywords");
EXPECT_EQ(Content()->frame_data->meta_data[1]->content,
"HTML, CSS, JavaScript");
EXPECT_EQ(Content()->frame_data->meta_data[2]->name, "nocontent");
EXPECT_EQ(Content()->frame_data->meta_data[3]->content, "");
EXPECT_EQ(Content()->frame_data->meta_data[3]->name, "emptycontent");
EXPECT_EQ(Content()->frame_data->meta_data[3]->content, "");
EXPECT_EQ(Content()->frame_data->meta_data[4]->name, "nullcontent");
EXPECT_EQ(Content()->frame_data->meta_data[4]->content, "");
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
const auto& iframe = *root.children_nodes[0];
EXPECT_EQ(iframe.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kIframe);
const auto& iframe_data = *iframe.content_attributes->iframe_data;
EXPECT_TRUE(iframe.content_attributes->iframe_data->content);
EXPECT_EQ(iframe_data.content->get_local_frame_data()->meta_data.size(), 2u);
EXPECT_EQ(iframe_data.content->get_local_frame_data()->meta_data[0]->name,
"author");
EXPECT_EQ(iframe_data.content->get_local_frame_data()->meta_data[0]->content,
"Gary");
EXPECT_EQ(iframe_data.content->get_local_frame_data()->meta_data[1]->name,
"keywords");
EXPECT_EQ(iframe_data.content->get_local_frame_data()->meta_data[1]->content,
"HTML, CSS, JavaScript");
}
TEST_F(AIPageContentAgentTest, NestedIframesMetaTags) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<head><meta name=author content=George></head>"
"<body>parent"
" <iframe srcdoc=\""
" <head><meta name=author content=Gary></head>"
" <body>child"
" <iframe srcdoc='"
" <head><meta name=author content=Jordan></head>"
" <body>grandchild</body"
" '></iframe>"
" </body>"
" \"></iframe>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
auto options = mojom::blink::AIPageContentOptions::New();
options->max_meta_elements = 32;
GetAIPageContent(std::move(options));
EXPECT_EQ(Content()->frame_data->meta_data.size(), 1u);
EXPECT_EQ(Content()->frame_data->meta_data[0]->name, "author");
EXPECT_EQ(Content()->frame_data->meta_data[0]->content, "George");
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 2u);
const auto& iframe = *root.children_nodes[1];
EXPECT_EQ(iframe.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kIframe);
const auto& iframe_data = *iframe.content_attributes->iframe_data;
EXPECT_TRUE(iframe_data.content);
EXPECT_EQ(iframe_data.content->get_local_frame_data()->meta_data.size(), 1u);
EXPECT_EQ(iframe_data.content->get_local_frame_data()->meta_data[0]->name,
"author");
EXPECT_EQ(iframe_data.content->get_local_frame_data()->meta_data[0]->content,
"Gary");
EXPECT_EQ(iframe.children_nodes.size(), 1u);
// In the iframe children_nodes there is a root node that has two children.
// The first child is a text node and the second is the subiframe. The key
// thing we want to check here is that the subiframe has the correct metadata.
const auto& subiframe = *iframe.children_nodes[0]->children_nodes[1];
EXPECT_EQ(subiframe.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kIframe);
const auto& subiframe_data = *subiframe.content_attributes->iframe_data;
EXPECT_TRUE(subiframe_data.content);
EXPECT_EQ(subiframe_data.content->get_local_frame_data()->meta_data.size(),
1u);
EXPECT_EQ(subiframe_data.content->get_local_frame_data()->meta_data[0]->name,
"author");
EXPECT_EQ(
subiframe_data.content->get_local_frame_data()->meta_data[0]->content,
"Jordan");
}
TEST_F(AIPageContentAgentTest, NodeIdAllowlist_DefaultModeTypePolicy) {
// This page has a paragraph with a text child. It gives us a simple shape to
// verify that type-based node-id policy does not implicitly propagate ids to
// descendants.
LoadNodeIdPolicyParagraphFixture();
GetAIPageContent(CreateNodeIdPolicyOptionsForTest(
{mojom::blink::AIPageContentAttributeType::kParagraph}));
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& paragraph = *root.children_nodes[0];
EXPECT_EQ(paragraph.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kParagraph);
ASSERT_TRUE(paragraph.content_attributes->dom_node_id.has_value());
EXPECT_GT(*paragraph.content_attributes->dom_node_id, kInvalidDOMNodeId);
ASSERT_EQ(paragraph.children_nodes.size(), 1u);
const auto& text = *paragraph.children_nodes[0];
EXPECT_EQ(text.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kText);
EXPECT_FALSE(text.content_attributes->dom_node_id.has_value());
}
TEST_F(AIPageContentAgentTest,
NodeIdAllowlist_UnsetOptionsPreserveLegacyIdEmission) {
// If `node_id_allowlist` is unset, APC should preserve legacy broad emission.
LoadNodeIdPolicyParagraphFixture();
GetAIPageContent(GetAIPageContentOptionsForTest());
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& paragraph = *root.children_nodes[0];
ASSERT_TRUE(paragraph.content_attributes->dom_node_id.has_value());
ASSERT_EQ(paragraph.children_nodes.size(), 1u);
const auto& text = *paragraph.children_nodes[0];
ASSERT_TRUE(text.content_attributes->dom_node_id.has_value());
}
TEST_F(AIPageContentAgentTest,
NodeIdAllowlist_EmptyOptionsSuppressTypeBasedIds) {
// With explicit but empty options, regular structural nodes should not emit
// ids. The APC root still needs one so later metadata can point back to it.
LoadNodeIdPolicyParagraphFixture();
GetAIPageContent(CreateNodeIdPolicyOptionsForTest());
const auto& root = ContentRootNode();
ASSERT_TRUE(root.content_attributes);
ASSERT_TRUE(root.content_attributes->dom_node_id.has_value());
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& paragraph = *root.children_nodes[0];
EXPECT_FALSE(paragraph.content_attributes->dom_node_id.has_value());
ASSERT_EQ(paragraph.children_nodes.size(), 1u);
const auto& text = *paragraph.children_nodes[0];
EXPECT_FALSE(text.content_attributes->dom_node_id.has_value());
}
TEST_F(AIPageContentAgentTest,
NodeIdAllowlist_EmptyOptionsDoNotGenerateIdForSuppressedTextNode) {
// Build a simple paragraph dynamically and verify APC does not generate a
// DOM id for its text node when empty node-id options suppress output ids.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), "<body></body>",
url_test_helpers::ToKURL("http://foobar.com"));
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
ASSERT_TRUE(document->body());
auto* paragraph = document->CreateRawElement(html_names::kPTag);
paragraph->setAttribute(html_names::kIdAttr, AtomicString("dynamic_p"));
auto* text_node = document->createTextNode("dynamic paragraph text");
paragraph->appendChild(text_node);
document->body()->appendChild(paragraph);
// Precondition: this test is only meaningful if APC starts from an idless
// text node.
ASSERT_EQ(DOMNodeIds::ExistingIdForNode(text_node), kInvalidDOMNodeId);
GetAIPageContent(CreateNodeIdPolicyOptionsForTest());
// The text node is suppressed by policy, so APC must not generate its id.
EXPECT_EQ(DOMNodeIds::ExistingIdForNode(text_node), kInvalidDOMNodeId);
// Keep the output-side assertion as an additional behavior guard.
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& output_paragraph = *root.children_nodes[0];
ASSERT_EQ(output_paragraph.children_nodes.size(), 1u);
const auto& output_text = *output_paragraph.children_nodes[0];
EXPECT_FALSE(output_text.content_attributes->dom_node_id.has_value());
}
TEST_F(
AIPageContentAgentTest,
NodeIdAllowlist_AllowlistedParentDoesNotGenerateIdForSuppressedTextNode) {
// The parent paragraph is allowlisted and may emit its id. The text child is
// still suppressed and must remain idless before and after APC extraction.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), "<body></body>",
url_test_helpers::ToKURL("http://foobar.com"));
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
ASSERT_TRUE(document->body());
auto* paragraph = document->CreateRawElement(html_names::kPTag);
paragraph->setTextContent("allowlisted parent text");
auto* text_node = paragraph->firstChild();
ASSERT_TRUE(text_node);
document->body()->appendChild(paragraph);
ASSERT_EQ(DOMNodeIds::ExistingIdForNode(text_node), kInvalidDOMNodeId);
GetAIPageContent(CreateNodeIdPolicyOptionsForTest(
{mojom::blink::AIPageContentAttributeType::kParagraph}));
EXPECT_EQ(DOMNodeIds::ExistingIdForNode(text_node), kInvalidDOMNodeId);
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& output_paragraph = *root.children_nodes[0];
ASSERT_TRUE(output_paragraph.content_attributes->dom_node_id.has_value());
ASSERT_EQ(output_paragraph.children_nodes.size(), 1u);
const auto& output_text = *output_paragraph.children_nodes[0];
EXPECT_FALSE(output_text.content_attributes->dom_node_id.has_value());
}
TEST_F(AIPageContentAgentTest, NodeIdAllowlist_ActionableNodesAlwaysShowId) {
// Action execution depends on ids for actionable targets. This override
// should apply even if the type allowlist is empty.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <button id='button'>button</button>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent(CreateNodeIdPolicyOptionsForTest(
mojom::blink::AIPageContentMode::kActionableElements));
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& button = *root.children_nodes[0];
ASSERT_TRUE(button.content_attributes->node_interaction_info);
ASSERT_TRUE(button.content_attributes->dom_node_id.has_value());
EXPECT_GT(*button.content_attributes->dom_node_id, kInvalidDOMNodeId);
}
TEST_F(AIPageContentAgentTest,
NodeIdAllowlist_AnchoredOffscreenActionableNodeLosesActionableState) {
// A fixed popup item parked below the viewport is not reachable by
// scrolling. It should therefore lose actionable metadata even if earlier
// output-policy decisions already caused APC to mint a DOM id for it.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <button id='trigger'>Open sizes</button>"
" <div id='sheet' style='position: fixed; left: 0; right: 0; bottom: 0;"
" transform: translateY(100%);'>"
" <button id='item'>Size 6.5</button>"
" </div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent(CreateNodeIdPolicyOptionsForTest(
mojom::blink::AIPageContentMode::kActionableElements));
const auto* trigger_node = FindNodeBySelector("#trigger");
ASSERT_TRUE(trigger_node);
ASSERT_TRUE(trigger_node->content_attributes->node_interaction_info);
ASSERT_TRUE(trigger_node->content_attributes->dom_node_id.has_value());
const auto* item_node =
FindFormControlNodeWithTextSubstring(ContentRootNode(), "Size 6.5");
ASSERT_TRUE(item_node);
EXPECT_FALSE(item_node->content_attributes->node_interaction_info);
}
TEST_F(AIPageContentAgentTest, NodeIdAllowlist_FocusedNodeAlwaysShowsId) {
// Focus metadata is consumed browser-side. The focused node must remain
// id-backed even with an empty allowlist.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <input id='focused_input' value='x'>"
" <p id='paragraph'>visible paragraph text</p>"
" <script>"
" document.getElementById('focused_input').focus();"
" </script>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent(CreateNodeIdPolicyOptionsForTest());
const auto& page_interaction_info = Content()->page_interaction_info;
ASSERT_TRUE(page_interaction_info->focused_dom_node_id.has_value());
EXPECT_GT(*page_interaction_info->focused_dom_node_id, kInvalidDOMNodeId);
const mojom::blink::AIPageContentNode* focused_node =
FindNodeByDomNodeId(*page_interaction_info->focused_dom_node_id);
ASSERT_TRUE(focused_node);
ASSERT_TRUE(focused_node->content_attributes->dom_node_id.has_value());
EXPECT_EQ(*focused_node->content_attributes->dom_node_id,
*page_interaction_info->focused_dom_node_id);
// Non-focused nodes should continue to suppress ids under empty allowlist
// policy.
ExpectSelectorNotInApcOutput("#paragraph");
}
TEST_F(AIPageContentAgentTest, NodeIdAllowlist_LabelForTargetAlwaysShowsId) {
// `label_for_dom_node_id` is a metadata edge. The referenced control must
// still emit a node id so browser-side linking remains valid.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <label for='name_input' id='name_label'>Name</label>"
" <input id='name_input' value='Ada'>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent(CreateNodeIdPolicyOptionsForTest(
mojom::blink::AIPageContentMode::kActionableElements));
const auto* label_node = FindNodeBySelector("#name_label");
ASSERT_TRUE(label_node);
ASSERT_TRUE(
label_node->content_attributes->label_for_dom_node_id.has_value());
const DOMNodeId target_dom_node_id =
*label_node->content_attributes->label_for_dom_node_id;
const auto* target_node = FindNodeByDomNodeId(target_dom_node_id);
ASSERT_TRUE(target_node);
ASSERT_TRUE(target_node->content_attributes->dom_node_id.has_value());
EXPECT_EQ(*target_node->content_attributes->dom_node_id, target_dom_node_id);
}
TEST_F(AIPageContentAgentTest,
NodeIdAllowlist_LabelTargetShowsIdWhenLabelFollowsControl) {
// The label can appear after its target control in layout order.
// The target must still remain id-backed for label metadata round-trips.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <input id='name_input' value='Ada'>"
" <label for='name_input' id='name_label'>Name</label>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent(CreateNodeIdPolicyOptionsForTest(
mojom::blink::AIPageContentMode::kActionableElements));
const auto* label_node = FindNodeBySelector("#name_label");
ASSERT_TRUE(label_node);
ASSERT_TRUE(
label_node->content_attributes->label_for_dom_node_id.has_value());
const DOMNodeId target_dom_node_id =
*label_node->content_attributes->label_for_dom_node_id;
const auto* target_node = FindNodeByDomNodeId(target_dom_node_id);
ASSERT_TRUE(target_node);
ASSERT_TRUE(target_node->content_attributes->dom_node_id.has_value());
EXPECT_EQ(*target_node->content_attributes->dom_node_id, target_dom_node_id);
}
TEST_F(AIPageContentAgentTest,
NodeIdAllowlist_AccessibilityFocusedNodeAlwaysShowsId) {
// Accessibility focus should force id emission even when type policy would
// otherwise suppress ids.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <button id='button1'>button1</button>"
" <div id='div2'>div2</div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
ui::AXMode ax_mode = ui::kAXModeComplete;
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
auto context = std::make_unique<AXContext>(*document, ax_mode);
auto* ax_object_cache =
To<AXObjectCacheImpl>(document->ExistingAXObjectCache());
ASSERT_TRUE(ax_object_cache);
ax_object_cache->UpdateAXForAllDocuments();
auto* button_element = document->getElementById(AtomicString("button1"));
ASSERT_TRUE(button_element);
auto* button_ax_object = ax_object_cache->Get(button_element);
ASSERT_TRUE(button_ax_object);
ui::AXActionData action_data;
action_data.action = ax::mojom::blink::Action::kSetAccessibilityFocus;
button_ax_object->PerformAction(action_data);
GetAIPageContent(CreateNodeIdPolicyOptionsForTest());
const auto& page_interaction_info = Content()->page_interaction_info;
ASSERT_TRUE(
page_interaction_info->accessibility_focused_dom_node_id.has_value());
EXPECT_GT(*page_interaction_info->accessibility_focused_dom_node_id,
kInvalidDOMNodeId);
const mojom::blink::AIPageContentNode* focused_node = FindNodeByDomNodeId(
*page_interaction_info->accessibility_focused_dom_node_id);
ASSERT_TRUE(focused_node);
ASSERT_TRUE(focused_node->content_attributes->dom_node_id.has_value());
// Non-focused nodes should continue to suppress ids under empty allowlist
// policy.
ExpectSelectorNotInApcOutput("#div2");
}
TEST_F(AIPageContentAgentTest, NodeIdAllowlist_SelectionNodesAlwaysShowId) {
// Selection metadata references start/end DOM nodes and must remain
// round-trippable even when the policy allowlist is empty.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <p id='p1'>Paragraph 1</p>"
" <p id='p2'>Paragraph 2</p>"
" <p id='p3'>Paragraph 3</p>"
" <script>"
" const p1 = document.getElementById('p1');"
" const p2 = document.getElementById('p2');"
" const range = new Range();"
" range.setStart(p1.childNodes[0], 10);"
" range.setEnd(p2.childNodes[0], 9);"
" const selection = window.getSelection();"
" selection.removeAllRanges();"
" selection.addRange(range);"
" </script>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent(CreateNodeIdPolicyOptionsForTest());
const auto& frame_interaction_info =
Content()->frame_data->frame_interaction_info;
ASSERT_TRUE(frame_interaction_info->selection);
const auto& selection = *frame_interaction_info->selection;
EXPECT_GT(selection.start_dom_node_id, kInvalidDOMNodeId);
EXPECT_GT(selection.end_dom_node_id, kInvalidDOMNodeId);
const auto* start_node = FindNodeByDomNodeId(selection.start_dom_node_id);
ASSERT_TRUE(start_node);
ASSERT_TRUE(start_node->content_attributes->dom_node_id.has_value());
const auto* end_node = FindNodeByDomNodeId(selection.end_dom_node_id);
ASSERT_TRUE(end_node);
ASSERT_TRUE(end_node->content_attributes->dom_node_id.has_value());
// Nodes unrelated to the selection should continue to suppress ids.
ExpectSelectorNotInApcOutput("#p3");
}
TEST_F(AIPageContentAgentTest,
NodeIdAllowlist_DefaultModeMultipleAllowlistedTypes) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <h1 id='heading'>Visible heading text</h1>"
" <p id='paragraph'>Visible paragraph text</p>"
" <div id='container'>Visible container text</div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent(CreateNodeIdPolicyOptionsForTest(
{mojom::blink::AIPageContentAttributeType::kParagraph,
mojom::blink::AIPageContentAttributeType::kHeading}));
const auto* heading_node = FindNodeBySelector("#heading");
ASSERT_TRUE(heading_node);
ASSERT_TRUE(heading_node->content_attributes->dom_node_id.has_value());
EXPECT_GT(*heading_node->content_attributes->dom_node_id, kInvalidDOMNodeId);
const auto* paragraph_node = FindNodeBySelector("#paragraph");
ASSERT_TRUE(paragraph_node);
ASSERT_TRUE(paragraph_node->content_attributes->dom_node_id.has_value());
EXPECT_GT(*paragraph_node->content_attributes->dom_node_id,
kInvalidDOMNodeId);
ExpectSelectorNotInApcOutput("#container");
}
TEST_F(AIPageContentAgentTest, Title) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<head>"
" <title>test title</title>"
"</head>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
EXPECT_EQ(Content()->frame_data->title, "test title");
}
bool ContainsRole(const Vector<mojom::blink::AIPageContentAnnotatedRole>& roles,
mojom::blink::AIPageContentAnnotatedRole role) {
for (const auto& r : roles) {
if (r == role) {
return true;
}
}
return false;
}
TEST_F(AIPageContentAgentTest, PaidContent) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"HTML(
<head>
<script></script>
<script type='unrelated'></script>
<script type="application/ld+json">{this: "will fail parsing",}</script>
<script type="application/ld+json">"not": "an object"</script>
<script type="application/ld+json">{
"@context": "http://schema.org",
"@type": "NewsArticle",
"mainEntityOfPage": "https://www.evergreengazette.com/world/world-news/",
"headline": "City Council Debates Future of Automated Transit System",
"alternativeHeadline": "City Council Debates Future of Automated Transit System",
"dateModified": "2025-03-25T19:17:05.541Z",
"datePublished": "2025-03-25T09:02:58.131Z",
"description": "The City Council has been asked to discuss the future of automated transit systems, including the feasibility of a bus-on-rails system, in a special meeting on Thursday, March 28.",
"author": [
{
"@type": "Person",
"name": "Finlay Joy",
"url": "https://www.evergreengazette.com/people/finlay-joy/"
},
{
"@type": "Person",
"name": "Calum Gerhard",
"url": "https://www.evergreengazette.com/people/calum-gerhard/"
}
],
"isPartOf": {
"@type": [
"CreativeWork",
"Product"
],
"name": "The Evergreen Gazette",
"productID": "evergreengazette.com:basic",
"description": "The Evergreen Gazette is your trusted source for comprehensive local news, insightful analysis, and community-focused reporting.",
"sku": "https://subscribe.evergreengazette.com",
"image": "https://www.evergreengazette.com/evergreen-gazette-logo.png",
"brand": {
"@type": "brand",
"name": "The Evergreen Gazette"
},
"offers": {
"@type": "offer",
"url": "https://subscribe.evergreengazette.com/acquisition?promo=h97"
}
},
"publisher": {
"@id": "evergreengazette.com",
"@type": "NewsMediaOrganization",
"name": "The Evergreen Gazette"
},
"isAccessibleForFree": false,
"hasPart": {
"@type": "WebPageElement",
"cssSelector": ".paidContent",
"isAccessibleForFree": false
}
}</script>
<body>
Content
<div class="paidContent">Paid Content</div>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
// The root node contains paid content.
EXPECT_TRUE(Content()->frame_data->contains_paid_content);
const auto& root = ContentRootNode();
// The text node should not have the paid content role.
const auto& text_node = *root.children_nodes[0];
EXPECT_FALSE(
ContainsRole(text_node.content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
// The paid content node should have the paid content role.
const auto& paid_node = *root.children_nodes[1];
EXPECT_TRUE(
ContainsRole(paid_node.content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
}
TEST_F(AIPageContentAgentTest, PaidContentContextMismatch) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"HTML(
<script type="application/ld+json">{
"@context": "http://acme.org",
"@type": "NewsArticle",
"isAccessibleForFree": false,
"hasPart": {
"@type": "WebPageElement",
"cssSelector": ".paidContent",
"isAccessibleForFree": false
}
}</script>
<body>
Content
<div class="paidContent">Paid Content</div>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
// The root node does not contain paid content.
EXPECT_FALSE(Content()->frame_data->contains_paid_content);
const auto& root = ContentRootNode();
// The text node should not have the paid content role.
const auto& text_node = *root.children_nodes[0];
EXPECT_FALSE(
ContainsRole(text_node.content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
// The paid content node should have the paid content role.
const auto& paid_node = *root.children_nodes[1];
EXPECT_FALSE(
ContainsRole(paid_node.content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
}
TEST_F(AIPageContentAgentTest, PaidContentRootOnly) {
// Note that isAccessibleForFree = "False" to match real world examples.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"HTML(
<script type="application/ld+json">{
"@context": "http://schema.org",
"@type": "NewsArticle",
"isAccessibleForFree": "False",
"hasPart": {
"@type": "unrelated"
}
}</script>
<body>
Content
<div class="paidContent">Paid Content</div>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
// The root node contains paid content.
EXPECT_TRUE(Content()->frame_data->contains_paid_content);
const auto& root = ContentRootNode();
// The text node should not have the paid content role.
const auto& text_node = *root.children_nodes[0];
EXPECT_FALSE(
ContainsRole(text_node.content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
// The paid content node should have the paid content role.
const auto& paid_node = *root.children_nodes[1];
EXPECT_FALSE(
ContainsRole(paid_node.content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
}
TEST_F(AIPageContentAgentTest, PaidContentMicrodata) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"HTML(
<script type="application/ld+json">{
"@context": "http://schema.org",
"@type": "NewsArticle",
"isAccessibleForFree": false
}</script>
<body>
Content
<div class="paidContent">
<meta itemprop="isAccessibleForFree" content="false">
Paid Content
</div>
<div class="paidContent">
<meta itemprop="unrelated">
Content
</div>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
// The root node contains paid content.
EXPECT_TRUE(Content()->frame_data->contains_paid_content);
const auto& root = ContentRootNode();
// The text node should not have the paid content role.
const auto& text_node = *root.children_nodes[0];
EXPECT_FALSE(
ContainsRole(text_node.content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
// The paid content node should have the paid content role.
const auto& paid_node = *root.children_nodes[1];
EXPECT_TRUE(
ContainsRole(paid_node.content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
}
TEST_F(AIPageContentAgentTest, PaidContentSomeYesSomeNo) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"HTML(
<script type="application/ld+json">{
"@context": "http://schema.org",
"@type": "NewsArticle",
"isAccessibleForFree": false,
"hasPart": {
"@type": "WebPageElement",
"cssSelector": ".paidContent",
"isAccessibleForFree": false
}
}</script>
<body>
Content
<div class="paidContent">Paid Content</div>
<div>Free Content</div>
<div class="paidContent">Paid Content</div>
<div>Free Content</div>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
// The root node contains paid content.
EXPECT_TRUE(Content()->frame_data->contains_paid_content);
const auto& root = ContentRootNode();
auto& nodes = root.children_nodes;
// Every other node should have the paid content role.
EXPECT_FALSE(
ContainsRole(nodes[0]->content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
EXPECT_TRUE(
ContainsRole(nodes[1]->content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
EXPECT_FALSE(
ContainsRole(nodes[2]->content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
EXPECT_TRUE(
ContainsRole(nodes[3]->content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
EXPECT_FALSE(
ContainsRole(nodes[4]->content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
}
TEST_F(AIPageContentAgentTest, PaidContentMultipleHasParts) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"HTML(
<script type="application/ld+json">{
"@context": "https://schema.org",
"@type": "NewsArticle",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://example.org/article"
},
"isAccessibleForFree": false,
"hasPart": [
{
"@type": "WebPageElement",
"isAccessibleForFree": false,
"cssSelector": ".section1"
}, {
"@type": "WebPageElement",
"isAccessibleForFree": false,
"cssSelector": ".section2"
}
]
}</script>
<body>
Content
<div class="section1">Paid Content</div>
<div class="section2">Paid Content</div>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
// The root node contains paid content.
EXPECT_TRUE(Content()->frame_data->contains_paid_content);
const auto& root = ContentRootNode();
auto& nodes = root.children_nodes;
EXPECT_FALSE(
ContainsRole(nodes[0]->content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
EXPECT_TRUE(
ContainsRole(nodes[1]->content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
EXPECT_TRUE(
ContainsRole(nodes[2]->content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
}
TEST_F(AIPageContentAgentTest, PaidContentSubframe) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"HTML(
<body>
Parent doc is free!
<iframe srcdoc='
<script type="application/ld+json">{
"@context": "http://schema.org",
"@type": "NewsArticle",
"isAccessibleForFree": false,
"hasPart": {
"@type": "WebPageElement",
"cssSelector": ".paidContent",
"isAccessibleForFree": false
}
}</script>
<body>
Content
<div class="paidContent">Paid Content</div>
</body>
'></iframe>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
// The root node does not contain paid content.
EXPECT_FALSE(Content()->frame_data->contains_paid_content);
const auto& root = ContentRootNode();
auto& nodes = root.children_nodes;
EXPECT_FALSE(
ContainsRole(nodes[0]->content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
const auto& iframe = nodes[1];
EXPECT_EQ(iframe->content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kIframe);
EXPECT_TRUE(iframe->content_attributes->iframe_data->content);
EXPECT_TRUE(
iframe->content_attributes->iframe_data->content->get_local_frame_data()
->contains_paid_content);
auto& children = iframe->children_nodes[0]->children_nodes;
EXPECT_FALSE(
ContainsRole(children[0]->content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
EXPECT_TRUE(
ContainsRole(children[1]->content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
}
TEST_F(AIPageContentAgentTest, PaidContentSubframeMicrodata) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"HTML(
<script type="application/ld+json">{
"@context": "https://schema.org",
"@type": "NewsArticle",
"isAccessibleForFree": true
}</script>
<body>
Free Content
<div class="paidContent">
<meta itemprop="isAccessibleForFree" content="false">
Microdata not checked
</div>
<iframe srcdoc='
<script type="application/ld+json">{
"@context": "http://schema.org",
"@type": "NewsArticle",
"isAccessibleForFree": false
}</script>
<body>
Content
<div class="paidContent">
<meta itemprop="isAccessibleForFree" content="false">
Paid Content
</div>
</body>
'></iframe>
<iframe srcdoc='
<body>
Content
<div class="paidContent">
<meta itemprop="isAccessibleForFree" content="false">
Microdata not checked
</div>
</body>
'></iframe>
<iframe srcdoc='
<script type="application/ld+json">{
"@context": "http://schema.org",
"@type": "NewsArticle",
"isAccessibleForFree": false
}</script>
<body>
Content
<div class="paidContent">
<meta itemprop="isAccessibleForFree" content="false">
Paid Content
</div>
</body>
'></iframe>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
// The root node does not contain paid content.
EXPECT_FALSE(Content()->frame_data->contains_paid_content);
const auto& root = ContentRootNode();
auto& nodes = root.children_nodes;
EXPECT_FALSE(
ContainsRole(nodes[0]->content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
EXPECT_FALSE(
ContainsRole(nodes[1]->content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
const auto& iframe1 = nodes[2];
EXPECT_EQ(iframe1->content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kIframe);
EXPECT_TRUE(iframe1->content_attributes->iframe_data->content);
EXPECT_TRUE(
iframe1->content_attributes->iframe_data->content->get_local_frame_data()
->contains_paid_content);
const auto& children1 = iframe1->children_nodes[0]->children_nodes;
EXPECT_FALSE(
ContainsRole(children1[0]->content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
EXPECT_TRUE(
ContainsRole(children1[1]->content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
const auto& iframe2 = nodes[3];
EXPECT_EQ(iframe2->content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kIframe);
EXPECT_TRUE(iframe2->content_attributes->iframe_data->content);
EXPECT_FALSE(
iframe2->content_attributes->iframe_data->content->get_local_frame_data()
->contains_paid_content);
const auto& children2 = iframe2->children_nodes[0]->children_nodes;
EXPECT_FALSE(
ContainsRole(children2[0]->content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
EXPECT_FALSE(
ContainsRole(children2[1]->content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
const auto& iframe3 = nodes[4];
EXPECT_EQ(iframe3->content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kIframe);
EXPECT_TRUE(iframe3->content_attributes->iframe_data->content);
EXPECT_TRUE(
iframe3->content_attributes->iframe_data->content->get_local_frame_data()
->contains_paid_content);
const auto& children3 = iframe3->children_nodes[0]->children_nodes;
EXPECT_FALSE(
ContainsRole(children3[0]->content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
EXPECT_TRUE(
ContainsRole(children3[1]->content_attributes->annotated_roles,
mojom::blink::AIPageContentAnnotatedRole::kPaidContent));
}
TEST_F(AIPageContentAgentTest, AnchorInInlineWithFloatingSibling) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<!DOCTYPE html>"
"<body style='margin:0; font: 1px/1px Ahem'>"
" <span>"
" <a href='https://www.google.com'>"
" <div style='position: relative; float: left;'>text in div</div>"
" <span>text</span>"
" </a>"
" </span>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
LoadAhem();
GetAIPageContentWithActionableElements();
const mojom::blink::AIPageContentNode* anchor = FindNodeBySelector("a");
ASSERT_TRUE(anchor->content_attributes->geometry);
CheckAnchorNode(*anchor, blink::KURL("https://www.google.com/"), {});
ASSERT_TRUE(anchor->content_attributes->node_interaction_info);
EXPECT_TRUE(anchor->content_attributes->node_interaction_info
->document_scoped_z_order);
CheckGeometry(*anchor, gfx::Rect(11, 0, 4, 1), gfx::Rect(11, 0, 4, 1));
}
TEST_F(AIPageContentAgentTest, OverflowHiddenGeometry) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div style='width: 100px; height: 100px; overflow-y: hidden;'>"
" <article style='width: 50px; height: 300px;'></article>"
" </div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& outer = ContentRootNode().children_nodes[0];
const auto& article = outer->children_nodes[0];
CheckAnnotatedRole(*article,
mojom::blink::AIPageContentAnnotatedRole::kArticle);
EXPECT_GT(*article->content_attributes->node_interaction_info
->document_scoped_z_order,
*outer->content_attributes->node_interaction_info
->document_scoped_z_order);
CheckGeometry(*outer, gfx::Rect(8, 8, 100, 100), gfx::Rect(8, 8, 100, 100));
CheckGeometry(*article, gfx::Rect(8, 8, 50, 300), gfx::Rect(8, 8, 50, 100));
}
TEST_F(AIPageContentAgentTest, OverflowVisibleGeometry) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <section style='width: 100px; height: 100px; overflow-y: visible;'>"
" <article style='width: 50px; height: 300px;'></article>"
" </section>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& outer = ContentRootNode().children_nodes[0];
const auto& article = outer->children_nodes[0];
CheckAnnotatedRole(*article,
mojom::blink::AIPageContentAnnotatedRole::kArticle);
EXPECT_GT(*article->content_attributes->node_interaction_info
->document_scoped_z_order,
*outer->content_attributes->node_interaction_info
->document_scoped_z_order);
CheckGeometry(*outer, gfx::Rect(8, 8, 100, 100), gfx::Rect(8, 8, 100, 100));
CheckGeometry(*article, gfx::Rect(8, 8, 50, 300), gfx::Rect(8, 8, 50, 300));
}
TEST_F(AIPageContentAgentTest, BlurGeometry) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <section style='width: 100px; height: 100px; filter: "
"blur(10px);'></section>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& section = ContentRootNode().children_nodes[0];
CheckAnnotatedRole(*section,
mojom::blink::AIPageContentAnnotatedRole::kSection);
// Although blurring causes the rectangle to show outside of the 100x100 rect,
// the extra area is not hit testable, and is therefore not included in the
// visible bounding box.
CheckGeometry(*section, gfx::Rect(8, 8, 100, 100), gfx::Rect(8, 8, 100, 100));
}
TEST_F(AIPageContentAgentTest, GeomtryAbsPos) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <section style='width: 100px; height: 100px; position: absolute; top: "
"200px; left: 200px;'>"
"</section>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& section = ContentRootNode().children_nodes[0];
CheckAnnotatedRole(*section,
mojom::blink::AIPageContentAnnotatedRole::kSection);
CheckGeometry(*section, gfx::Rect(200, 200, 100, 100),
gfx::Rect(200, 200, 100, 100));
}
TEST_F(AIPageContentAgentTest, GeometryTransform) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <section style='width: 100px; height: 100px; transform: "
"translate(200px, 200px)'>"
"</section>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& section = ContentRootNode().children_nodes[0];
CheckAnnotatedRole(*section,
mojom::blink::AIPageContentAnnotatedRole::kSection);
CheckGeometry(*section, gfx::Rect(208, 208, 100, 100),
gfx::Rect(208, 208, 100, 100));
}
TEST_F(AIPageContentAgentTest, CursorForClickability) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div style='cursor: pointer'>"
" <p>no-click</p>"
" <p style='cursor: pointer'>click</p>"
" </div>"
" <article>article</article>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
EXPECT_EQ(ContentRootNode().children_nodes.size(), 2u);
const auto& cursor = *ContentRootNode().children_nodes[0];
EXPECT_TRUE(cursor.content_attributes->node_interaction_info);
CheckHitTestableAndInteractive(cursor, {ClickabilityReason::kCursorPointer});
const auto& no_click = *cursor.children_nodes[0];
EXPECT_TRUE(no_click.content_attributes->node_interaction_info);
CheckHitTestableButNotInteractive(no_click);
const auto& click = *cursor.children_nodes[1];
EXPECT_TRUE(click.content_attributes->node_interaction_info);
CheckHitTestableAndInteractive(click, {ClickabilityReason::kCursorPointer});
const auto& article = *ContentRootNode().children_nodes[1];
EXPECT_TRUE(article.content_attributes->node_interaction_info);
CheckHitTestableButNotInteractive(article);
}
TEST_F(AIPageContentAgentTest, LinkForClickability) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <a href='test.com'>valid</a>"
" <a>invalid</a>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
EXPECT_EQ(ContentRootNode().children_nodes.size(), 2u);
const auto& valid = *ContentRootNode().children_nodes[0];
EXPECT_TRUE(valid.content_attributes->node_interaction_info);
CheckHitTestableAndInteractive(valid, {ClickabilityReason::kCursorPointer});
const auto& invalid = *ContentRootNode().children_nodes[1];
EXPECT_TRUE(invalid.content_attributes->node_interaction_info);
CheckHitTestableButNotInteractive(invalid);
}
TEST_F(AIPageContentAgentTest, LabelWithForSibling) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <input type='checkbox' id='myCheckbox' />"
" <label for='myCheckbox'>Check me!</label>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 2u);
const auto& input = *root.children_nodes[0];
CheckFormControlNode(input, mojom::blink::FormControlType::kInputCheckbox);
ASSERT_TRUE(input.content_attributes->node_interaction_info);
CheckHitTestableAndInteractive(input,
{ClickabilityReason::kClickableControl});
EXPECT_EQ(input.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kNoRedactionNecessary);
const auto& label = *root.children_nodes[1];
CheckContainerNode(label);
ASSERT_TRUE(label.content_attributes->node_interaction_info);
EXPECT_TRUE(label.content_attributes->node_interaction_info
->clickability_reasons.empty());
EXPECT_EQ(label.content_attributes->label_for_dom_node_id,
input.content_attributes->dom_node_id);
}
TEST_F(AIPageContentAgentTest, LabelGeometry) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<style>body * { margin:0; padding:0; font:1px/1px 'Ahem'; }</style>"
"<body style='margin: 3px;'>"
" <label for='myCheckbox'>1234567890</label>"
" <input type='checkbox' id='myCheckbox' "
" style='width:1px;height:1px;appearance:none;'/>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
LoadAhem();
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 2u);
const auto& label = *root.children_nodes[0];
CheckContainerNode(label);
ASSERT_TRUE(label.content_attributes->node_interaction_info);
EXPECT_TRUE(label.content_attributes->node_interaction_info
->clickability_reasons.empty());
const auto& input = *root.children_nodes[1];
CheckFormControlNode(input, mojom::blink::FormControlType::kInputCheckbox);
ASSERT_TRUE(input.content_attributes->node_interaction_info);
CheckHitTestableAndInteractive(input,
{ClickabilityReason::kClickableControl});
EXPECT_EQ(input.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kNoRedactionNecessary);
EXPECT_EQ(label.content_attributes->label_for_dom_node_id,
input.content_attributes->dom_node_id);
ASSERT_TRUE(label.content_attributes->geometry);
const auto& label_geometry = *label.content_attributes->geometry;
// With the 1px Ahem font loaded above, each glyph occupies a 1x1 cell so the
// 10-character label spans exactly 10x1 CSS pixels.
EXPECT_EQ(label_geometry.outer_bounding_box, gfx::Rect(3, 3, 10, 1));
EXPECT_EQ(label_geometry.visible_bounding_box,
label_geometry.outer_bounding_box);
}
TEST_F(AIPageContentAgentTest, LabelWithForDescendant) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <label>"
" <input type='checkbox' id='myCheckbox' />"
"Check me!"
"</label>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
const auto& label = *root.children_nodes[0];
EXPECT_EQ(label.children_nodes.size(), 2u);
CheckContainerNode(label);
ASSERT_TRUE(label.content_attributes->node_interaction_info);
CheckHitTestableButNotInteractive(label);
const auto& input = *label.children_nodes[0];
CheckFormControlNode(input, mojom::blink::FormControlType::kInputCheckbox);
ASSERT_TRUE(input.content_attributes->node_interaction_info);
CheckHitTestableAndInteractive(input,
{ClickabilityReason::kClickableControl});
EXPECT_EQ(label.content_attributes->label_for_dom_node_id,
input.content_attributes->dom_node_id);
CheckTextNode(*label.children_nodes[1], "Check me!");
}
TEST_F(AIPageContentAgentTest, SVGWithText) {
ScopedAIPageContentIncludeSVGSubtreeForTest scoped_feature(
/*enabled=*/true);
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <svg width='400' height='200'>"
" <text x='50%' y='50/%' font-size='24'>"
" Hello SVG Text!"
" </text>"
" </svg>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& svg = *ContentRootNode().children_nodes[0];
EXPECT_EQ(svg.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kSvgRoot);
ASSERT_TRUE(svg.content_attributes->svg_root_data);
EXPECT_EQ(svg.content_attributes->svg_root_data->inner_text,
"Hello SVG Text!");
const auto& text_child = *svg.children_nodes[0];
EXPECT_EQ(text_child.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kText);
// Note that whitespace is kept.
CheckTextNode(text_child, " Hello SVG Text! ");
}
TEST_F(AIPageContentAgentTest, SVGWithNoText) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <svg width='400' height='200' style='content-visibility: hidden'>"
" <text x='50%' y='50/%' font-size='24'>"
" Hello SVG Text!"
" </text>"
" </svg>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& svg = *ContentRootNode().children_nodes[0];
EXPECT_EQ(svg.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kSvgRoot);
ASSERT_TRUE(svg.content_attributes->svg_root_data);
EXPECT_FALSE(svg.content_attributes->svg_root_data->inner_text);
// Only visible text nodes are extracted.
EXPECT_EQ(svg.children_nodes.size(), 0u);
}
TEST_F(AIPageContentAgentTest, SVGSubtreeContainersAreKept) {
ScopedAIPageContentIncludeSVGSubtreeForTest scoped_feature(
/*enabled=*/true);
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <svg width='400' height='200'>"
" <a href='example.html'>"
" <image/>"
" <rect width='10%' height='10%' fill='red'/>"
" <text x='50%' y='50/%' font-size='24'>"
" Hello SVG Text!"
" </text>"
" </a>"
" </svg>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
auto& document = *helper_.LocalMainFrame()->GetFrame()->GetDocument();
document.getElementsByTagName(AtomicString("image"))
->item(0)
->setAttribute(html_names::kSrcAttr, AtomicString(kSmallImage));
GetAIPageContentWithActionableElements();
const auto& svg = *ContentRootNode().children_nodes[0];
EXPECT_EQ(svg.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kSvgRoot);
ASSERT_TRUE(svg.content_attributes->svg_root_data);
EXPECT_EQ(svg.content_attributes->svg_root_data->inner_text,
"Hello SVG Text!");
ASSERT_EQ(svg.children_nodes.size(), 1u);
const auto& a_child = *svg.children_nodes[0];
EXPECT_EQ(a_child.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kContainer);
ASSERT_TRUE(a_child.content_attributes->node_interaction_info);
EXPECT_FALSE(a_child.content_attributes->node_interaction_info
->clickability_reasons.empty());
ASSERT_EQ(a_child.children_nodes.size(), 3u);
const auto& image_child = *a_child.children_nodes[0];
EXPECT_EQ(image_child.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kImage);
// Non-text, non-image elements are processed as generic containers.
const auto& rect_child = *a_child.children_nodes[1];
EXPECT_EQ(rect_child.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kContainer);
const auto& text_child = *a_child.children_nodes[2];
EXPECT_EQ(text_child.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kText);
// Note that whitespace is kept.
CheckTextNode(text_child, " Hello SVG Text! ");
ASSERT_TRUE(text_child.content_attributes->node_interaction_info);
EXPECT_TRUE(text_child.content_attributes->node_interaction_info
->clickability_reasons.empty());
}
TEST_F(AIPageContentAgentTest, Canvas) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style>"
" canvas {"
" width: 200px;"
" height: 300px;"
" }"
" </style>"
" <canvas id='myCanvas' width='100' height='200'></canvas>"
" <script>"
" const canvas = document.getElementById('myCanvas');"
" const ctx = canvas.getContext('2d');"
" ctx.fillStyle = 'pink';"
" ctx.fillRect(0, 0, 100, 200);"
" </script>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& canvas = *ContentRootNode().children_nodes[0];
EXPECT_EQ(canvas.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kCanvas);
ASSERT_TRUE(canvas.content_attributes->canvas_data);
EXPECT_EQ(canvas.content_attributes->canvas_data->layout_size,
gfx::Size(200, 300));
}
TEST_F(AIPageContentAgentTest, AriaLabelledBy) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div id='hiddenLabel1' style='display: none;'>and first</div>"
" <div id='hiddenLabel2' style='display: none;'>and second</div>"
" <input type='text' aria-labelledby='hiddenLabel1 hiddenLabel2' "
"aria-label='on element'/>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
const auto& input = *root.children_nodes[0];
CheckFormControlNode(input, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(input.content_attributes->label, "on element and first and second");
}
TEST_F(AIPageContentAgentTest, DisabledButton) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body>
<button disabled>Text</button>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& button = *root.children_nodes.at(0);
CheckHitTestableButNotInteractive(button);
EXPECT_TRUE(button.content_attributes->node_interaction_info->is_disabled);
EXPECT_THAT(
button.content_attributes->node_interaction_info
->interaction_disabled_reasons,
testing::UnorderedElementsAre(InteractionDisabledReason::kDisabled));
}
TEST_F(AIPageContentAgentTest, InertButton) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body>
<button inert>Text</button>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& button = *root.children_nodes.at(0);
EXPECT_FALSE(button.content_attributes->node_interaction_info);
}
TEST_F(AIPageContentAgentTest, ActionablePseudoElements) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style> a::before { content: 'hello'; cursor: pointer;} </style>"
" <a href='#'></a>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
EXPECT_EQ(ContentRootNode().children_nodes.size(), 1u);
const auto& a = *ContentRootNode().children_nodes[0];
ASSERT_TRUE(a.content_attributes->node_interaction_info);
CheckHitTestableAndInteractive(a, {ClickabilityReason::kCursorPointer});
EXPECT_EQ(a.children_nodes.size(), 1u);
const auto& before = *a.children_nodes[0];
ASSERT_TRUE(before.content_attributes->node_interaction_info);
CheckHitTestableAndInteractive(before, {ClickabilityReason::kCursorPointer});
}
TEST_F(AIPageContentAgentTest, PseudoElementNotActionable) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style> a::before { content: 'hello';} </style>"
" <a href='#'></a>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
EXPECT_EQ(ContentRootNode().children_nodes.size(), 1u);
const auto& a = *ContentRootNode().children_nodes[0];
ASSERT_TRUE(a.content_attributes->node_interaction_info);
CheckHitTestableAndInteractive(a, {ClickabilityReason::kCursorPointer});
EXPECT_EQ(a.children_nodes.size(), 1u);
const auto& before = *a.children_nodes[0];
ASSERT_TRUE(before.content_attributes->node_interaction_info);
CheckHitTestableButNotInteractive(before);
}
TEST_F(AIPageContentAgentTest, PseudoElementNoPointerEvents) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style> a::before { content: 'hello'; pointer-events: none;} </style>"
" <a href='#'></a>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
EXPECT_EQ(Content()->root_node->children_nodes.size(), 1u);
const auto& a = *ContentRootNode().children_nodes[0];
ASSERT_TRUE(a.content_attributes->node_interaction_info);
CheckHitTestableAndInteractive(a, {ClickabilityReason::kCursorPointer});
EXPECT_EQ(a.children_nodes.size(), 1u);
const auto& text = *a.children_nodes[0];
CheckTextNode(text, "hello");
EXPECT_FALSE(text.content_attributes->node_interaction_info);
}
TEST_F(AIPageContentAgentTest, AriaDisabled) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body>
<section style='cursor: pointer' aria-disabled=true>
<input type=text aria-disabled=false></input>
</section>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
// aria-disabled is advisory, so the section keeps its normal clickability
// signal while also reporting why an actor may want to avoid it.
const auto& section = *root.children_nodes.at(0);
CheckContainerNode(section);
CheckHitTestableAndInteractive(section, {ClickabilityReason::kCursorPointer});
EXPECT_FALSE(section.content_attributes->node_interaction_info->is_disabled);
EXPECT_THAT(
section.content_attributes->node_interaction_info
->interaction_disabled_reasons,
testing::UnorderedElementsAre(InteractionDisabledReason::kAriaDisabled));
// The inherited reason is also advisory for descendants. A child-level
// aria-disabled=false does not override the ancestor's true value.
ASSERT_EQ(section.children_nodes.size(), 1u);
const auto& input = *section.children_nodes.at(0);
CheckHitTestableAndInteractive(input,
{ClickabilityReason::kClickableControl});
EXPECT_FALSE(input.content_attributes->node_interaction_info->is_disabled);
EXPECT_THAT(
input.content_attributes->node_interaction_info
->interaction_disabled_reasons,
testing::UnorderedElementsAre(InteractionDisabledReason::kAriaDisabled));
}
TEST_F(AIPageContentAgentTest, AriaHiddenInteractionReason) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body>
<section aria-hidden=true>
<button>Text</button>
</section>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& section = *root.children_nodes.at(0);
CheckContainerNode(section);
ASSERT_EQ(section.children_nodes.size(), 1u);
// aria-hidden is inherited, so the button carries the reason even though the
// attribute is on its section ancestor.
const auto& button = *section.children_nodes.at(0);
CheckHitTestableAndInteractive(button,
{ClickabilityReason::kClickableControl,
ClickabilityReason::kHoverPseudoClass});
EXPECT_FALSE(button.content_attributes->node_interaction_info->is_disabled);
EXPECT_THAT(
button.content_attributes->node_interaction_info
->interaction_disabled_reasons,
testing::UnorderedElementsAre(InteractionDisabledReason::kAriaHidden));
}
TEST_F(AIPageContentAgentTest, AriaPresentationalRoleInteractionReason) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body>
<button role=presentation>Presentation</button>
<button role=none>None</button>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 2u);
for (const auto& button : root.children_nodes) {
// role=presentation and role=none are synonymous ARIA roles.
CheckHitTestableAndInteractive(*button,
{ClickabilityReason::kClickableControl,
ClickabilityReason::kHoverPseudoClass});
EXPECT_FALSE(
button->content_attributes->node_interaction_info->is_disabled);
EXPECT_THAT(button->content_attributes->node_interaction_info
->interaction_disabled_reasons,
testing::UnorderedElementsAre(
InteractionDisabledReason::kAriaRolePresentational));
}
}
TEST_F(AIPageContentAgentTest, DisabledInheritance) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body>
<form>
<fieldset disabled>
<button type="submit"></button>
</fieldset>
</form>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& form = *root.children_nodes.at(0);
CheckHitTestableButNotInteractive(form);
ASSERT_EQ(form.children_nodes.size(), 1u);
const auto& fieldset = *form.children_nodes.at(0);
CheckHitTestableButNotInteractive(fieldset);
ASSERT_EQ(fieldset.children_nodes.size(), 1u);
EXPECT_TRUE(fieldset.content_attributes->node_interaction_info->is_disabled);
EXPECT_THAT(
fieldset.content_attributes->node_interaction_info
->interaction_disabled_reasons,
testing::UnorderedElementsAre(InteractionDisabledReason::kDisabled));
const auto& button = *fieldset.children_nodes.at(0);
CheckHitTestableButNotInteractive(button);
EXPECT_TRUE(button.content_attributes->node_interaction_info->is_disabled);
EXPECT_THAT(
button.content_attributes->node_interaction_info
->interaction_disabled_reasons,
testing::UnorderedElementsAre(InteractionDisabledReason::kDisabled));
}
TEST_F(AIPageContentAgentTest, Fieldset) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body>
<form>
<fieldset>
<button type="submit"></button>
</fieldset>
</form>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& form = *root.children_nodes.at(0);
CheckHitTestableButNotInteractive(form);
ASSERT_EQ(form.children_nodes.size(), 1u);
const auto& fieldset = *form.children_nodes.at(0);
CheckHitTestableButNotInteractive(fieldset);
}
TEST_F(AIPageContentAgentTest, ShadowDOMInInput) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body>
<input type=range></input>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& input = *root.children_nodes.at(0);
ASSERT_TRUE(input.content_attributes->node_interaction_info);
CheckHitTestableAndInteractive(input,
{ClickabilityReason::kClickableControl});
EXPECT_NE(input.children_nodes.size(), 0u);
const auto& shadow_div = *input.children_nodes.at(0);
CheckHitTestableButNotInteractive(shadow_div);
}
TEST_F(AIPageContentAgentTest, DisabledOption) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body>
<select>
<option value="banana">Banana</option>
<option value="cherry" disabled>Cherry</option>
</select>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& select = *root.children_nodes.at(0);
CheckFormControlNode(select, mojom::blink::FormControlType::kSelectOne);
const auto& options =
select.content_attributes->form_control_data->select_options;
ASSERT_EQ(options.size(), 2u);
const auto& banana = *options.at(0);
EXPECT_EQ(banana.value, "banana");
EXPECT_FALSE(banana.disabled);
const auto& cherry = *options.at(1);
EXPECT_EQ(cherry.value, "cherry");
EXPECT_TRUE(cherry.disabled);
}
TEST_F(AIPageContentAgentTest, AriaRole) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body>
<div role="button">hello</div>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& button = *root.children_nodes.at(0);
ASSERT_TRUE(button.content_attributes->node_interaction_info);
CheckHitTestableAndInteractive(button, {ClickabilityReason::kAriaRole});
EXPECT_EQ(button.content_attributes->aria_role,
ax::mojom::blink::Role::kButton);
}
TEST_F(AIPageContentAgentTest,
ActionableModeKeepsInteractiveContainersButDropsEmptySpan) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body>
<div id="outer" onclick="void(0)">
<div id="option" role="option">
<span id="empty"></span>
</div>
</div>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
// The outer wrapper has a click handler, so actionable mode should keep it
// as a generic container with click-event metadata.
const mojom::blink::AIPageContentNode* outer = FindNodeBySelector("#outer");
ASSERT_TRUE(outer);
CheckContainerNode(*outer);
ASSERT_TRUE(outer->content_attributes->node_interaction_info);
EXPECT_THAT(
outer->content_attributes->node_interaction_info->clickability_reasons,
testing::Contains(ClickabilityReason::kClickEvents));
// The inner wrapper has role=option. APC still uses a generic container
// here, but actionable mode should preserve the node because the ARIA role
// makes it interactive.
const mojom::blink::AIPageContentNode* option = FindNodeBySelector("#option");
ASSERT_TRUE(option);
CheckContainerNode(*option);
EXPECT_EQ(option->content_attributes->aria_role,
ax::mojom::blink::Role::kListBoxOption);
ASSERT_TRUE(option->content_attributes->node_interaction_info);
EXPECT_THAT(
option->content_attributes->node_interaction_info->clickability_reasons,
testing::Contains(ClickabilityReason::kAriaRole));
// The span has no text and no interactive signal, so APC should flatten it
// away instead of emitting an empty wrapper node.
ExpectSelectorNotInApcOutput("#empty");
}
TEST_F(AIPageContentAgentTest, LabelNotActionable) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body>
<input type='checkbox' id='myCheckbox' />
<label for='myCheckbox' style='pointer-events: none;'>Check me!</label>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 2u);
const auto& button = *root.children_nodes.at(0);
ASSERT_TRUE(button.content_attributes->node_interaction_info);
CheckHitTestableAndInteractive(button,
{ClickabilityReason::kClickableControl});
const auto& label = *root.children_nodes.at(1);
EXPECT_FALSE(label.content_attributes->node_interaction_info);
EXPECT_EQ(*label.content_attributes->label_for_dom_node_id,
button.content_attributes->dom_node_id);
}
TEST_F(AIPageContentAgentTest, SelectLabelNotActionable) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body>
<label for="fruit-select">Choose a fruit:</label>
<select id="fruit-select" name="fruits">
<option value="">--Please choose an option--</option>
<option value="apple">Apple</option>
<option value="banana">Banana</option>
</select>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 2u);
const auto& label = *root.children_nodes.at(0);
ASSERT_TRUE(label.content_attributes->node_interaction_info);
CheckHitTestableButNotInteractive(label);
const auto& select = *root.children_nodes.at(1);
ASSERT_TRUE(select.content_attributes->node_interaction_info);
CheckHitTestableAndInteractive(select,
{ClickabilityReason::kClickableControl,
ClickabilityReason::kHoverPseudoClass});
}
TEST_F(AIPageContentAgentTest, ClickabilityReasonClickableControl) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body><button id='testButton'>Click Me</button></body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& button_node = *ContentRootNode().children_nodes[0];
ASSERT_TRUE(button_node.content_attributes->node_interaction_info);
EXPECT_THAT(
button_node.content_attributes->node_interaction_info
->clickability_reasons,
testing::Contains(
mojom::blink::AIPageContentClickabilityReason::kClickableControl));
}
TEST_F(AIPageContentAgentTest, ClickabilityReasonClickEvents) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body><div id='testDiv'>Clickable</div>"
"<script>document.getElementById('testDiv').onclick = "
"function(){};</script>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& div_node = *ContentRootNode().children_nodes[0];
ASSERT_TRUE(div_node.content_attributes->node_interaction_info);
EXPECT_THAT(
div_node.content_attributes->node_interaction_info->clickability_reasons,
testing::Contains(
mojom::blink::AIPageContentClickabilityReason::kClickEvents));
}
TEST_F(AIPageContentAgentTest, ClickabilityReasonMouseHover) {
// An element with various mouse event listeners.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body><div id='testDiv'>Mouse Events</div>"
"<script>"
" const div = document.getElementById('testDiv');"
" div.onmouseover = function(){};"
"</script>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& div_node = *ContentRootNode().children_nodes[0];
ASSERT_TRUE(div_node.content_attributes->node_interaction_info);
EXPECT_THAT(
div_node.content_attributes->node_interaction_info->clickability_reasons,
testing::Contains(
mojom::blink::AIPageContentClickabilityReason::kMouseHover));
}
TEST_F(AIPageContentAgentTest, ClickabilityReasonMouseClick) {
// An element with various mouse event listeners.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body><div id='testDiv'>Mouse Events</div>"
"<script>"
" const div = document.getElementById('testDiv');"
" div.onmousedown = function(){};"
"</script>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& div_node = *ContentRootNode().children_nodes[0];
ASSERT_TRUE(div_node.content_attributes->node_interaction_info);
EXPECT_THAT(
div_node.content_attributes->node_interaction_info->clickability_reasons,
testing::Contains(
mojom::blink::AIPageContentClickabilityReason::kMouseClick));
}
TEST_F(AIPageContentAgentTest, ClickabilityReasonKeyEvents) {
// An element with keyboard event listeners.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body><input type='text' id='testInput'>"
"<script>"
" const input = document.getElementById('testInput');"
" input.onkeydown = function(){};"
"</script>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& input_node = *ContentRootNode().children_nodes[0];
ASSERT_TRUE(input_node.content_attributes->node_interaction_info);
EXPECT_THAT(input_node.content_attributes->node_interaction_info
->clickability_reasons,
testing::Contains(
mojom::blink::AIPageContentClickabilityReason::kKeyEvents));
}
TEST_F(AIPageContentAgentTest, ClickabilityReasonEditable) {
// A div with contenteditable attribute.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body><div contenteditable='true'>Editable Content</div></body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& div_node = *ContentRootNode().children_nodes[0];
ASSERT_TRUE(div_node.content_attributes->node_interaction_info);
EXPECT_THAT(
div_node.content_attributes->node_interaction_info->clickability_reasons,
testing::Contains(
mojom::blink::AIPageContentClickabilityReason::kEditable));
}
TEST_F(AIPageContentAgentTest, ClickabilityReasonCursorPointer) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body><div style='cursor: pointer;'>Pointer Cursor</div></body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& div_node = *ContentRootNode().children_nodes[0];
ASSERT_TRUE(div_node.content_attributes->node_interaction_info);
EXPECT_THAT(
div_node.content_attributes->node_interaction_info->clickability_reasons,
testing::Contains(
mojom::blink::AIPageContentClickabilityReason::kCursorPointer));
}
TEST_F(AIPageContentAgentTest, ClickabilityReasonAriaRole) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), "<body><div role='link'>ARIA Link</div></body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& div_node = *ContentRootNode().children_nodes[0];
ASSERT_TRUE(div_node.content_attributes->node_interaction_info);
EXPECT_EQ(div_node.content_attributes->aria_role,
ax::mojom::blink::Role::kLink);
EXPECT_THAT(
div_node.content_attributes->node_interaction_info->clickability_reasons,
testing::Contains(
mojom::blink::AIPageContentClickabilityReason::kAriaRole));
}
TEST_F(AIPageContentAgentTest, ClickabilityReasonMultipleReasons) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
"<button id='multiReasonBtn' contenteditable='true' style='cursor: "
"pointer;' role='menuitem'>"
"Multi-Reason Button"
"</button>"
"<script>"
" const btn = document.getElementById('multiReasonBtn');"
" btn.onclick = function(){};"
" btn.onmouseover = function(){};"
" btn.onmouseup = function(){};"
" btn.onkeydown = function(){};"
"</script>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& button_node = *ContentRootNode().children_nodes[0];
ASSERT_TRUE(button_node.content_attributes->node_interaction_info);
EXPECT_THAT(
button_node.content_attributes->node_interaction_info
->clickability_reasons,
testing::UnorderedElementsAre(
mojom::blink::AIPageContentClickabilityReason::kClickableControl,
mojom::blink::AIPageContentClickabilityReason::kClickEvents,
mojom::blink::AIPageContentClickabilityReason::kMouseHover,
mojom::blink::AIPageContentClickabilityReason::kMouseClick,
mojom::blink::AIPageContentClickabilityReason::kKeyEvents,
mojom::blink::AIPageContentClickabilityReason::kEditable,
mojom::blink::AIPageContentClickabilityReason::kCursorPointer,
mojom::blink::AIPageContentClickabilityReason::kHoverPseudoClass,
mojom::blink::AIPageContentClickabilityReason::kAriaRole));
}
TEST_F(AIPageContentAgentTest, ClickabilityReasonNoReasons) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), "<body><div>Plain Div</div></body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& div_node = *ContentRootNode().children_nodes[0];
ASSERT_TRUE(div_node.content_attributes->node_interaction_info);
EXPECT_TRUE(div_node.content_attributes->node_interaction_info
->clickability_reasons.empty());
}
TEST_F(AIPageContentAgentTest, AriaHasPopup) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body><div aria-haspopup=true>Plain Div</div></body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& div_node = *ContentRootNode().children_nodes[0];
ASSERT_TRUE(div_node.content_attributes->node_interaction_info);
const auto& interaction_info =
*div_node.content_attributes->node_interaction_info;
EXPECT_THAT(
interaction_info.clickability_reasons,
testing::Contains(
mojom::blink::AIPageContentClickabilityReason::kAriaHasPopup));
}
TEST_F(AIPageContentAgentTest, AriaExpandedTrue) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body><div aria-expanded=true>Plain Div</div></body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& div_node = *ContentRootNode().children_nodes[0];
ASSERT_TRUE(div_node.content_attributes->node_interaction_info);
const auto& interaction_info =
*div_node.content_attributes->node_interaction_info;
EXPECT_THAT(
interaction_info.clickability_reasons,
testing::Contains(
mojom::blink::AIPageContentClickabilityReason::kAriaExpandedTrue));
}
TEST_F(AIPageContentAgentTest, AriaExpandedFalse) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body><div aria-expanded=false>Plain Div</div></body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& div_node = *ContentRootNode().children_nodes[0];
ASSERT_TRUE(div_node.content_attributes->node_interaction_info);
const auto& interaction_info =
*div_node.content_attributes->node_interaction_info;
EXPECT_THAT(
interaction_info.clickability_reasons,
testing::Contains(
mojom::blink::AIPageContentClickabilityReason::kAriaExpandedFalse));
}
TEST_F(AIPageContentAgentTest, ClickabilityReasonAriaToggleAndSelectable) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"(
<body>
<button aria-pressed="false">Toggle Button</button>
<div role="checkbox" aria-checked="mixed">Toggle Checkbox</div>
<div role="option" aria-selected="false">Selectable Option</div>
</body>)",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& pressed_node = *ContentRootNode().children_nodes[0];
ASSERT_TRUE(pressed_node.content_attributes->node_interaction_info);
EXPECT_THAT(pressed_node.content_attributes->node_interaction_info
->clickability_reasons,
testing::Contains(
mojom::blink::AIPageContentClickabilityReason::kAriaToggle));
const auto& checked_node = *ContentRootNode().children_nodes[1];
ASSERT_TRUE(checked_node.content_attributes->node_interaction_info);
EXPECT_THAT(checked_node.content_attributes->node_interaction_info
->clickability_reasons,
testing::Contains(
mojom::blink::AIPageContentClickabilityReason::kAriaToggle));
const auto& selected_node = *ContentRootNode().children_nodes[2];
ASSERT_TRUE(selected_node.content_attributes->node_interaction_info);
EXPECT_THAT(
selected_node.content_attributes->node_interaction_info
->clickability_reasons,
testing::Contains(
mojom::blink::AIPageContentClickabilityReason::kAriaSelectable));
}
TEST_F(AIPageContentAgentTest, Autocomplete) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"(<body>
<input>
<input autocomplete=off>
<input autocomplete=on>
<input aria-autocomplete>
<input aria-autocomplete=none>
<input aria-autocomplete=list>
</body>)",
url_test_helpers::ToKURL("http://foobar.com"));
const bool kExpected[] = {
false, // no attribute
false, // disabled
true,
false, // empty
false, // disabled
true,
};
GetAIPageContentWithActionableElements();
for (int i = 0; bool expected : kExpected) {
SCOPED_TRACE(i);
const auto& input_node = *ContentRootNode().children_nodes[i];
ASSERT_TRUE(input_node.content_attributes->node_interaction_info);
const auto& interaction_info =
*input_node.content_attributes->node_interaction_info;
EXPECT_THAT(
interaction_info.clickability_reasons,
testing::Contains(
mojom::blink::AIPageContentClickabilityReason::kAutocomplete)
.Times(expected));
++i;
}
}
TEST_F(AIPageContentAgentTest, TabIndex) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), "<body><div tabindex=0>Plain Div</div></body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& div_node = *ContentRootNode().children_nodes[0];
ASSERT_TRUE(div_node.content_attributes->node_interaction_info);
const auto& interaction_info =
*div_node.content_attributes->node_interaction_info;
EXPECT_THAT(interaction_info.clickability_reasons,
testing::Contains(
mojom::blink::AIPageContentClickabilityReason::kTabIndex));
}
TEST_F(AIPageContentAgentTest, IsTabbable) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"(
<body>
<button tabindex="-1">Programmatic Focus</button>
<div tabindex="0">Tab Stop</div>
</body>)",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& focusable_only_node = *ContentRootNode().children_nodes[0];
ASSERT_TRUE(focusable_only_node.content_attributes->node_interaction_info);
const auto& focusable_only_interaction_info =
*focusable_only_node.content_attributes->node_interaction_info;
EXPECT_TRUE(focusable_only_interaction_info.is_focusable);
EXPECT_FALSE(focusable_only_interaction_info.is_tabbable);
const auto& tabbable_node = *ContentRootNode().children_nodes[1];
ASSERT_TRUE(tabbable_node.content_attributes->node_interaction_info);
const auto& tabbable_interaction_info =
*tabbable_node.content_attributes->node_interaction_info;
EXPECT_TRUE(tabbable_interaction_info.is_focusable);
EXPECT_TRUE(tabbable_interaction_info.is_tabbable);
}
TEST_F(AIPageContentAgentTest, HasAriaActivedescendant) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"(
<body>
<div role="listbox" tabindex="0" aria-activedescendant="active-option">
<div id="active-option" role="option">Active Option</div>
</div>
</body>)",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& listbox_node = *ContentRootNode().children_nodes[0];
ASSERT_TRUE(listbox_node.content_attributes->node_interaction_info);
EXPECT_TRUE(listbox_node.content_attributes->node_interaction_info
->has_aria_activedescendant);
}
TEST_F(AIPageContentAgentTest, AriaActionTargetNodeIds) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"(
<body>
<div role="tab" tabindex="0" aria-actions="close-action">
Closable Tab
<button id="close-action">Close</button>
</div>
</body>)",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& tab_node = *ContentRootNode().children_nodes[0];
ASSERT_TRUE(tab_node.content_attributes->node_interaction_info);
ASSERT_EQ(tab_node.content_attributes->node_interaction_info
->aria_action_target_node_ids.size(),
1u);
const auto& close_button_node = *tab_node.children_nodes[1];
ASSERT_TRUE(close_button_node.content_attributes->dom_node_id.has_value());
EXPECT_EQ(tab_node.content_attributes->node_interaction_info
->aria_action_target_node_ids[0],
*close_button_node.content_attributes->dom_node_id);
}
TEST_F(AIPageContentAgentTest, ClipPathCircle) {
// The <div> element is clipped to a small circle.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"(
<body>
<style>
div {
position: absolute;
top: 0;
left: 0;
width: 100px;
height: 100px;
background: red;
clip-path: circle(20%);
}
</style>
<div onclick=console.log(1)></div>
</body>)",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& div_node = *ContentRootNode().children_nodes[0];
CheckGeometry(div_node, gfx::Rect(30, 30, 40, 40), gfx::Rect(30, 30, 40, 40));
}
TEST_F(AIPageContentAgentTest, ClipPathEllipse) {
// The <div> element is clipped to an ellipse.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"(
<body>
<style>
div {
position: absolute;
top: 0;
left: 0;
width: 100px;
height: 100px;
background: red;
clip-path: ellipse(20px 50px);
}
</style>
<div onclick=console.log(1)></div>
</body>)",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& div_node = *ContentRootNode().children_nodes[0];
CheckGeometry(div_node, gfx::Rect(30, 0, 40, 100), gfx::Rect(30, 0, 40, 100));
}
TEST_F(AIPageContentAgentTest, ClipPathCircleHalf) {
// Only the bottom half of the circle is within the viewport.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"(
<body>
<style>
div {
position: absolute;
top: -50px;
left: 0;
width: 100px;
height: 100px;
background: red;
clip-path: circle(20%);
}
</style>
<div onclick=console.log(1)></div>
</body>)",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& div_node = *ContentRootNode().children_nodes[0];
CheckGeometry(div_node, gfx::Rect(30, -20, 40, 40), gfx::Rect(30, 0, 40, 20));
}
TEST_F(AIPageContentAgentTest, ClipPathEmpty) {
// The entire <div> element is clipped.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"(
<body>
<style>
body { margin: 0; }
div {
position: absolute;
top: 200px;
left: 300px;
width: 100px;
height: 100px;
background: red;
clip-path: circle(0 at left top);
}
</style>
<div onclick=console.log(1)></div>
</body>)",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& div_node = *ContentRootNode().children_nodes[0];
const auto& geometry = *div_node.content_attributes->geometry;
EXPECT_TRUE(geometry.outer_bounding_box.IsEmpty());
EXPECT_TRUE(geometry.visible_bounding_box.IsEmpty());
}
TEST_F(AIPageContentAgentTest, InlineWithFloatAndInlineContentGeometry) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"HTML(
<body>
<style>
body { margin: 0; font: 10px/10px Ahem; }
a { position: absolute; top: 0; left: 0; color: black; }
#floater { float: left; width: 30px; height: 10px; background: #ccc; }
</style>
<a id="target" href="#"><span id="floater"></span>XX</a>
</body>)HTML",
url_test_helpers::ToKURL("http://example.com"));
LoadAhem();
GetAIPageContentWithActionableElements();
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
Element* anchor_element = document->getElementById(AtomicString("target"));
ASSERT_TRUE(anchor_element);
LayoutObject* anchor_layout_object = anchor_element->GetLayoutObject();
ASSERT_TRUE(anchor_layout_object);
const auto* anchor_box =
DynamicTo<LayoutBoxModelObject>(anchor_layout_object);
ASSERT_TRUE(anchor_box);
const mojom::blink::AIPageContentNode* anchor_node =
FindNodeBySelector("#target");
ASSERT_TRUE(anchor_node);
ASSERT_TRUE(anchor_node->content_attributes->geometry);
const auto& anchor_geometry = *anchor_node->content_attributes->geometry;
EXPECT_EQ(gfx::Rect(0, 0, 50, 10), anchor_geometry.outer_bounding_box);
EXPECT_EQ(gfx::Rect(0, 0, 50, 10), anchor_geometry.visible_bounding_box);
const mojom::blink::AIPageContentNode* floater_node =
FindNodeBySelector("#floater");
ASSERT_TRUE(floater_node);
ASSERT_TRUE(floater_node->content_attributes->geometry);
const auto& floater_geometry = *floater_node->content_attributes->geometry;
EXPECT_EQ(gfx::Rect(0, 0, 30, 10), floater_geometry.outer_bounding_box);
EXPECT_EQ(gfx::Rect(0, 0, 30, 10), floater_geometry.visible_bounding_box);
}
TEST_F(AIPageContentAgentTest, ActionableModeKeepsContainerQuadGeometry) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"HTML(
<body style="margin: 0; font: 10px/10px Ahem;">
<details id="wrapper">
<summary>Expandable</summary>
Contents.
</details>
</body>)HTML",
url_test_helpers::ToKURL("http://example.com"));
LoadAhem();
GetAIPageContentWithActionableElements();
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
Element* wrapper_element = document->getElementById(AtomicString("wrapper"));
ASSERT_TRUE(wrapper_element);
DOMNodeId wrapper_dom_node_id = DOMNodeIds::IdForNode(wrapper_element);
const auto* wrapper_node = FindNodeByDomNodeId(wrapper_dom_node_id);
ASSERT_TRUE(wrapper_node);
CheckContainerNode(*wrapper_node);
ASSERT_TRUE(wrapper_node->content_attributes->geometry);
const auto& geometry = *wrapper_node->content_attributes->geometry;
LayoutObject* wrapper_layout_object = wrapper_element->GetLayoutObject();
ASSERT_TRUE(wrapper_layout_object);
const auto* wrapper_box =
DynamicTo<LayoutBoxModelObject>(wrapper_layout_object);
ASSERT_TRUE(wrapper_box);
EXPECT_EQ(gfx::Rect(0, 0, 1000, 10), geometry.outer_bounding_box);
EXPECT_EQ(gfx::Rect(0, 0, 1000, 10), geometry.visible_bounding_box);
}
TEST_F(AIPageContentAgentTest, InlineWithFloatInlineBoxUnionGeometry) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"HTML(
<body style="margin: 0; font: 10px/10px Ahem;">
<a id="inline-float-union">
<span id="inline-wrapper">
<span id="floated" class="inner-float">1</span>
<span id="inline-box" style="display:inline-block; width: 4px; height: 20px;">2</span>
</span>
</a>
<style>
.inner-float {
width: 20px;
height: 20px;
display: block;
float: left;
}
</style>
</body>)HTML",
url_test_helpers::ToKURL("http://example.com"));
LoadAhem();
GetAIPageContentWithActionableElements();
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
// As there is no clipping for either node, the outer box == visible box.
{
const auto* anchor_node = FindNodeBySelector("#inline-float-union");
ASSERT_TRUE(anchor_node);
ASSERT_TRUE(anchor_node->content_attributes);
ASSERT_TRUE(anchor_node->content_attributes->geometry);
const auto& anchor_geometry = *anchor_node->content_attributes->geometry;
EXPECT_EQ(anchor_geometry.outer_bounding_box,
anchor_geometry.visible_bounding_box);
}
{
const auto* container_node = FindNodeBySelector("#inline-wrapper");
ASSERT_TRUE(container_node);
ASSERT_TRUE(container_node->content_attributes);
ASSERT_TRUE(container_node->content_attributes->geometry);
const auto& container_geometry =
*container_node->content_attributes->geometry;
EXPECT_EQ(container_geometry.outer_bounding_box,
container_geometry.visible_bounding_box);
}
}
TEST_F(AIPageContentAgentTest, InlineWithOnlyFloatGeometry) {
// An inline anchor with no inline text content but containing a floated
// descendant should not inherit geometry from the float.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"(
<body>
<style>
body { margin: 0; }
a { position: absolute; left: 0; top: 0; }
#floater { float: left; width: 20px; height: 10px; background: #000; }
</style>
<a href="#"><span><span id="floater"></span></span></a>
</body>)",
url_test_helpers::ToKURL("http://example.com"));
// Use actionable mode so the anchor is included in the APC tree.
GetAIPageContentWithActionableElements();
// Find the first node that has anchor data (iterative DFS, no std::function).
const auto& root = ContentRootNode();
const mojom::blink::AIPageContentNode* anchor_node = nullptr;
Vector<const mojom::blink::AIPageContentNode*> stack;
stack.push_back(&root);
while (!stack.empty()) {
const auto* node = stack.back();
stack.pop_back();
if (node->content_attributes && node->content_attributes->anchor_data) {
anchor_node = node;
break;
}
// Push children in reverse so traversal order matches natural order.
for (wtf_size_t i = node->children_nodes.size(); i > 0; --i) {
stack.push_back(node->children_nodes[i - 1].get());
}
}
ASSERT_TRUE(anchor_node);
ASSERT_TRUE(anchor_node->content_attributes->geometry);
const auto& anchor_geom = *anchor_node->content_attributes->geometry;
// Inline elements that keep their own fragment should retain geometry even
// when their only ink comes from a floating descendant.
EXPECT_EQ(anchor_geom.outer_bounding_box, gfx::Rect(0, 0, 20, 10));
EXPECT_EQ(anchor_geom.visible_bounding_box, gfx::Rect(0, 0, 20, 10));
}
TEST_F(AIPageContentAgentTest, StructuralWrapperWithoutPaintGeometry) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style="margin: 0; font: 10px/10px Ahem;">
<a id="wrapper" href="#" style="position: relative; display: inline-block;">
<span id="child" style="position: absolute; left: 0; top: 0;
width: 10px; height: 10px;
background: black; color: white;">X</span>
</a>
</body>)HTML",
url_test_helpers::ToKURL("http://example.com"));
LoadAhem();
GetAIPageContentWithActionableElements();
auto* wrapper_node = FindNodeBySelector("#wrapper");
ASSERT_TRUE(wrapper_node);
EXPECT_TRUE(wrapper_node->content_attributes->anchor_data);
ASSERT_TRUE(wrapper_node->content_attributes->geometry);
const auto& wrapper_geometry = *wrapper_node->content_attributes->geometry;
ASSERT_EQ(wrapper_geometry.fragment_visible_bounding_boxes.size(), 1u);
auto* child_node = FindNodeBySelector("#child");
ASSERT_TRUE(child_node);
ASSERT_TRUE(child_node->content_attributes->geometry);
const auto& child_geometry = *child_node->content_attributes->geometry;
EXPECT_FALSE(child_geometry.visible_bounding_box.IsEmpty());
EXPECT_FALSE(child_geometry.outer_bounding_box.IsEmpty());
EXPECT_EQ(wrapper_geometry.outer_bounding_box,
child_geometry.outer_bounding_box);
EXPECT_EQ(wrapper_geometry.visible_bounding_box,
child_geometry.visible_bounding_box);
EXPECT_EQ(wrapper_geometry.fragment_visible_bounding_boxes[0],
child_geometry.visible_bounding_box);
}
TEST_F(AIPageContentAgentTest,
NonActionableZeroAreaContainerUsesChildGeometry) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style="margin: 0; font: 10px/10px Ahem;">
<article id="container" style="position: relative; width: 0;
height: 0;">
<button id="child" style="position: absolute; left: 10px; top: 20px;
width: 20px; height: 10px;">X</button>
</article>
</body>)HTML",
url_test_helpers::ToKURL("http://example.com"));
LoadAhem();
GetAIPageContentWithActionableElements();
const auto* container = FindNodeBySelector("#container");
const auto* child = FindNodeBySelector("#child");
ASSERT_TRUE(container);
ASSERT_TRUE(child);
ASSERT_TRUE(container->content_attributes->geometry);
ASSERT_TRUE(child->content_attributes->geometry);
// The article is included for its structure, not because it is actionable.
EXPECT_FALSE(container->content_attributes->node_interaction_info);
const auto& container_geometry = *container->content_attributes->geometry;
const auto& child_geometry = *child->content_attributes->geometry;
// Ensure equality cannot pass because both source and repaired boxes are
// empty.
EXPECT_FALSE(child_geometry.outer_bounding_box.IsEmpty());
EXPECT_FALSE(child_geometry.visible_bounding_box.IsEmpty());
EXPECT_EQ(container_geometry.outer_bounding_box,
child_geometry.outer_bounding_box);
EXPECT_EQ(container_geometry.visible_bounding_box,
child_geometry.visible_bounding_box);
EXPECT_THAT(container_geometry.fragment_visible_bounding_boxes,
testing::ElementsAre(child_geometry.visible_bounding_box));
}
TEST_F(AIPageContentAgentTest,
ZeroAreaAnchorsUseVisibleOutOfFlowChildrenAsFragments) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style="margin: 0; font: 10px/10px Ahem;">
<a id="float" href="#" class="zero">
<span id="float-child-1" class="float-child" onclick="void(0)">A</span>
<span id="float-child-2" class="float-child" onclick="void(0)">B</span>
</a>
<a id="absolute" href="#" class="zero">
<span id="absolute-child-1" class="absolute-child"
onclick="void(0)">C</span>
<span id="absolute-child-2" class="absolute-child second"
onclick="void(0)">D</span>
</a>
<a id="fixed" href="#" class="zero">
<span id="fixed-child-1" class="fixed-child"
onclick="void(0)">E</span>
<span id="fixed-child-2" class="fixed-child second"
onclick="void(0)">F</span>
</a>
<style>
.zero {
display: inline-block;
position: relative;
width: 0;
height: 0;
}
.float-child { float: left; width: 20px; height: 10px; }
.absolute-child {
position: absolute;
left: 0;
top: 20px;
width: 20px;
height: 10px;
}
.fixed-child {
position: fixed;
left: 0;
top: 40px;
width: 20px;
height: 10px;
}
.second { left: 30px; }
</style>
</body>)HTML",
url_test_helpers::ToKURL("http://example.com"));
LoadAhem();
GetAIPageContentWithActionableElements();
auto expect_anchor_geometry_from_children =
[this](const char* anchor_selector, const char* first_child_selector,
const char* second_child_selector) {
const auto* anchor_node = FindNodeBySelector(anchor_selector);
ASSERT_TRUE(anchor_node);
ASSERT_TRUE(anchor_node->content_attributes->geometry);
const auto& anchor_geometry =
*anchor_node->content_attributes->geometry;
const auto* first_child = FindNodeBySelector(first_child_selector);
ASSERT_TRUE(first_child);
ASSERT_TRUE(first_child->content_attributes->geometry);
const gfx::Rect first_child_box =
first_child->content_attributes->geometry->visible_bounding_box;
EXPECT_FALSE(first_child_box.IsEmpty());
const auto* second_child = FindNodeBySelector(second_child_selector);
ASSERT_TRUE(second_child);
ASSERT_TRUE(second_child->content_attributes->geometry);
const gfx::Rect second_child_box =
second_child->content_attributes->geometry->visible_bounding_box;
EXPECT_FALSE(second_child_box.IsEmpty());
EXPECT_THAT(
anchor_geometry.fragment_visible_bounding_boxes,
testing::UnorderedElementsAre(first_child_box, second_child_box));
gfx::Rect expected_outer_box =
first_child->content_attributes->geometry->outer_bounding_box;
expected_outer_box.Union(
second_child->content_attributes->geometry->outer_bounding_box);
EXPECT_EQ(anchor_geometry.outer_bounding_box, expected_outer_box);
gfx::Rect expected_visible_box = first_child_box;
expected_visible_box.Union(second_child_box);
EXPECT_EQ(anchor_geometry.visible_bounding_box, expected_visible_box);
};
expect_anchor_geometry_from_children("#float", "#float-child-1",
"#float-child-2");
expect_anchor_geometry_from_children("#absolute", "#absolute-child-1",
"#absolute-child-2");
expect_anchor_geometry_from_children("#fixed", "#fixed-child-1",
"#fixed-child-2");
}
TEST_F(AIPageContentAgentTest,
ZeroAreaAncestorUsesFirstNonemptyGeometryFromEachBranch) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style="margin: 0; font: 10px/10px Ahem;">
<a id="anchor" href="#" class="zero">
<span id="deep-zero-1" class="zero" onclick="void(0)">
<span id="deep-zero-2" class="zero" onclick="void(0)">
<span id="deep-leaf" class="deep-leaf"
onclick="void(0)">A</span>
</span>
</span>
<span id="nonempty-branch" class="nonempty-branch"
onclick="void(0)">
<span id="far-leaf" class="far-leaf"
onclick="void(0)">B</span>
</span>
</a>
<style>
.zero {
display: inline-block;
position: relative;
width: 0;
height: 0;
}
.deep-leaf, .far-leaf {
position: absolute;
display: block;
width: 10px;
height: 10px;
}
.deep-leaf { left: 20px; top: 10px; }
.nonempty-branch {
display: inline-block;
position: relative;
width: 5px;
height: 5px;
}
.far-leaf { left: 100px; top: 100px; }
</style>
</body>)HTML",
url_test_helpers::ToKURL("http://example.com"));
LoadAhem();
GetAIPageContentWithActionableElements();
const auto* anchor = FindNodeBySelector("#anchor");
const auto* deep_zero_1 = FindNodeBySelector("#deep-zero-1");
const auto* deep_zero_2 = FindNodeBySelector("#deep-zero-2");
const auto* deep_leaf = FindNodeBySelector("#deep-leaf");
const auto* nonempty_branch = FindNodeBySelector("#nonempty-branch");
const auto* far_leaf = FindNodeBySelector("#far-leaf");
ASSERT_TRUE(anchor);
ASSERT_TRUE(deep_zero_1);
ASSERT_TRUE(deep_zero_2);
ASSERT_TRUE(deep_leaf);
ASSERT_TRUE(nonempty_branch);
ASSERT_TRUE(far_leaf);
ASSERT_TRUE(anchor->content_attributes->geometry);
ASSERT_TRUE(deep_zero_1->content_attributes->geometry);
ASSERT_TRUE(deep_zero_2->content_attributes->geometry);
ASSERT_TRUE(deep_leaf->content_attributes->geometry);
ASSERT_TRUE(nonempty_branch->content_attributes->geometry);
ASSERT_TRUE(far_leaf->content_attributes->geometry);
const auto& anchor_geometry = *anchor->content_attributes->geometry;
const auto& deep_leaf_geometry = *deep_leaf->content_attributes->geometry;
const auto& nonempty_branch_geometry =
*nonempty_branch->content_attributes->geometry;
const auto& far_leaf_geometry = *far_leaf->content_attributes->geometry;
// Each zero-area level on the deep branch receives the same first nonempty
// geometry from below it.
EXPECT_EQ(deep_zero_2->content_attributes->geometry->visible_bounding_box,
deep_leaf_geometry.visible_bounding_box);
EXPECT_EQ(deep_zero_1->content_attributes->geometry->visible_bounding_box,
deep_leaf_geometry.visible_bounding_box);
// The anchor receives one contribution from each branch. The far leaf does
// not contribute because its branch already has nonempty container geometry.
EXPECT_THAT(anchor_geometry.fragment_visible_bounding_boxes,
testing::UnorderedElementsAre(
deep_leaf_geometry.visible_bounding_box,
nonempty_branch_geometry.visible_bounding_box));
EXPECT_THAT(
anchor_geometry.fragment_visible_bounding_boxes,
testing::Not(testing::Contains(far_leaf_geometry.visible_bounding_box)));
}
TEST_F(AIPageContentAgentTest,
ZeroAreaAnchorsUseGeometryThroughNonpaintingWrappers) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style="margin: 0; font: 10px/10px Ahem;">
<a id="collapse-anchor" href="#" class="zero">
<span style="visibility: collapse;">
<span id="collapse-child" class="collapse-child"
onclick="void(0)">A</span>
</span>
</a>
<a id="contents-anchor" href="#" class="zero">
<span style="display: contents;">
<span id="contents-child" class="contents-child"
onclick="void(0)">B</span>
</span>
</a>
<style>
.zero {
display: inline-block;
position: relative;
width: 0;
height: 0;
}
.collapse-child, .contents-child {
position: absolute;
display: block;
width: 20px;
height: 10px;
visibility: visible;
}
.collapse-child { left: 10px; top: 20px; }
.contents-child { left: 40px; top: 20px; }
</style>
</body>)HTML",
url_test_helpers::ToKURL("http://example.com"));
LoadAhem();
GetAIPageContentWithActionableElements();
auto expect_anchor_matches_child = [this](const char* anchor_selector,
const char* child_selector) {
const auto* anchor = FindNodeBySelector(anchor_selector);
const auto* child = FindNodeBySelector(child_selector);
ASSERT_TRUE(anchor);
ASSERT_TRUE(child);
ASSERT_TRUE(anchor->content_attributes->geometry);
ASSERT_TRUE(child->content_attributes->geometry);
const auto& anchor_geometry = *anchor->content_attributes->geometry;
const auto& child_geometry = *child->content_attributes->geometry;
EXPECT_EQ(anchor_geometry.outer_bounding_box,
child_geometry.outer_bounding_box);
EXPECT_EQ(anchor_geometry.visible_bounding_box,
child_geometry.visible_bounding_box);
EXPECT_THAT(anchor_geometry.fragment_visible_bounding_boxes,
testing::ElementsAre(child_geometry.visible_bounding_box));
};
expect_anchor_matches_child("#collapse-anchor", "#collapse-child");
expect_anchor_matches_child("#contents-anchor", "#contents-child");
}
TEST_F(AIPageContentAgentTest,
ZeroAreaAnchorCombinesFragmentsFromMultipleSources) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style="margin: 0; font: 10px/10px Ahem;">
<a id="anchor" href="#">AB CD<span id="sibling"
onclick="void(0)">E</span></a>
<style>
#anchor {
display: block;
position: relative;
width: 20px;
height: 0;
}
#sibling {
position: absolute;
left: 50px;
top: 30px;
width: 10px;
height: 10px;
}
</style>
</body>)HTML",
url_test_helpers::ToKURL("http://example.com"));
LoadAhem();
GetAIPageContentWithActionableElements();
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
Element* anchor = document->getElementById(AtomicString("anchor"));
ASSERT_TRUE(anchor);
Node* wrapping_text = anchor->firstChild();
ASSERT_TRUE(wrapping_text);
ASSERT_TRUE(wrapping_text->IsTextNode());
const auto* anchor_node = FindNodeBySelector("#anchor");
const auto* text_node =
FindNodeByDomNodeId(DOMNodeIds::IdForNode(wrapping_text));
const auto* sibling_node = FindNodeBySelector("#sibling");
ASSERT_TRUE(anchor_node);
ASSERT_TRUE(text_node);
ASSERT_TRUE(sibling_node);
ASSERT_TRUE(anchor_node->content_attributes->geometry);
ASSERT_TRUE(text_node->content_attributes->geometry);
ASSERT_TRUE(sibling_node->content_attributes->geometry);
const auto& anchor_geometry = *anchor_node->content_attributes->geometry;
const auto& text_geometry = *text_node->content_attributes->geometry;
const auto& sibling_geometry = *sibling_node->content_attributes->geometry;
ASSERT_EQ(text_geometry.fragment_visible_bounding_boxes.size(), 2u);
ASSERT_TRUE(sibling_geometry.fragment_visible_bounding_boxes.empty());
// `#sibling` is inside the anchor and is the wrapping text node's sibling.
// The empty anchor therefore combines the first nonempty geometry from both
// child branches.
Vector<gfx::Rect> expected_fragments =
text_geometry.fragment_visible_bounding_boxes;
expected_fragments.push_back(sibling_geometry.visible_bounding_box);
EXPECT_THAT(anchor_geometry.fragment_visible_bounding_boxes,
testing::UnorderedElementsAreArray(expected_fragments));
gfx::Rect expected_outer_box = text_geometry.outer_bounding_box;
expected_outer_box.Union(sibling_geometry.outer_bounding_box);
EXPECT_EQ(anchor_geometry.outer_bounding_box, expected_outer_box);
gfx::Rect expected_visible_box = text_geometry.visible_bounding_box;
expected_visible_box.Union(sibling_geometry.visible_bounding_box);
EXPECT_EQ(anchor_geometry.visible_bounding_box, expected_visible_box);
}
TEST_F(AIPageContentAgentTest, InlinePreWrapGeometry) {
// Inline wrappers that rely on white-space:pre-wrap frequently delegate all
// actual painting to anonymous block fragments generated for line wrapping.
// The legacy outer-bounding-box path used AbsoluteBoundingBoxRect() on the
// LayoutInline host, so it produced an empty rect even though the wrapped
// text occupied multiple lines. Mapping via GeometryMapper keeps outer and
// visible boxes consistent in these inlines-with-block-descendants cases.
ScopedAIPageContentOuterBoxMapToAncestorSpaceForTest enable_outer_box_mapping(
true);
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<style>
body { margin: 0; font: 16px/16px Ahem; }
#prewrap-inline { white-space: pre-wrap; }
</style>
<body>
<span id="prewrap-inline">
<span>Label:</span> Wrapped inline text.
</span>
</body>)HTML",
url_test_helpers::ToKURL("http://example.com"));
LoadAhem();
GetAIPageContentWithActionableElements();
const auto* entry_node = FindNodeBySelector("#prewrap-inline");
ASSERT_TRUE(entry_node);
ASSERT_TRUE(entry_node->content_attributes);
ASSERT_TRUE(entry_node->content_attributes->geometry);
const auto& geometry = *entry_node->content_attributes->geometry;
EXPECT_FALSE(geometry.outer_bounding_box.IsEmpty());
EXPECT_FALSE(geometry.visible_bounding_box.IsEmpty());
EXPECT_EQ(geometry.outer_bounding_box, geometry.visible_bounding_box);
}
TEST_F(AIPageContentAgentTest, IframeOuterBoxNotViewportClipped) {
// Force outer-box mapping on so GeometryMapper is used for both the visible
// and unclipped bounds.
ScopedAIPageContentOuterBoxMapToAncestorSpaceForTest enable_outer_box_mapping(
true);
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<style>
body { margin: 0; }
iframe {
border: none;
width: 200px;
height: 150px;
}
</style>
<body>
<iframe id="offscreen-frame" srcdoc="
<style>
body { margin: 0; height: 800px; position: relative; font: 10px/10px Ahem; }
#deep {
position: absolute;
left: 20px;
top: -10px;
width: 50px;
height: 40px;
background: lightgreen;
}
</style>
<body>
<a id='deep' href='#'>Deep target</a>
</body>">
</iframe>
</body>)HTML",
url_test_helpers::ToKURL("http://example.com"));
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
LocalFrameView* view = document->View();
ASSERT_TRUE(view);
test::RunPendingTasks();
view->UpdateAllLifecyclePhasesForTest();
auto* iframe_element = DynamicTo<HTMLIFrameElement>(
document->getElementById(AtomicString("offscreen-frame")));
ASSERT_TRUE(iframe_element);
LocalFrame* child_frame =
DynamicTo<LocalFrame>(iframe_element->ContentFrame());
ASSERT_TRUE(child_frame);
PageTestBase::LoadAhem(*child_frame);
if (LocalFrameView* child_view = child_frame->View()) {
test::RunPendingTasks();
child_view->UpdateAllLifecyclePhasesForTest();
}
Document* child_document = child_frame->GetDocument();
ASSERT_TRUE(child_document);
Element* deep = child_document->getElementById(AtomicString("deep"));
ASSERT_TRUE(deep);
GetAIPageContentWithActionableElements();
DOMNodeId deep_dom_node_id = DOMNodeIds::IdForNode(deep);
ASSERT_GE(deep_dom_node_id, 1);
const auto* deep_node = FindNodeByDomNodeId(deep_dom_node_id);
ASSERT_TRUE(deep_node);
ASSERT_TRUE(deep_node->content_attributes);
ASSERT_TRUE(deep_node->content_attributes->geometry);
// The element straddles the iframe's viewport top edge. When
// kSkipAncestorAndViewportClips is honored, MapToVisualRectInAncestorSpace
// skips that viewport clip so the outer box reports the true location in the
// embedder viewport, while the visible box remains clipped to the portion
// that is painted.
const auto& geometry = *deep_node->content_attributes->geometry;
EXPECT_EQ(geometry.outer_bounding_box, gfx::Rect(20, -10, 50, 40));
EXPECT_EQ(geometry.visible_bounding_box, gfx::Rect(20, 0, 50, 30));
}
TEST_F(AIPageContentAgentTest, InlineBlockFixedDescendantKeepsGeometry) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<style>
body { margin: 0; font: 10px/10px Ahem; }
.content { padding: 60px 0 0 40px; }
a.inline-block-anchor {
display: inline-block;
width: 100px;
height: 100px;
border: 1px solid black;
}
.fixed-box {
position: fixed;
top: 0;
left: 0;
width: 200px;
height: 25px;
}
</style>
<body>
<div class="content">
<a id="anchor" class="inline-block-anchor" href="#test">
<div class="fixed-box">a</div>
</a>
</div>
</body>)HTML",
url_test_helpers::ToKURL("http://example.com"));
GetAIPageContentWithActionableElements();
const auto* anchor_node = FindNodeBySelector("#anchor");
ASSERT_TRUE(anchor_node);
const auto& attributes = *anchor_node->content_attributes;
EXPECT_EQ(attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kAnchor);
ASSERT_TRUE(attributes.geometry);
const auto& geometry = *attributes.geometry;
EXPECT_EQ(gfx::Rect(40, 60, 102, 102), geometry.outer_bounding_box);
EXPECT_EQ(geometry.outer_bounding_box, geometry.visible_bounding_box);
}
TEST_F(AIPageContentAgentTest, GeometryIncludesCssPosition) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<style>
body { margin: 0; font: 10px/10px Ahem; }
#fixed {
position: fixed;
top: 0;
left: 0;
width: 50px;
height: 10px;
}
</style>
<body>
<button id="fixed">Action</button>
</body>)HTML",
url_test_helpers::ToKURL("http://example.com"));
GetAIPageContentWithActionableElements();
const auto* fixed_node = FindNodeBySelector("#fixed");
ASSERT_TRUE(fixed_node);
ASSERT_TRUE(fixed_node->content_attributes);
ASSERT_TRUE(fixed_node->content_attributes->geometry);
// A fixed control should carry its computed CSS position alongside geometry.
EXPECT_EQ(fixed_node->content_attributes->geometry->css_position,
mojom::blink::AIPageContentCssPosition::kFixed);
}
TEST_F(AIPageContentAgentTest, TableTextClippedByScrollerBeforeScroll) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<style>
body { margin: 0; font: 10px/10px Ahem; }
* { margin: 0; padding: 0; box-sizing: border-box; }
main {
width: 70px;
height: 30px;
overflow: auto;
}
table {
width: 120%;
height: 30px;
table-layout: fixed;
border-collapse: collapse;
}
td.small10x10 {
width: 100px;
height: 3ch;
vertical-align: top;
font: 10px/10px Ahem;
}
</style>
<body>
<main id="scroller">
<table>
<tr>
<td id="cell" class="small10x10">ABC DEF GHI JKL MNO PQR STU VWX YZ 0123456789</td>
<td></td>
</tr>
<tr>
<td></td>
<td style="height: 100vh; width: 100vh;"></td>
</tr>
</table>
</main>
</body>)HTML",
url_test_helpers::ToKURL("http://example.com"));
LoadAhem();
GetAIPageContentWithActionableElements();
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
Element* cell = document->getElementById(AtomicString("cell"));
ASSERT_TRUE(cell);
Node* text = cell->firstChild();
ASSERT_TRUE(text);
ASSERT_TRUE(text->IsTextNode());
const auto* text_node = FindNodeByDomNodeId(DOMNodeIds::IdForNode(text));
ASSERT_TRUE(text_node);
ASSERT_TRUE(text_node->content_attributes->geometry);
Element* scroller_element =
document->getElementById(AtomicString("scroller"));
ASSERT_TRUE(scroller_element);
const auto& geometry = *text_node->content_attributes->geometry;
const auto& fragments = geometry.fragment_visible_bounding_boxes;
const ComputedStyle& before_style = text->GetLayoutObject()->StyleRef();
LocalFrame* local_frame = document->GetFrame();
ASSERT_TRUE(local_frame);
EXPECT_NEAR(before_style.ComputedFontSize(), 10.0f, 0.01f);
EXPECT_NEAR(before_style.GetFont()->GetFontDescription().ComputedSize(),
10.0f, 0.01f);
EXPECT_EQ(gfx::Rect(0, 0, 100, 50), geometry.outer_bounding_box);
EXPECT_EQ(gfx::Rect(0, 0, 70, 30), geometry.visible_bounding_box);
ASSERT_EQ(fragments.size(), 3u);
EXPECT_EQ(fragments[0], gfx::Rect(0, 0, 70, 10));
EXPECT_EQ(fragments[1], gfx::Rect(0, 10, 70, 10));
EXPECT_EQ(fragments[2], gfx::Rect(0, 20, 70, 10));
}
TEST_F(AIPageContentAgentTest, TableTextClippedByScrollerAfterScroll) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<style>
body { margin: 0; font: 10px/10px Ahem; }
* { margin: 0; padding: 0; box-sizing: border-box; }
main {
width: 70px;
height: 30px;
overflow: auto;
}
table {
width: 120%;
height: 30px;
table-layout: fixed;
border-collapse: collapse;
}
td.small10x10 {
width: 100px;
height: 3ch;
vertical-align: top;
font: 10px/10px Ahem;
}
</style>
<body>
<main id="scroller">
<table>
<tr>
<td id="cell" class="small10x10">ABC DEF GHI JKL MNO PQR STU VWX YZ 0123456789</td>
<td></td>
</tr>
<tr>
<td></td>
<td style="height: 100vh; width: 100vh;"></td>
</tr>
</table>
</main>
</body>)HTML",
url_test_helpers::ToKURL("http://example.com"));
LoadAhem();
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
Element* scroller = document->getElementById(AtomicString("scroller"));
ASSERT_TRUE(scroller);
scroller->setScrollLeft(15);
scroller->setScrollTop(15);
LocalFrameView* view = document->View();
ASSERT_TRUE(view);
test::RunPendingTasks();
view->UpdateAllLifecyclePhasesForTest();
GetAIPageContentWithActionableElements();
Element* cell = document->getElementById(AtomicString("cell"));
ASSERT_TRUE(cell);
Node* text = cell->firstChild();
ASSERT_TRUE(text);
ASSERT_TRUE(text->IsTextNode());
const auto* text_node = FindNodeByDomNodeId(DOMNodeIds::IdForNode(text));
ASSERT_TRUE(text_node);
ASSERT_TRUE(text_node->content_attributes->geometry);
const auto& geometry = *text_node->content_attributes->geometry;
const auto& fragments = geometry.fragment_visible_bounding_boxes;
const ComputedStyle& after_style = text->GetLayoutObject()->StyleRef();
std::string fragments_trace;
for (wtf_size_t i = 0; i < fragments.size(); ++i) {
if (!fragments_trace.empty()) {
fragments_trace.append("; ");
}
std::string fragment_rect = fragments[i].ToString();
fragments_trace.append(
base::StringPrintf("%zu:%s", i, fragment_rect.c_str()));
}
LocalFrame* local_frame = document->GetFrame();
ASSERT_TRUE(local_frame);
EXPECT_NEAR(after_style.ComputedFontSize(), 10.0f, 0.01f);
EXPECT_NEAR(after_style.GetFont()->GetFontDescription().ComputedSize(), 10.0f,
0.01f);
EXPECT_EQ(gfx::Rect(-15, -15, 100, 50), geometry.outer_bounding_box);
EXPECT_EQ(gfx::Rect(0, 0, 70, 30), geometry.visible_bounding_box);
ASSERT_EQ(fragments.size(), 4u);
EXPECT_EQ(fragments[0], gfx::Rect(0, 0, 55, 5));
EXPECT_EQ(fragments[1], gfx::Rect(0, 5, 55, 10));
EXPECT_EQ(fragments[2], gfx::Rect(0, 15, 70, 10));
EXPECT_EQ(fragments[3], gfx::Rect(0, 25, 70, 5));
}
TEST_F(AIPageContentAgentTest, IframeInlineTextClippedWhenViewportScrolled) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<style>
body { margin: 0; height: 2000px; font: 10px/10px Ahem; }
iframe {
border: none;
width: 100px;
height: 200px;
}
</style>
<body>
<iframe id="target" srcdoc="
<style>
body { margin: 0; font: 10px/10px Ahem; }
p { margin: 0; background: red; }
</style>
<body>
<p id='inner'>ABC DEF GHI JKL MNO PQR STU</p>
</body>">
</iframe>
</body>)HTML",
url_test_helpers::ToKURL("http://example.com"));
LoadAhem();
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
Element* scrolling_element = document->scrollingElement();
ASSERT_TRUE(scrolling_element);
scrolling_element->setScrollLeft(15);
scrolling_element->setScrollTop(15);
LocalFrameView* view = document->View();
ASSERT_TRUE(view);
test::RunPendingTasks();
view->UpdateAllLifecyclePhasesForTest();
HTMLIFrameElement* iframe_element = DynamicTo<HTMLIFrameElement>(
document->getElementById(AtomicString("target")));
ASSERT_TRUE(iframe_element);
LocalFrame* child_frame =
DynamicTo<LocalFrame>(iframe_element->ContentFrame());
ASSERT_TRUE(child_frame);
// Load Ahem inside the iframe before we snapshot geometry to avoid races on
// platforms where font activation is asynchronous.
PageTestBase::LoadAhem(*child_frame);
test::RunPendingTasks();
if (LocalFrameView* child_view = child_frame->View()) {
child_view->UpdateAllLifecyclePhasesForTest();
}
Document* child_document = child_frame->GetDocument();
ASSERT_TRUE(child_document);
Element* inner_p = child_document->getElementById(AtomicString("inner"));
ASSERT_TRUE(inner_p);
Node* inner_text = inner_p->firstChild();
ASSERT_TRUE(inner_text);
ASSERT_TRUE(inner_text->IsTextNode());
GetAIPageContentWithActionableElements();
const auto* text_node =
FindNodeByDomNodeId(DOMNodeIds::IdForNode(inner_text));
ASSERT_TRUE(text_node);
ASSERT_TRUE(text_node->content_attributes->geometry);
const auto& geometry = *text_node->content_attributes->geometry;
const auto& fragments = geometry.fragment_visible_bounding_boxes;
const ComputedStyle& iframe_style = inner_text->GetLayoutObject()->StyleRef();
std::string fragments_trace;
for (wtf_size_t i = 0; i < fragments.size(); ++i) {
if (!fragments_trace.empty()) {
fragments_trace.append("; ");
}
std::string fragment_rect = fragments[i].ToString();
fragments_trace.append(
base::StringPrintf("%zu:%s", i, fragment_rect.c_str()));
}
EXPECT_NEAR(iframe_style.ComputedFontSize(), 10.0f, 0.01f);
EXPECT_NEAR(iframe_style.GetFont()->GetFontDescription().ComputedSize(),
10.0f, 0.01f);
EXPECT_EQ(AtomicString("Ahem"),
iframe_style.GetFont()->GetFontDescription().Family().FamilyName());
EXPECT_EQ(gfx::Rect(0, -15, 70, 40), geometry.outer_bounding_box);
EXPECT_EQ(gfx::Rect(0, 0, 70, 25), geometry.visible_bounding_box);
ASSERT_EQ(fragments.size(), 3u);
EXPECT_EQ(fragments[0], gfx::Rect(0, 0, 70, 5));
EXPECT_EQ(fragments[1], gfx::Rect(0, 5, 70, 10));
EXPECT_EQ(fragments[2], gfx::Rect(0, 15, 30, 10));
}
TEST_F(AIPageContentAgentTest, CSSHoverPseudoClass) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"(
<body>
<style>
div:hover {
background-color: red;
}
</style>
<div onclick=console.log(1)></div>
<p>sibling</p>
</body>)",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& div_node = *ContentRootNode().children_nodes[0];
EXPECT_TRUE(
div_node.content_attributes->node_interaction_info->clickability_reasons
.Contains(ClickabilityReason::kHoverPseudoClass));
const auto& p_node = *ContentRootNode().children_nodes[1];
EXPECT_FALSE(
p_node.content_attributes->node_interaction_info->clickability_reasons
.Contains(ClickabilityReason::kHoverPseudoClass));
}
TEST_F(AIPageContentAgentTest, CSSHoverPseudoClassNotInherited) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(), R"(
<body>
<style>
div:hover {
background-color: red;
}
</style>
<div onclick=console.log(1)>
<p>child</p>
</div>
<p>sibling</p>
</body>)",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& div_node = *ContentRootNode().children_nodes[0];
EXPECT_TRUE(
div_node.content_attributes->node_interaction_info->clickability_reasons
.Contains(ClickabilityReason::kHoverPseudoClass));
const auto& child_p_node = *div_node.children_nodes[0];
EXPECT_FALSE(child_p_node.content_attributes->node_interaction_info
->clickability_reasons.Contains(
ClickabilityReason::kHoverPseudoClass));
const auto& sibling_p_node = *ContentRootNode().children_nodes[1];
EXPECT_FALSE(sibling_p_node.content_attributes->node_interaction_info
->clickability_reasons.Contains(
ClickabilityReason::kHoverPseudoClass));
}
// Tests hit-testing and z-order computations for AIPageContentAgent.
class AIPageContentAgentTestZOrder : public AIPageContentAgentTest {};
TEST_F(AIPageContentAgentTestZOrder, HitTestElementsBasic) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <p style='background:red'>Text 1</p>"
" <p>Text 2</p>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = *Content()->root_node;
ASSERT_TRUE(root.content_attributes->node_interaction_info);
EXPECT_EQ(
root.content_attributes->node_interaction_info->document_scoped_z_order,
1);
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& html = *root.children_nodes.at(0);
EXPECT_EQ(
html.content_attributes->node_interaction_info->document_scoped_z_order,
2);
ASSERT_EQ(html.children_nodes.size(), 1u);
const auto& body = *html.children_nodes.at(0);
EXPECT_EQ(
body.content_attributes->node_interaction_info->document_scoped_z_order,
3);
// The tree should look as follows, with the given z order.
// root - 1
// |_html - 2
// |_body - 3
// |_p - 4
// | |_Text1 - 5
// |_p - 6
// |_Text2 - 7
ASSERT_EQ(body.children_nodes.size(), 2u);
const auto& p1 = *body.children_nodes.at(0);
EXPECT_EQ(
p1.content_attributes->node_interaction_info->document_scoped_z_order, 4);
const auto& p2 = *body.children_nodes.at(1);
EXPECT_EQ(
p2.content_attributes->node_interaction_info->document_scoped_z_order, 6);
ASSERT_EQ(p1.children_nodes.size(), 1u);
const auto& text1 = *p1.children_nodes.at(0);
CheckTextNode(text1, "Text 1");
EXPECT_EQ(
text1.content_attributes->node_interaction_info->document_scoped_z_order,
5);
ASSERT_EQ(p2.children_nodes.size(), 1u);
const auto& text2 = *p2.children_nodes.at(0);
CheckTextNode(text2, "Text 2");
EXPECT_EQ(
text2.content_attributes->node_interaction_info->document_scoped_z_order,
7);
}
TEST_F(AIPageContentAgentTestZOrder, HitTestElementsFixedPos) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <p style='position: fixed; top: 10px;'>Text 1</p>"
" <p>Text 2</p>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 2u);
// The first node is now on top.
const auto& p1 = *root.children_nodes.at(0);
ASSERT_TRUE(p1.content_attributes->node_interaction_info);
ASSERT_TRUE(
p1.content_attributes->node_interaction_info->document_scoped_z_order);
EXPECT_EQ(
p1.content_attributes->node_interaction_info->document_scoped_z_order, 6);
const auto& p2 = *root.children_nodes.at(1);
ASSERT_TRUE(p2.content_attributes->node_interaction_info);
ASSERT_TRUE(
p2.content_attributes->node_interaction_info->document_scoped_z_order);
EXPECT_EQ(
p2.content_attributes->node_interaction_info->document_scoped_z_order, 4);
}
TEST_F(AIPageContentAgentTestZOrder, HitTestElementsPointerNone) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <p style='pointer-events:none'>Text 1</p>"
" <p>Text 2</p>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 2u);
// The first node is not actionable anymore.
const auto& p1 = *root.children_nodes.at(0);
EXPECT_FALSE(p1.content_attributes->node_interaction_info);
const auto& p2 = *root.children_nodes.at(1);
ASSERT_TRUE(p2.content_attributes->node_interaction_info);
ASSERT_TRUE(
p2.content_attributes->node_interaction_info->document_scoped_z_order);
}
TEST_F(AIPageContentAgentTestZOrder, HitTestElementsOffscreen) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <p style='cursor:pointer; position:fixed; top:110vh;'>Text 1</p>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
// A viewport-anchored node parked offscreen cannot be reached by scrolling,
// so APC should not serialize it as actionable.
const auto& p1 = *root.children_nodes.at(0);
EXPECT_FALSE(p1.content_attributes->node_interaction_info);
}
TEST_F(AIPageContentAgentTestZOrder, HitTestElementsOffscreenFeatureDisabled) {
ScopedAIPageContentAnchoredFixedOffscreenNonActionabilityForTest
scoped_feature(false);
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <p style='cursor:pointer; position:fixed; top:110vh;'>Text 1</p>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
// With the new gate disabled, APC keeps the previous behavior and still
// serializes actionability for the offscreen fixed node.
const auto& p1 = *root.children_nodes.at(0);
ASSERT_TRUE(p1.content_attributes->node_interaction_info);
}
TEST_F(AIPageContentAgentTestZOrder,
HitTestElementsNestedScrollerOffscreenFeaturesDisabled) {
ScopedAIPageContentAnchoredFixedOffscreenNonActionabilityForTest
scoped_fixed_feature(false);
ScopedAIPageContentAnchoredNonFixedOffscreenNonActionabilityForTest
scoped_non_fixed_feature(false);
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style="margin: 0;">
<div id="scroller" style="overflow: auto; width: 200px; height: 200px; position: relative;">
<p id="target" style="cursor: pointer; position: absolute; left: -9999px; top: 100px;">Text 1</p>
<div style="height: 1000px;"></div>
</div>
</body>)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
// Overflow-container state is tracked during traversal even when the
// actionability gates are off. With both gates disabled, APC should keep the
// older behavior and leave this offscreen nested-scroller target actionable.
const auto* target = FindNodeBySelector("#target");
ASSERT_TRUE(target);
ASSERT_TRUE(target->content_attributes->node_interaction_info);
}
TEST_F(AIPageContentAgentTestZOrder,
HitTestElementsFixedScrollableAncestorKeepsOffscreenChildActionable) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body style='margin: 0;'>"
" <div id='dialog' style='position: fixed; inset: 0;'>"
" <div id='scroller' style='height: 100px; overflow-y: auto;'>"
" <div style='height: 1200px;'></div>"
" <button id='target'>Bottom action</button>"
" </div>"
" </div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
// The button is below the current scrollport inside the fixed dialog, but it
// is still reachable by scrolling #scroller. APC should keep it actionable so
// downstream clients can scroll the ancestor before clicking the button.
const auto* target = FindNodeBySelector("#target");
ASSERT_TRUE(target);
ASSERT_TRUE(target->content_attributes->node_interaction_info);
const auto& interaction_info =
*target->content_attributes->node_interaction_info;
EXPECT_FALSE(interaction_info.clickability_reasons.empty());
}
TEST_F(AIPageContentAgentTestZOrder,
HitTestElementsFixedScrollerEscapesUnreachableAncestorScroller) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body style='margin: 0;'>"
" <div id='outer-scroller' style='overflow: auto; width: 200px;"
" height: 200px; position: absolute; left: -9999px; top: 0;'>"
" <div id='fixed-scroller' style='position: fixed; overflow: auto;"
" width: 100px; height: 100px; left: 10px; top: 10px;'>"
" <p id='target' style='cursor: pointer;'>Text 1</p>"
" <div style='height: 200px;'></div>"
" </div>"
" </div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
// The fixed scroller escapes the offscreen ancestor scroller and is visible
// in the viewport. Its target should not inherit the ancestor's unreachable
// overflow state.
const auto* target = FindNodeBySelector("#target");
ASSERT_TRUE(target);
ASSERT_TRUE(target->content_attributes->node_interaction_info);
const auto& interaction_info =
*target->content_attributes->node_interaction_info;
EXPECT_FALSE(interaction_info.clickability_reasons.empty());
}
TEST_F(AIPageContentAgentTestZOrder,
HitTestElementsOffscreenFixedInsideTransform) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body style='margin: 0; height: 2500px;'>"
" <div style='height: 1200px;'></div>"
" <div style='transform: translateZ(0); position: relative;'>"
" <p id='target' style='cursor:pointer; position:fixed; top:0;'>"
" Text 1"
" </p>"
" </div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto* target = FindNodeBySelector("#target");
ASSERT_TRUE(target);
ASSERT_TRUE(target->content_attributes->node_interaction_info);
const auto& interaction_info =
*target->content_attributes->node_interaction_info;
EXPECT_FALSE(interaction_info.clickability_reasons.empty());
}
TEST_F(AIPageContentAgentTestZOrder, HitTestElementsAbsoluteNegativeTrap) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <p id='target' style='cursor:pointer; position:absolute;"
" left:-9999px; top:0;'>"
" Text 1"
" </p>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto* target = FindNodeBySelector("#target");
ASSERT_TRUE(target);
EXPECT_FALSE(target->content_attributes->node_interaction_info);
}
TEST_F(AIPageContentAgentTestZOrder, HitTestElementsAbsoluteReachableByScroll) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body style='margin: 0; height: 7000px;'>"
" <p id='target' style='cursor:pointer; position:absolute;"
" left:0; top:5000px;'>"
" Text 1"
" </p>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto* target = FindNodeBySelector("#target");
ASSERT_TRUE(target);
ASSERT_TRUE(target->content_attributes->node_interaction_info);
const auto& interaction_info =
*target->content_attributes->node_interaction_info;
EXPECT_FALSE(interaction_info.clickability_reasons.empty());
}
TEST_F(AIPageContentAgentTestZOrder,
HitTestElementsAbsoluteReachableWhenScrolledPast) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body style='margin: 0; height: 7000px;'>"
" <p id='target' style='cursor:pointer; position:absolute;"
" left:0; top:200px;'>"
" Text 1"
" </p>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// Scroll the page so the target moves above the current viewport. The node
// should still stay actionable because the user can scroll back to it.
helper_.LocalMainFrame()->ExecuteScript(
WebScriptSource("window.scrollTo(0, 2000);"));
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
ASSERT_TRUE(document->View());
document->View()->UpdateAllLifecyclePhasesForTest();
GetAIPageContentWithActionableElements();
const auto* target = FindNodeBySelector("#target");
ASSERT_TRUE(target);
ASSERT_TRUE(target->content_attributes->node_interaction_info);
const auto& interaction_info =
*target->content_attributes->node_interaction_info;
EXPECT_FALSE(interaction_info.clickability_reasons.empty());
}
TEST_F(AIPageContentAgentTestZOrder,
HitTestElementsAbsoluteReachableWhenScrolledPastOnHighDPI) {
// This regression combines the two coordinate transforms the offscreen check
// now relies on: a visual-viewport shift from browser controls and a scaled
// page surface. The node is above the current viewport, but it should remain
// actionable because the user can scroll back to it.
frame_test_helpers::WebViewHelper high_dpi_helper(base::BindRepeating(
[](base::PassKey<WebLocalFrame> pass_key,
CrossVariantMojoAssociatedRemote<
mojom::blink::FrameWidgetHostInterfaceBase> frame_widget_host,
CrossVariantMojoAssociatedReceiver<
mojom::blink::FrameWidgetInterfaceBase> frame_widget,
CrossVariantMojoAssociatedRemote<mojom::blink::WidgetHostInterfaceBase>
widget_host,
CrossVariantMojoAssociatedReceiver<mojom::blink::WidgetInterfaceBase>
widget,
scoped_refptr<base::SingleThreadTaskRunner> task_runner,
const viz::FrameSinkId& frame_sink_id, bool hidden,
bool never_composited, bool is_for_child_local_root,
bool is_for_nested_main_frame,
bool is_for_scalable_page) -> frame_test_helpers::TestWebFrameWidget* {
auto* test_web_frame_widget =
MakeGarbageCollected<frame_test_helpers::TestWebFrameWidget>(
std::move(pass_key), std::move(frame_widget_host),
std::move(frame_widget), std::move(widget_host),
std::move(widget), std::move(task_runner), frame_sink_id,
hidden, never_composited, is_for_child_local_root,
is_for_nested_main_frame, is_for_scalable_page);
display::ScreenInfo screen_info;
screen_info.device_scale_factor = 2.f;
test_web_frame_widget->SetInitialScreenInfo(screen_info);
return test_web_frame_widget;
}));
high_dpi_helper.InitializeWithSettings(
[](WebSettings* settings) { settings->SetTextAreasAreResizable(true); });
high_dpi_helper.LoadAhem();
ASSERT_TRUE(high_dpi_helper.LocalMainFrame());
constexpr gfx::Size kDipViewportSize{464, 828};
constexpr int kDeviceScaleFactor = 2;
high_dpi_helper.Resize(
gfx::Size(kDipViewportSize.width() * kDeviceScaleFactor,
kDipViewportSize.height() * kDeviceScaleFactor));
frame_test_helpers::LoadHTMLString(
high_dpi_helper.LocalMainFrame(),
"<body style='margin: 0; height: 7000px;'>"
" <p id='target' style='cursor:pointer; position:absolute;"
" left:0; top:200px;'>"
" Text 1"
" </p>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
Document* document =
high_dpi_helper.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
LocalFrameView* view = document->View();
ASSERT_TRUE(view);
// Establish layout before applying viewport transforms.
view->UpdateAllLifecyclePhasesForTest();
constexpr int kVisualViewportOffsetDip = 20;
document->GetPage()->GetChromeClient().SetBrowserControlsState(
kVisualViewportOffsetDip, 0, /*shrinks_layout=*/true);
ScriptState* script_state =
ToScriptStateForMainWorld(high_dpi_helper.LocalMainFrame()->GetFrame());
ScriptState::Scope scope(script_state);
ViewTransitionSupplement::startViewTransition(script_state, *document,
IGNORE_EXCEPTION_FOR_TESTING);
test::RunPendingTasks();
high_dpi_helper.LocalMainFrame()->ExecuteScript(
WebScriptSource("window.scrollTo(0, 2000);"));
view->UpdateAllLifecyclePhasesForTest();
Element* target_element = document->getElementById(AtomicString("target"));
ASSERT_TRUE(target_element);
const DOMNodeId target_dom_node_id = DOMNodeIds::IdForNode(target_element);
ASSERT_GT(target_dom_node_id, kInvalidDOMNodeId);
auto options = GetAIPageContentOptionsForTest();
options->mode = mojom::blink::AIPageContentMode::kActionableElements;
auto* agent = AIPageContentAgent::GetOrCreateForTesting(*document);
ASSERT_TRUE(agent);
auto content = agent->GetAIPageContentInternal(*options);
ASSERT_TRUE(content);
ASSERT_TRUE(content->root_node);
const auto* target_node =
FindNodeByDomNodeId(*content->root_node, target_dom_node_id);
ASSERT_TRUE(target_node);
ASSERT_TRUE(target_node->content_attributes->node_interaction_info);
const auto& interaction_info =
*target_node->content_attributes->node_interaction_info;
EXPECT_FALSE(interaction_info.clickability_reasons.empty());
}
TEST_F(AIPageContentAgentTestZOrder,
HitTestElementsNestedScrollerAbsoluteReachable) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style="margin: 0;">
<div id="scroller" style="overflow: auto; width: 200px; height: 200px; position: relative;">
<div style="height: 2000px;">
<p id="target" style="cursor: pointer; position: absolute; left: 0; top: 1500px;">Text 1</p>
</div>
</div>
</body>)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
Element* scroller = document->getElementById(AtomicString("scroller"));
ASSERT_TRUE(scroller);
scroller->setScrollTop(1000);
LocalFrameView* view = document->View();
ASSERT_TRUE(view);
test::RunPendingTasks();
view->UpdateAllLifecyclePhasesForTest();
GetAIPageContentWithActionableElements();
const auto* target = FindNodeBySelector("#target");
ASSERT_TRUE(target);
ASSERT_TRUE(target->content_attributes->node_interaction_info);
}
TEST_F(AIPageContentAgentTestZOrder,
HitTestElementsNestedScrollerAbsoluteReachableWhenScrolledPast) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style="margin: 0;">
<div id="scroller" style="overflow: auto; width: 200px; height: 200px; position: relative;">
<div style="height: 2000px;">
<p id="target" style="cursor: pointer; position: absolute; left: 0; top: 200px;">Text 1</p>
</div>
</div>
</body>)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
Element* scroller = document->getElementById(AtomicString("scroller"));
ASSERT_TRUE(scroller);
// The target is above the scroller's current viewport, but the user can
// scroll the container back to it.
scroller->setScrollTop(1000);
LocalFrameView* view = document->View();
ASSERT_TRUE(view);
test::RunPendingTasks();
view->UpdateAllLifecyclePhasesForTest();
GetAIPageContentWithActionableElements();
const auto* target = FindNodeBySelector("#target");
ASSERT_TRUE(target);
ASSERT_TRUE(target->content_attributes->node_interaction_info);
}
TEST_F(AIPageContentAgentTestZOrder,
HitTestElementsFixedNestedScrollerAbsoluteReachable) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style="margin: 0;">
<div id="scroller" style="overflow: auto; width: 200px; height: 200px; position: fixed; left: 0; top: 0;">
<div style="height: 2000px; position: relative;">
<p id="target" style="cursor: pointer; position: absolute; left: 0; top: 1500px;">Text 1</p>
</div>
</div>
</body>)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto* target = FindNodeBySelector("#target");
ASSERT_TRUE(target);
// The fixed container is visible, so the user can scroll it to the target.
ASSERT_TRUE(target->content_attributes->node_interaction_info);
}
TEST_F(AIPageContentAgentTestZOrder,
HitTestElementsFixedChildOfScrollerNegativeTrap) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style="margin: 0;">
<div id="scroller" style="overflow: auto; width: 200px; height: 200px; position: relative;">
<div style="height: 2000px;">
<p id="target" style="cursor: pointer; position: fixed; left: 0; top: 1500px;">Text 1</p>
</div>
</div>
</body>)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto* target = FindNodeBySelector("#target");
ASSERT_TRUE(target);
// The target is fixed to the viewport, so scrolling its DOM scroller cannot
// bring it into view.
EXPECT_FALSE(target->content_attributes->node_interaction_info);
}
TEST_F(AIPageContentAgentTestZOrder,
HitTestElementsNestedScrollerAbsoluteNegativeTrap) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style="margin: 0;">
<div id="scroller" style="overflow: auto; width: 200px; height: 200px; position: relative;">
<div style="height: 2000px;">
<p id="target" style="cursor: pointer; position: absolute; left: -9999px; top: 100px;">Text 1</p>
</div>
</div>
</body>)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto* target = FindNodeBySelector("#target");
ASSERT_TRUE(target);
EXPECT_FALSE(target->content_attributes->node_interaction_info);
}
TEST_F(AIPageContentAgentTestZOrder,
HitTestElementsOverflowHiddenAbsoluteNegativeTrap) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style="margin: 0;">
<div id="clipper" style="overflow: hidden; width: 200px; height: 200px; position: relative;">
<div style="height: 2000px;">
<p id="target" style="cursor: pointer; position: absolute; left: 0; top: 1500px;">Text 1</p>
</div>
</div>
</body>)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto* target = FindNodeBySelector("#target");
ASSERT_TRUE(target);
// `overflow: hidden` clips the target, but the user cannot scroll the
// clipper.
EXPECT_FALSE(target->content_attributes->node_interaction_info);
}
TEST_F(AIPageContentAgentTestZOrder,
HitTestElementsOffscreenScrollerSelfNegativeTrap) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style="margin: 0;">
<div id="scroller" style="cursor: pointer; overflow: auto; width: 200px; height: 200px; position: absolute; left: -9999px; top: 0;">
<div style="height: 2000px;">Text 1</div>
</div>
</body>)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto* scroller = FindNodeBySelector("#scroller");
ASSERT_TRUE(scroller);
// A scroller must be reachable through its parent before it can be
// actionable.
EXPECT_FALSE(scroller->content_attributes->node_interaction_info);
}
TEST_F(AIPageContentAgentTestZOrder,
HitTestElementsOffscreenNestedScrollerAbsoluteNegativeTrap) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style="margin: 0;">
<div id="scroller" style="overflow: auto; width: 200px; height: 200px; position: absolute; left: -9999px; top: 0;">
<div style="height: 2000px; position: relative;">
<p id="target" style="cursor: pointer; position: absolute; left: 0; top: 100px;">Text 1</p>
</div>
</div>
</body>)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto* target = FindNodeBySelector("#target");
ASSERT_TRUE(target);
// A child cannot be reached when its nearest scroller is itself offscreen.
EXPECT_FALSE(target->content_attributes->node_interaction_info);
}
TEST_F(AIPageContentAgentTestZOrder,
HitTestElementsOffscreenScrollerIframeChildNegativeTrap) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style="margin: 0;">
<div id="scroller" style="overflow: auto; width: 200px; height: 200px; position: absolute; left: -9999px; top: 0;">
<iframe id="target-frame"
src="about:blank"
style="border: 0; width: 200px; height: 200px;"></iframe>
</div>
</body>)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
auto* iframe_element = DynamicTo<HTMLIFrameElement>(
document->getElementById(AtomicString("target-frame")));
ASSERT_TRUE(iframe_element);
auto* child_frame = DynamicTo<LocalFrame>(iframe_element->ContentFrame());
ASSERT_TRUE(child_frame);
Document* child_document = child_frame->GetDocument();
ASSERT_TRUE(child_document);
ASSERT_TRUE(child_document->body());
child_document->body()->SetInnerHTMLWithoutTrustedTypes(
"<body style='margin: 0;'>"
" <button id='target'>Text 1</button>"
"</body>");
LocalFrameView* child_view = child_document->View();
ASSERT_TRUE(child_view);
test::RunPendingTasks();
child_view->UpdateAllLifecyclePhasesForTest();
Element* target_element =
child_document->getElementById(AtomicString("target"));
ASSERT_TRUE(target_element);
const DOMNodeId target_dom_node_id = DOMNodeIds::IdForNode(target_element);
ASSERT_GT(target_dom_node_id, kInvalidDOMNodeId);
GetAIPageContentWithActionableElements();
const auto* target_node = FindNodeByDomNodeId(target_dom_node_id);
ASSERT_TRUE(target_node);
// The iframe is inside an unreachable scroller, so its child cannot be
// directly actionable in this APC snapshot.
EXPECT_FALSE(target_node->content_attributes->node_interaction_info);
}
TEST_F(AIPageContentAgentTestZOrder, HitTestElementsStaticNegativeTrap) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <p id='target' style='cursor:pointer; transform: translate(-9999px, "
"-9999px);'>"
" Text 1"
" </p>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto* target = FindNodeBySelector("#target");
ASSERT_TRUE(target);
EXPECT_FALSE(target->content_attributes->node_interaction_info);
}
TEST_F(AIPageContentAgentTestZOrder,
HitTestElementsNestedScrollerStaticNegativeTrap) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style="margin: 0;">
<div id="scroller" style="overflow: auto; width: 200px; height: 200px;">
<div style="height: 2000px;">
<p id="target" style="cursor: pointer; transform: translate(-9999px, -9999px);">Text 1</p>
</div>
</div>
</body>)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto* target = FindNodeBySelector("#target");
ASSERT_TRUE(target);
EXPECT_FALSE(target->content_attributes->node_interaction_info);
}
TEST_F(AIPageContentAgentTestZOrder, HitTestElementsRelativeNegativeTrap) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <p id='target' style='cursor:pointer; position:relative; "
"left:-9999px; top:0;'>"
" Text 1"
" </p>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto* target = FindNodeBySelector("#target");
ASSERT_TRUE(target);
EXPECT_FALSE(target->content_attributes->node_interaction_info);
}
TEST_F(AIPageContentAgentTestZOrder,
HitTestElementsIFrameAbsoluteReachableWhenScrolledPast) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style="margin: 0;">
<iframe id="target-frame"
src="about:blank"
style="border: 0; width: 200px; height: 200px;"></iframe>
</body>)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
auto* iframe_element = DynamicTo<HTMLIFrameElement>(
document->getElementById(AtomicString("target-frame")));
ASSERT_TRUE(iframe_element);
auto* child_frame = DynamicTo<LocalFrame>(iframe_element->ContentFrame());
ASSERT_TRUE(child_frame);
Document* child_document = child_frame->GetDocument();
ASSERT_TRUE(child_document);
ASSERT_TRUE(child_document->body());
child_document->body()->SetInnerHTMLWithoutTrustedTypes(
"<style>"
" body { margin: 0; height: 7000px; }"
" #target { cursor: pointer; position: absolute; left: 0; top: 200px; }"
"</style>"
"<p id='target'>Text 1</p>");
// Scroll the iframe document so the target moves above the iframe viewport.
// The node should stay actionable because the iframe can scroll back to it.
Element* child_scrolling_element = child_document->scrollingElement();
ASSERT_TRUE(child_scrolling_element);
child_scrolling_element->setScrollTop(2000);
LocalFrameView* child_view = child_document->View();
ASSERT_TRUE(child_view);
test::RunPendingTasks();
child_view->UpdateAllLifecyclePhasesForTest();
Element* target_element =
child_document->getElementById(AtomicString("target"));
ASSERT_TRUE(target_element);
const DOMNodeId target_dom_node_id = DOMNodeIds::IdForNode(target_element);
ASSERT_GT(target_dom_node_id, kInvalidDOMNodeId);
GetAIPageContentWithActionableElements();
const auto* target_node = FindNodeByDomNodeId(target_dom_node_id);
ASSERT_TRUE(target_node);
ASSERT_TRUE(target_node->content_attributes->node_interaction_info);
const auto& interaction_info =
*target_node->content_attributes->node_interaction_info;
EXPECT_FALSE(interaction_info.clickability_reasons.empty());
}
TEST_F(AIPageContentAgentTestZOrder, HitTestElementsIFrameFixedOffscreen) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body style="margin: 0;">
<iframe id="target-frame"
src="about:blank"
style="border: 0; width: 200px; height: 200px;"></iframe>
</body>)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
ASSERT_TRUE(document);
auto* iframe_element = DynamicTo<HTMLIFrameElement>(
document->getElementById(AtomicString("target-frame")));
ASSERT_TRUE(iframe_element);
auto* child_frame = DynamicTo<LocalFrame>(iframe_element->ContentFrame());
ASSERT_TRUE(child_frame);
Document* child_document = child_frame->GetDocument();
ASSERT_TRUE(child_document);
ASSERT_TRUE(child_document->body());
child_document->body()->SetInnerHTMLWithoutTrustedTypes(
"<style>"
" body { margin: 0; min-height: 400px; }"
" #sheet {"
" position: fixed;"
" left: 0;"
" right: 0;"
" bottom: 0;"
" transform: translateY(100%);"
" }"
"</style>"
"<button id='trigger'>Open sizes</button>"
"<div id='sheet'><button id='item'>Size 6.5</button></div>");
LocalFrameView* child_view = child_document->View();
ASSERT_TRUE(child_view);
test::RunPendingTasks();
child_view->UpdateAllLifecyclePhasesForTest();
Element* trigger_element =
child_document->getElementById(AtomicString("trigger"));
ASSERT_TRUE(trigger_element);
const DOMNodeId trigger_dom_node_id = DOMNodeIds::IdForNode(trigger_element);
ASSERT_GT(trigger_dom_node_id, kInvalidDOMNodeId);
Element* item_element = child_document->getElementById(AtomicString("item"));
ASSERT_TRUE(item_element);
const DOMNodeId item_dom_node_id = DOMNodeIds::IdForNode(item_element);
ASSERT_GT(item_dom_node_id, kInvalidDOMNodeId);
GetAIPageContentWithActionableElements();
const auto* trigger_node = FindNodeByDomNodeId(trigger_dom_node_id);
ASSERT_TRUE(trigger_node);
ASSERT_TRUE(trigger_node->content_attributes->node_interaction_info);
const auto* item_node = FindNodeByDomNodeId(item_dom_node_id);
ASSERT_TRUE(item_node);
EXPECT_FALSE(item_node->content_attributes->node_interaction_info);
}
TEST_F(AIPageContentAgentTestZOrder, HitTestElementsIframe) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body>
<iframe srcdoc='<p>Text 1</p>'></iframe>
<p>Text 2</p>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
// The iframe and outer p have z order relative to each other.
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 2u);
const auto& iframe = *root.children_nodes.at(0);
ASSERT_TRUE(iframe.content_attributes->node_interaction_info);
ASSERT_TRUE(iframe.content_attributes->node_interaction_info
->document_scoped_z_order);
const auto& p = *root.children_nodes.at(1);
ASSERT_TRUE(p.content_attributes->node_interaction_info);
ASSERT_TRUE(
p.content_attributes->node_interaction_info->document_scoped_z_order);
// If we're respecting tree-order traversal when computing z-order, the
// iframe will have a lower z-order because it is the first child.
EXPECT_LT(
*iframe.content_attributes->node_interaction_info
->document_scoped_z_order,
*p.content_attributes->node_interaction_info->document_scoped_z_order);
ASSERT_EQ(iframe.children_nodes.size(), 1u);
const auto& doc_inside_iframe = *iframe.children_nodes.at(0);
ASSERT_TRUE(doc_inside_iframe.content_attributes->node_interaction_info);
ASSERT_TRUE(doc_inside_iframe.content_attributes->node_interaction_info
->document_scoped_z_order);
EXPECT_EQ(*doc_inside_iframe.content_attributes->node_interaction_info
->document_scoped_z_order,
1);
}
TEST_F(AIPageContentAgentTestZOrder, OverflowHiddenGeometry) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div style='width: 100px; height: 100px; overflow-y: hidden;'>"
" <article style='width: 50px; height: 300px;'></article>"
" </div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& outer = ContentRootNode().children_nodes[0];
const auto& article = outer->children_nodes[0];
CheckAnnotatedRole(*article,
mojom::blink::AIPageContentAnnotatedRole::kArticle);
EXPECT_GT(*article->content_attributes->node_interaction_info
->document_scoped_z_order,
*outer->content_attributes->node_interaction_info
->document_scoped_z_order);
CheckGeometry(*outer, gfx::Rect(8, 8, 100, 100), gfx::Rect(8, 8, 100, 100));
CheckGeometry(*article, gfx::Rect(8, 8, 50, 300), gfx::Rect(8, 8, 50, 100));
}
TEST_F(AIPageContentAgentTestZOrder, OverflowVisibleGeometry) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <section style='width: 100px; height: 100px; overflow-y: visible;'>"
" <article style='width: 50px; height: 300px;'></article>"
" </section>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& outer = ContentRootNode().children_nodes[0];
const auto& article = outer->children_nodes[0];
CheckAnnotatedRole(*article,
mojom::blink::AIPageContentAnnotatedRole::kArticle);
EXPECT_GT(*article->content_attributes->node_interaction_info
->document_scoped_z_order,
*outer->content_attributes->node_interaction_info
->document_scoped_z_order);
CheckGeometry(*outer, gfx::Rect(8, 8, 100, 100), gfx::Rect(8, 8, 100, 100));
CheckGeometry(*article, gfx::Rect(8, 8, 50, 300), gfx::Rect(8, 8, 50, 300));
}
TEST_F(AIPageContentAgentTestZOrder, HitTestElementsRelativePos) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <section style='width: 100px; height: 100px; position: relative; "
"overflow: clip;'>"
" <article style='width: 50px; height: 50px; position: absolute; "
"left: "
"150px; top:0px;'></article>"
" </section>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& outer = ContentRootNode().children_nodes[0];
const auto& article = outer->children_nodes[0];
CheckAnnotatedRole(*article,
mojom::blink::AIPageContentAnnotatedRole::kArticle);
EXPECT_GT(*article->content_attributes->node_interaction_info
->document_scoped_z_order,
*outer->content_attributes->node_interaction_info
->document_scoped_z_order);
CheckGeometry(*outer, gfx::Rect(8, 8, 100, 100), gfx::Rect(8, 8, 100, 100));
CheckGeometry(*article, gfx::Rect(158, 8, 50, 50), gfx::Rect());
}
TEST_F(AIPageContentAgentTestZOrder, LabelWithText) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body style='margin:0; font:1px/1px Ahem'>"
" <label>xyz</label>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
LoadAhem();
GetAIPageContentWithActionableElements();
const auto& body = ContentRootNode();
ASSERT_EQ(body.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kContainer);
const auto& label = *body.children_nodes.at(0);
ASSERT_EQ(label.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kContainer);
const auto& text = *label.children_nodes.at(0);
ASSERT_EQ(text.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kText);
SCOPED_TRACE("Label");
CheckGeometry(label, gfx::Rect(0, 0, 3, 1), gfx::Rect(0, 0, 3, 1));
SCOPED_TRACE("Text");
CheckGeometry(text, gfx::Rect(0, 0, 3, 1), gfx::Rect(0, 0, 3, 1));
}
TEST_F(AIPageContentAgentTestZOrder, HitTestElementsAnchorWithSpanParent) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body>
<span id="target-span">
<a id="link" href="https://example.com">
<span id="inner-span">
This is the inner span.
</span>
<div id="inner-div">
This is the inner div.
</div>
</a>
</span>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = *Content()->root_node;
EXPECT_EQ(
root.content_attributes->node_interaction_info->document_scoped_z_order,
1);
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& html = *root.children_nodes.at(0);
EXPECT_EQ(
html.content_attributes->node_interaction_info->document_scoped_z_order,
2);
ASSERT_EQ(html.children_nodes.size(), 1u);
const auto& body = *html.children_nodes.at(0);
EXPECT_EQ(
body.content_attributes->node_interaction_info->document_scoped_z_order,
3);
ASSERT_EQ(body.children_nodes.size(), 1u);
// The resulting tree with corresponding z-order is:
// root - 1
// |_html - 2
// |_body - 3
// |_span - 4
// |_a - 5
// |_span - 6
// | |_text - 7
// |_div - 8
// |_text - 9
const auto& target_span = *body.children_nodes.at(0);
EXPECT_EQ(target_span.content_attributes->node_interaction_info
->document_scoped_z_order,
4);
ASSERT_EQ(target_span.children_nodes.size(), 1u);
const auto& anchor = *target_span.children_nodes.at(0);
EXPECT_EQ(
anchor.content_attributes->node_interaction_info->document_scoped_z_order,
5);
ASSERT_EQ(anchor.children_nodes.size(), 2u);
const auto& inner_span = *anchor.children_nodes.at(0);
EXPECT_EQ(inner_span.content_attributes->node_interaction_info
->document_scoped_z_order,
6);
ASSERT_EQ(inner_span.children_nodes.size(), 1u);
const auto& span_text = *inner_span.children_nodes.at(0);
EXPECT_EQ(span_text.content_attributes->node_interaction_info
->document_scoped_z_order,
7);
const auto& inner_div = *anchor.children_nodes.at(1);
EXPECT_EQ(inner_div.content_attributes->node_interaction_info
->document_scoped_z_order,
8);
ASSERT_EQ(inner_div.children_nodes.size(), 1u);
const auto& div_text = *inner_div.children_nodes.at(0);
EXPECT_EQ(div_text.content_attributes->node_interaction_info
->document_scoped_z_order,
9);
}
TEST_F(AIPageContentAgentTest, LinkWithOverflowGeometry) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <style>"
" a {"
" display: inline-block;"
" width: 20px;"
" height: 20px;"
" overflow: visible;"
" }"
" </style>"
" <a href='#' style='font-size: 50px'>Text that will overflow</a>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 1u);
auto& anchor_node = *root.children_nodes[0];
CheckAnchorNode(anchor_node, url_test_helpers::ToKURL("http://foobar.com/#"),
{});
const auto& geometry = *anchor_node.content_attributes->geometry;
// Visible overflow is not currently included in the outer_bounding_box or
// visible_bounding_box. This may not be the correct choice given that visible
// overflow is hit testable. However, this is not a major concern because
// events bubble up. If we see a problem case caused by the lack of overflow
// inclusion we can attempt to incorporate hit test rects (which was attempted
// previously but was non-trivial).
EXPECT_EQ(geometry.outer_bounding_box.width(), 20);
EXPECT_EQ(geometry.outer_bounding_box.width(), 20);
EXPECT_EQ(geometry.visible_bounding_box.width(), 20);
EXPECT_EQ(geometry.visible_bounding_box.height(), 20);
}
TEST_F(AIPageContentAgentTest, CursorNotAllowedButton) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body>
<button style="cursor: not-allowed;">Text</button>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& button = *root.children_nodes.at(0);
CheckHitTestableAndInteractive(button,
{ClickabilityReason::kClickableControl,
ClickabilityReason::kHoverPseudoClass});
EXPECT_FALSE(button.content_attributes->node_interaction_info->is_disabled);
EXPECT_THAT(button.content_attributes->node_interaction_info
->interaction_disabled_reasons,
testing::UnorderedElementsAre(
InteractionDisabledReason::kCursorNotAllowed));
}
TEST_F(AIPageContentAgentTest, MultipleInteractionDisabledReasons) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
R"HTML(
<body>
<button disabled aria-disabled=true style="cursor: not-allowed;">
Text
</button>
</body>
)HTML",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& button = *root.children_nodes.at(0);
CheckHitTestableButNotInteractive(button);
EXPECT_TRUE(button.content_attributes->node_interaction_info->is_disabled);
EXPECT_THAT(button.content_attributes->node_interaction_info
->interaction_disabled_reasons,
testing::UnorderedElementsAre(
InteractionDisabledReason::kDisabled,
InteractionDisabledReason::kAriaDisabled,
InteractionDisabledReason::kCursorNotAllowed));
}
TEST_F(AIPageContentAgentTest, ZeroSizeActionableElement) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div id='zero' style='width: 0; height: 0;' onclick='void(0)'></div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto* zero_node = FindNodeBySelector("#zero");
// An actionable element with 0 size should still be included in the APC tree
// if it is visible, e.g. it doesn't have visibility: hidden.
ASSERT_TRUE(zero_node);
ASSERT_TRUE(zero_node->content_attributes->geometry);
EXPECT_TRUE(
zero_node->content_attributes->geometry->outer_bounding_box.IsEmpty());
EXPECT_TRUE(
zero_node->content_attributes->geometry->visible_bounding_box.IsEmpty());
}
TEST_F(AIPageContentAgentTest, ZeroSizeNonActionableElement) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div id='zero' style='width: 0; height: 0;'></div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto* zero_node = FindNodeBySelector("#zero");
// A non-actionable element with 0 size should be excluded from the APC tree.
EXPECT_FALSE(zero_node);
}
TEST_F(AIPageContentAgentTest, ZeroSizeContainerWithVisibleChild) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div id='zero' style='width: 0; height: 0; position: fixed;'>"
" <div id='visible' style='width: 10px; height: 10px;'>text</div>"
" </div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContentWithActionableElements();
const auto* zero_node = FindNodeBySelector("#zero");
// A container with 0 size but visible children should be included.
ASSERT_TRUE(zero_node);
const auto* visible_node = FindNodeBySelector("#visible");
ASSERT_TRUE(visible_node);
// Verify that 'visible' is a child of 'zero' in the APC tree.
bool found = false;
for (const auto& child : zero_node->children_nodes) {
if (child->content_attributes->dom_node_id ==
visible_node->content_attributes->dom_node_id) {
found = true;
break;
}
}
EXPECT_TRUE(found);
}
TEST_F(AIPageContentAgentTest, GetImageBytes) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <img alt=testimg></img>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
auto& document = *helper_.LocalMainFrame()->GetFrame()->GetDocument();
document.getElementsByTagName(AtomicString("img"))
->item(0)
->setAttribute(html_names::kSrcAttr, AtomicString(kSmallImage));
test::RunPendingTasks();
GetAIPageContentWithActionableElements();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
auto& image_node = *root.children_nodes[0];
CheckImageNode(image_node, "testimg", KURL());
ASSERT_TRUE(image_node.content_attributes->dom_node_id.has_value());
int32_t dom_node_id = image_node.content_attributes->dom_node_id.value();
auto* agent = AIPageContentAgent::From(document);
ASSERT_TRUE(agent);
base::HistogramTester histogram_tester;
base::RunLoop run_loop;
agent->GetImageBytes(
dom_node_id,
base::BindLambdaForTesting(
[&](mojom::blink::AIPageContentImageBytesResultPtr result) {
ASSERT_TRUE(result);
EXPECT_GT(result->image_bytes.size(), 0u);
EXPECT_EQ(result->image_info->mime_type, "image/jpeg");
ASSERT_TRUE(result->image_info);
EXPECT_EQ(result->image_info->image_caption, "testimg");
run_loop.Quit();
}));
run_loop.Run();
histogram_tester.ExpectUniqueSample(
"OptimizationGuide.AIPageContent.GetImageBytes.Status", 0 /* kSuccess */,
1);
}
TEST_F(AIPageContentAgentTest, GetImageBytesFailures) {
base::HistogramTester histogram_tester;
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <img id='img' alt='testimg'></img>"
" <div id='not-image'>hello</div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
auto& document = *helper_.LocalMainFrame()->GetFrame()->GetDocument();
document.getElementById(AtomicString("img"))
->setAttribute(html_names::kSrcAttr, AtomicString(kSmallImage));
test::RunPendingTasks();
auto* agent = AIPageContentAgent::GetOrCreateForTesting(document);
ASSERT_TRUE(agent);
int32_t image_node_id =
DOMNodeIds::IdForNode(document.getElementById(AtomicString("img")));
int32_t div_node_id =
DOMNodeIds::IdForNode(document.getElementById(AtomicString("not-image")));
// 1. Invalid Node ID
{
base::RunLoop run_loop;
agent->GetImageBytes(
image_node_id + 9999,
base::BindLambdaForTesting(
[&](mojom::blink::AIPageContentImageBytesResultPtr result) {
EXPECT_FALSE(result);
run_loop.Quit();
}));
run_loop.Run();
histogram_tester.ExpectBucketCount(
"OptimizationGuide.AIPageContent.GetImageBytes.Status",
1 /* kNodeNotFound */, 1);
}
// 2. Cross-Tree Query (different LocalFrameRoot / different WebView)
{
frame_test_helpers::WebViewHelper helper2;
helper2.Initialize();
frame_test_helpers::LoadHTMLString(
helper2.LocalMainFrame(),
"<body>"
" <img id='img2' alt='testimg2'></img>"
"</body>",
url_test_helpers::ToKURL("http://foobar2.com"));
auto& document2 = *helper2.LocalMainFrame()->GetFrame()->GetDocument();
document2.getElementById(AtomicString("img2"))
->setAttribute(html_names::kSrcAttr, AtomicString(kSmallImage));
test::RunPendingTasks();
int32_t helper2_image_node_id =
DOMNodeIds::IdForNode(document2.getElementById(AtomicString("img2")));
base::RunLoop run_loop;
agent->GetImageBytes(
helper2_image_node_id,
base::BindLambdaForTesting(
[&](mojom::blink::AIPageContentImageBytesResultPtr result) {
EXPECT_FALSE(result);
run_loop.Quit();
}));
run_loop.Run();
histogram_tester.ExpectBucketCount(
"OptimizationGuide.AIPageContent.GetImageBytes.Status",
4 /* kCrossTreeQuery */, 1);
}
// 3. Non-Image Node
{
base::RunLoop run_loop;
agent->GetImageBytes(
div_node_id,
base::BindLambdaForTesting(
[&](mojom::blink::AIPageContentImageBytesResultPtr result) {
EXPECT_FALSE(result);
run_loop.Quit();
}));
run_loop.Run();
histogram_tester.ExpectBucketCount(
"OptimizationGuide.AIPageContent.GetImageBytes.Status",
5 /* kNotAnImage */, 1);
}
// 4. Image resource error (broken image)
{
document.getElementById(AtomicString("img"))
->setAttribute(html_names::kSrcAttr,
AtomicString("data:image/jpeg;base64,invalid"));
document.UpdateStyleAndLayout(DocumentUpdateReason::kTest);
test::RunPendingTasks();
base::RunLoop run_loop;
agent->GetImageBytes(
image_node_id,
base::BindLambdaForTesting(
[&](mojom::blink::AIPageContentImageBytesResultPtr result) {
EXPECT_FALSE(result);
run_loop.Quit();
}));
run_loop.Run();
histogram_tester.ExpectBucketCount(
"OptimizationGuide.AIPageContent.GetImageBytes.Status",
6 /* kImageError */, 1);
}
}
class AIPageContentAgentTestTextEncoding : public AIPageContentAgentTest {
private:
// All of these tests assume the UTF-8 conversion feature is enabled.
ScopedAIPageContentConvertNodeTextToUtf8ForTest enable_utf8_conversion_{true};
};
TEST_F(AIPageContentAgentTestTextEncoding, TextContentCorrected) {
// Start with a basic valid UTF-16 string.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <p id='wrong'>Hello</p>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
auto& document = *helper_.LocalMainFrame()->GetFrame()->GetDocument();
auto* p = document.getElementById(AtomicString("wrong"));
// Confirm that on the Blink side, we have a valid UTF-16 string.
EXPECT_EQ(p->innerText(), String(u"Hello"));
// The only way to get an invalid UTF-16 string into the element is via
// Javascript, which isn't required to match surrogates. In this case, the
// unmatched surrogate is \uD83D. We also need to use a raw string so that the
// unicode character is interpreted by Javascript, not CPP.
helper_.LocalMainFrame()->ExecuteScript(WebScriptSource(
R"(document.getElementById('wrong').innerText = 'Hello\uD83D')"));
GetAIPageContentWithActionableElements();
auto* p_node = FindNodeBySelector("#wrong");
CheckTextNode(*p_node->children_nodes[0], String(u"Hello\uFFFD"));
}
TEST_F(AIPageContentAgentTestTextEncoding, ImageCaptionCorrected) {
// Start with a basic valid UTF-16 string.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <img id='wrong' alt='Hello'>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
auto& document = *helper_.LocalMainFrame()->GetFrame()->GetDocument();
auto* img = document.getElementById(AtomicString("wrong"));
// Put an actual image in the node.
img->setAttribute(html_names::kSrcAttr, AtomicString(kSmallImage));
// Confirm that on the Blink side, we have a valid UTF-16 string.
ASSERT_TRUE(IsA<HTMLImageElement>(*img));
EXPECT_EQ(To<HTMLImageElement>(*img).AltText(), String(u"Hello"));
// The only way to get an invalid UTF-16 string into the element is via
// Javascript, which isn't required to match surrogates. In this case, the
// unmatched surrogate is \uD83D. We also need to use a raw string so that the
// unicode character is interpreted by Javascript, not CPP.
helper_.LocalMainFrame()->ExecuteScript(WebScriptSource(
R"(document.getElementById('wrong').alt = 'Hello\uD83D')"));
GetAIPageContentWithActionableElements();
auto* image_node = FindNodeBySelector("#wrong");
CheckImageNode(*image_node, String(u"Hello\uFFFD"), KURL());
}
TEST_F(AIPageContentAgentTestTextEncoding, SVGRootCorrected) {
ScopedAIPageContentIncludeSVGSubtreeForTest scoped_feature(
/*enabled=*/true);
// Start with a basic valid UTF-16 string.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <svg id='wrongsvg' width='400' height='200'>"
" <text id='wrongtext' x='50%' y='50%' font-size='24'>"
" Hello SVG Text!"
" </text>"
" </svg>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// The only way to get an invalid UTF-16 string into the element is via
// Javascript, which isn't required to match surrogates. In this case, the
// unmatched surrogate is \uD83D. We also need to use a raw string so that the
// unicode character is interpreted by Javascript, not CPP.
helper_.LocalMainFrame()->ExecuteScript(WebScriptSource(
R"(
document
.getElementById('wrongsvg')
.getElementById('wrongtext')
.textContent = ' Hello\uD83D '
)"));
GetAIPageContentWithActionableElements();
const auto& svg = *ContentRootNode().children_nodes[0];
EXPECT_EQ(svg.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kSvgRoot);
ASSERT_TRUE(svg.content_attributes->svg_root_data);
EXPECT_EQ(svg.content_attributes->svg_root_data->inner_text, u"Hello\uFFFD");
const auto& text_child = *svg.children_nodes[0];
EXPECT_EQ(text_child.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kText);
// Note that whitespace is kept.
CheckTextNode(text_child, u" Hello\uFFFD ");
}
TEST_F(AIPageContentAgentTestTextEncoding, TableNameCorrected) {
// Start with a basic valid UTF-16 string.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <table>"
" <caption id='wrong'>Hello</caption>"
" </table>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// The only way to get an invalid UTF-16 string into the element is via
// Javascript, which isn't required to match surrogates. In this case, the
// unmatched surrogate is \uD83D. We also need to use a raw string so that the
// unicode character is interpreted by Javascript, not CPP.
helper_.LocalMainFrame()->ExecuteScript(WebScriptSource(
R"(document.getElementById('wrong').innerText = 'Hello\uD83D')"));
GetAIPageContentWithActionableElements();
const auto& table = *ContentRootNode().children_nodes[0];
CheckTableNode(table, u"Hello\uFFFD");
}
TEST_F(AIPageContentAgentTestTextEncoding, FormNameCorrected) {
// Start with a basic valid UTF-16 string.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <form id='wrong' name='Hello'>"
" <p>Greetings</p>"
" </form>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// The only way to get an invalid UTF-16 string into the element is via
// Javascript, which isn't required to match surrogates. In this case, the
// unmatched surrogate is \uD83D. We also need to use a raw string so that the
// unicode character is interpreted by Javascript, not CPP.
helper_.LocalMainFrame()->ExecuteScript(WebScriptSource(
R"(document.getElementById('wrong').name = 'Hello\uD83D')"));
GetAIPageContentWithActionableElements();
const auto& form = *ContentRootNode().children_nodes[0];
EXPECT_EQ(form.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kForm);
EXPECT_EQ(form.content_attributes->form_data->form_name, u"Hello\uFFFD");
}
TEST_F(AIPageContentAgentTestTextEncoding,
AriaFormControlPlaceholderCorrected) {
// Start with a basic valid UTF-16 string.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <form>"
" <input id='wrong' type='text' aria-required='true' "
"aria-placeholder='Hello'>"
" </form>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// The only way to get an invalid UTF-16 string into the element is via
// Javascript, which isn't required to match surrogates. In this case, the
// unmatched surrogate is \uD83D. We also need to use a raw string so that the
// unicode character is interpreted by Javascript, not CPP.
helper_.LocalMainFrame()->ExecuteScript(WebScriptSource(
R"(document.getElementById('wrong').ariaPlaceholder = 'Hello\uD83D')"));
GetAIPageContentWithActionableElements();
const auto* input = FindNodeBySelector("#wrong");
CheckFormControlNode(*input, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(input->content_attributes->form_control_data->placeholder,
u"Hello\uFFFD");
}
TEST_F(AIPageContentAgentTestTextEncoding, TextFormControlValueCorrected) {
// Start with a basic valid UTF-16 string.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <form>"
" <input id='wrong' type='text' name='Username' value='Hello'>"
" </form>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// The only way to get an invalid UTF-16 string into the element is via
// Javascript, which isn't required to match surrogates. In this case, the
// unmatched surrogate is \uD83D. We also need to use a raw string so that the
// unicode character is interpreted by Javascript, not CPP.
helper_.LocalMainFrame()->ExecuteScript(WebScriptSource(
R"(
let input = document.getElementById('wrong');
input.name = 'Username\uD83D';
input.value = 'Hello\uD83D';
)"));
GetAIPageContentWithActionableElements();
const auto* input = FindNodeBySelector("#wrong");
CheckFormControlNode(*input, mojom::blink::FormControlType::kInputText);
const mojom::blink::AIPageContentFormControlDataPtr& form_control_data =
input->content_attributes->form_control_data;
EXPECT_EQ(form_control_data->field_name, u"Username\uFFFD");
EXPECT_EQ(form_control_data->field_value, u"Hello\uFFFD");
}
TEST_F(AIPageContentAgentTestTextEncoding,
TextFormControlPlaceholderCorrected) {
// Start with a basic valid UTF-16 string.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <form>"
" <input id='wrong' type='text' name='Username' placeholder='Hello'>"
" </form>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// The only way to get an invalid UTF-16 string into the element is via
// Javascript, which isn't required to match surrogates. In this case, the
// unmatched surrogate is \uD83D. We also need to use a raw string so that the
// unicode character is interpreted by Javascript, not CPP.
helper_.LocalMainFrame()->ExecuteScript(WebScriptSource(
R"(
let input = document.getElementById('wrong');
input.name = 'Username\uD83D';
input.placeholder = 'Hello\uD83D';
)"));
GetAIPageContentWithActionableElements();
const auto* input = FindNodeBySelector("#wrong");
CheckFormControlNode(*input, mojom::blink::FormControlType::kInputText);
const mojom::blink::AIPageContentFormControlDataPtr& form_control_data =
input->content_attributes->form_control_data;
EXPECT_EQ(form_control_data->field_name, u"Username\uFFFD");
EXPECT_EQ(form_control_data->placeholder, u"Hello\uFFFD");
}
TEST_F(AIPageContentAgentTestTextEncoding, SelectFormControlOptionsCorrected) {
// Start with a basic valid UTF-16 string.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <select id='container'>"
" <option id='wrong' value='First'>Hello</option>"
" </select>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// The only way to get an invalid UTF-16 string into the element is via
// Javascript, which isn't required to match surrogates. In this case, the
// unmatched surrogate is \uD83D. We also need to use a raw string so that the
// unicode character is interpreted by Javascript, not CPP.
helper_.LocalMainFrame()->ExecuteScript(WebScriptSource(
R"(
let option = document.getElementById('wrong');
option.value = 'First\uD83D';
option.text = 'Hello\uD83D';
)"));
GetAIPageContentWithActionableElements();
const auto* select = FindNodeBySelector("#container");
CheckFormControlNode(*select, mojom::blink::FormControlType::kSelectOne);
const mojom::blink::AIPageContentFormControlDataPtr& form_control_data =
select->content_attributes->form_control_data;
EXPECT_EQ(form_control_data->select_options[0]->value, u"First\uFFFD");
EXPECT_EQ(form_control_data->select_options[0]->text, u"Hello\uFFFD");
}
TEST_F(AIPageContentAgentTestTextEncoding, MetadataCorrected) {
// Start with a basic valid UTF-16 string.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<head>"
" <meta id='wrong' name='Hello' content='World'>"
"</head>"
"<body>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// The only way to get an invalid UTF-16 string into the element is via
// Javascript, which isn't required to match surrogates. In this case, the
// unmatched surrogate is \uD83D. We also need to use a raw string so that the
// unicode character is interpreted by Javascript, not CPP.
helper_.LocalMainFrame()->ExecuteScript(WebScriptSource(
R"(
let meta = document.getElementById('wrong');
meta.name = 'Hello\uD83D';
meta.content = 'World\uD83D';
)"));
auto options = GetAIPageContentOptionsForTest();
options->mode = mojom::blink::AIPageContentMode::kActionableElements;
options->max_meta_elements = 1;
GetAIPageContent(std::move(options));
const mojom::blink::AIPageContentMetaPtr& meta =
Content()->frame_data->meta_data[0];
EXPECT_EQ(meta->name, u"Hello\uFFFD");
EXPECT_EQ(meta->content, u"World\uFFFD");
}
TEST_F(AIPageContentAgentTestTextEncoding, AriaLabelCorrected) {
// Start with a basic valid UTF-16 string.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <button id='wrong' aria-label='Hello'></button>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// The only way to get an invalid UTF-16 string into the element is via
// Javascript, which isn't required to match surrogates. In this case, the
// unmatched surrogate is \uD83D. We also need to use a raw string so that the
// unicode character is interpreted by Javascript, not CPP.
helper_.LocalMainFrame()->ExecuteScript(WebScriptSource(
R"(
let button = document.getElementById('wrong');
button.ariaLabel = 'Hello\uD83D';
)"));
GetAIPageContentWithActionableElements();
auto* button = FindNodeBySelector("#wrong");
EXPECT_EQ(button->content_attributes->label, u"Hello\uFFFD");
}
TEST_F(AIPageContentAgentTestTextEncoding, AriaLabeledByCorrected) {
// Start with a basic valid UTF-16 string.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div id='wrong' style='display: none;'>Hello</div>"
" <input id='on' type='text' aria-labelledby='wrong'>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// The only way to get an invalid UTF-16 string into the element is via
// Javascript, which isn't required to match surrogates. In this case, the
// unmatched surrogate is \uD83D. We also need to use a raw string so that the
// unicode character is interpreted by Javascript, not CPP.
helper_.LocalMainFrame()->ExecuteScript(WebScriptSource(
R"(
let div = document.getElementById('wrong');
div.textContent = 'Hello\uD83D';
)"));
GetAIPageContentWithActionableElements();
auto* input = FindNodeBySelector("#on");
EXPECT_EQ(input->content_attributes->label, u"Hello\uFFFD");
}
TEST_F(AIPageContentAgentTestTextEncoding, FrameTitleCorrected) {
// Start with a basic valid UTF-16 string.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<head>"
" <title id='wrong'>Hello</title>"
"</head>"
"<body>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// The only way to get an invalid UTF-16 string into the element is via
// Javascript, which isn't required to match surrogates. In this case, the
// unmatched surrogate is \uD83D. We also need to use a raw string so that the
// unicode character is interpreted by Javascript, not CPP.
helper_.LocalMainFrame()->ExecuteScript(WebScriptSource(
R"(
let title = document.getElementById('wrong');
title.textContent = 'Hello\uD83D';
)"));
GetAIPageContentWithActionableElements();
EXPECT_EQ(Content()->frame_data->title, u"Hello\uFFFD");
}
TEST_F(AIPageContentAgentTestTextEncoding,
FrameInteractionInfoSelectedTextCorrected) {
// Start with a basic valid UTF-16 string.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <p id='p1'>Paragraph 1</p>"
" <p id='p2'>Paragraph 2</p>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
// The only way to get an invalid UTF-16 string into the element is via
// Javascript, which isn't required to match surrogates. In this case, the
// unmatched surrogate is \uD83D. We also need to use a raw string so that the
// unicode character is interpreted by Javascript, not CPP.
helper_.LocalMainFrame()->ExecuteScript(WebScriptSource(
R"(
const p1 = document.getElementById('p1');
p1.innerText = 'Hello\uD83D'
const p2 = document.getElementById('p2');
const range = new Range();
range.setStart(p1.childNodes[0], 0);
range.setEnd(p2.childNodes[0], 4);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
)"));
GetAIPageContentWithActionableElements();
EXPECT_EQ(
Content()->frame_data->frame_interaction_info->selection->selected_text,
u"Hello\uFFFD\n\nPara");
}
TEST_F(AIPageContentAgentTestTextEncoding, FlagDisabled) {
ScopedAIPageContentConvertNodeTextToUtf8ForTest enable_utf8_conversion{false};
// Start with a basic valid UTF-16 string.
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <p id='wrong'>Hello</p>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
auto& document = *helper_.LocalMainFrame()->GetFrame()->GetDocument();
auto* p = document.getElementById(AtomicString("wrong"));
// Confirm that on the Blink side, we have a valid UTF-16 string.
EXPECT_EQ(p->innerText(), String(u"Hello"));
// The only way to get an invalid UTF-16 string into the element is via
// Javascript, which isn't required to match surrogates. In this case, the
// unmatched surrogate is \uD83D. We also need to use a raw string so that the
// unicode character is interpreted by Javascript, not CPP.
helper_.LocalMainFrame()->ExecuteScript(WebScriptSource(
R"(document.getElementById('wrong').innerText = 'Hello\uD83D')"));
GetAIPageContentWithActionableElements();
auto* p_node = FindNodeBySelector("#wrong");
const auto& text_attributes = *p_node->children_nodes[0]->content_attributes;
EXPECT_EQ(text_attributes.attribute_type,
mojom::blink::AIPageContentAttributeType::kText);
ASSERT_TRUE(text_attributes.text_info);
// The last character should be 0xD83D.
EXPECT_EQ(text_attributes.text_info->text_content.Span16().back(), 0xD83D);
}
TEST_F(AIPageContentAgentTest, DivWithWebkitTextSecurityRedactsAndPersists) {
ScopedAIPageContentElementCSSRedactionForTest scoped_feature(
/*enabled=*/true);
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div id='masked' style='-webkit-text-security: disc;'>secret</div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
EXPECT_EQ(root.children_nodes.size(), 0u);
// Now remove the masking style (simulating a "show password" eye icon).
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
auto* div_element =
To<HTMLDivElement>(document->getElementById(AtomicString("masked")));
ASSERT_TRUE(div_element);
div_element->setAttribute(html_names::kStyleAttr,
AtomicString("-webkit-text-security: none;"));
// Ensure the DOM is updated.
document->UpdateStyleAndLayout(DocumentUpdateReason::kTest);
// Extract again: the field should remain redacted since it was previously
// classified as password-like.
GetAIPageContent();
const auto& root2 = ContentRootNode();
EXPECT_EQ(root2.children_nodes.size(), 0u);
}
TEST_F(AIPageContentAgentTest,
FormWithWebkitTextSecuritySelectRedactsAndPersists) {
ScopedAIPageContentElementCSSRedactionForTest scoped_feature(
/*enabled=*/true);
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <form>"
" <select id='masked' style='-webkit-text-security: disc;'>"
" <option value='Lorem'>Lorem Text</option>"
" <option value='Ipsum'>Ipsum Text</option>"
" </select>"
" </form>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& form = *root.children_nodes[0];
ASSERT_EQ(form.children_nodes.size(), 1u);
const auto& select = *form.children_nodes[0];
CheckFormControlNode(select, mojom::blink::FormControlType::kSelectOne);
EXPECT_EQ(
select.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kRedacted_CustomPassword_CSS);
EXPECT_TRUE(
select.content_attributes->form_control_data->select_options.empty());
// Now remove the masking style.
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
auto* select_element =
To<HTMLSelectElement>(document->getElementById(AtomicString("masked")));
ASSERT_TRUE(select_element);
select_element->setAttribute(html_names::kStyleAttr,
AtomicString("-webkit-text-security: none;"));
// Ensure the DOM is updated.
document->UpdateStyleAndLayout(DocumentUpdateReason::kTest);
// Extract again: the field should remain redacted.
GetAIPageContent();
const auto& root2 = ContentRootNode();
const auto& form2 = *root2.children_nodes[0];
const auto& select2 = *form2.children_nodes[0];
CheckFormControlNode(select2, mojom::blink::FormControlType::kSelectOne);
EXPECT_EQ(
select2.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kRedacted_CustomPassword_CSS);
EXPECT_TRUE(
select2.content_attributes->form_control_data->select_options.empty());
}
class AIPageContentAgentTestElementCSSRedactionDisabled
: public AIPageContentAgentTest {
private:
ScopedAIPageContentElementCSSRedactionForTest scoped_feature{false};
};
TEST_F(AIPageContentAgentTestElementCSSRedactionDisabled,
DivWithWebkitTextSecurityNotRedacted) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <div id='masked' style='-webkit-text-security: disc;'>secret</div>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
// Since the feature is disabled, the div is flattened (it's generic)
// and the text node is extracted without redaction.
// Note: the text is still masked by the layout engine (bullets),
// but it's not explicitly redacted by our logic.
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& text_node = *root.children_nodes[0];
EXPECT_EQ(text_node.content_attributes->attribute_type,
mojom::blink::AIPageContentAttributeType::kText);
ASSERT_TRUE(text_node.content_attributes->text_info);
// The text should be masked (bullets), but NOT redacted.
// We check that it's not the original text and has the same length.
EXPECT_NE(text_node.content_attributes->text_info->text_content, "secret");
EXPECT_EQ(text_node.content_attributes->text_info->text_content.length(), 6u);
EXPECT_EQ(text_node.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kNoRedactionNecessary);
}
TEST_F(AIPageContentAgentTestElementCSSRedactionDisabled,
FormWithWebkitTextSecuritySelectNotRedacted) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <select id='masked' style='-webkit-text-security: disc;'>"
" <option value='Lorem'>Lorem Text</option>"
" </select>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
// root -> <select>
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& select = *root.children_nodes[0];
CheckFormControlNode(select, mojom::blink::FormControlType::kSelectOne);
EXPECT_EQ(select.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kNoRedactionNecessary);
// Options should be present.
EXPECT_FALSE(
select.content_attributes->form_control_data->select_options.empty());
EXPECT_EQ(select.content_attributes->form_control_data->select_options.size(),
1u);
EXPECT_EQ(
select.content_attributes->form_control_data->select_options[0]->text,
"Lorem Text");
}
TEST_F(AIPageContentAgentTestElementCSSRedactionDisabled,
FormWithWebkitTextSecurityInputStillRedacted) {
frame_test_helpers::LoadHTMLString(
helper_.LocalMainFrame(),
"<body>"
" <form>"
" <input id='masked' type='text' name='Enter secret' "
"style='-webkit-text-security: disc;' value='supersecret'>"
" </form>"
"</body>",
url_test_helpers::ToKURL("http://foobar.com"));
GetAIPageContent();
const auto& root = ContentRootNode();
ASSERT_EQ(root.children_nodes.size(), 1u);
const auto& form = *root.children_nodes[0];
ASSERT_EQ(form.children_nodes.size(), 1u);
const auto& field = *form.children_nodes[0];
CheckFormControlNode(field, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(field.content_attributes->form_control_data->field_name,
"Enter secret");
EXPECT_EQ(field.content_attributes->form_control_data->field_value, nullptr);
EXPECT_EQ(
field.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kRedacted_CustomPassword_CSS);
// Now remove the masking style (simulating a "show password" eye icon).
Document* document = helper_.LocalMainFrame()->GetFrame()->GetDocument();
auto* input_element =
To<HTMLInputElement>(document->getElementById(AtomicString("masked")));
ASSERT_TRUE(input_element);
input_element->setAttribute(html_names::kStyleAttr,
AtomicString("-webkit-text-security: none;"));
// Ensure the DOM is updated.
document->UpdateStyleAndLayout(DocumentUpdateReason::kTest);
// Extract again: the field should remain redacted since it was previously
// classified as password-like.
GetAIPageContent();
const auto& root2 = ContentRootNode();
ASSERT_EQ(root2.children_nodes.size(), 1u);
const auto& form2 = *root2.children_nodes[0];
ASSERT_EQ(form2.children_nodes.size(), 1u);
const auto& field2 = *form2.children_nodes[0];
CheckFormControlNode(field2, mojom::blink::FormControlType::kInputText);
EXPECT_EQ(field2.content_attributes->form_control_data->field_value, nullptr);
EXPECT_EQ(
field2.content_attributes->redaction_decision,
mojom::AIPageContentRedactionDecision::kRedacted_CustomPassword_CSS);
}
} // namespace
} // namespace blink