// 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
