| // Copyright 2022 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include <memory> |
| #include <string> |
| |
| #include "base/json/json_reader.h" |
| #include "base/strings/strcat.h" |
| #include "base/strings/stringprintf.h" |
| #include "base/test/bind.h" |
| #include "base/test/scoped_feature_list.h" |
| #include "base/values.h" |
| #include "build/build_config.h" |
| #include "content/browser/renderer_host/render_widget_host_view_child_frame.h" |
| #include "content/browser/web_contents/web_contents_impl.h" |
| #include "content/public/test/browser_test.h" |
| #include "content/public/test/browser_test_utils.h" |
| #include "content/public/test/content_browser_test.h" |
| #include "content/public/test/content_browser_test_utils.h" |
| #include "content/public/test/test_utils.h" |
| #include "content/shell/browser/shell.h" |
| #include "content/shell/browser/shell_content_browser_client.h" |
| #include "content/shell/common/shell_switches.h" |
| #include "content/test/content_browser_test_utils_internal.h" |
| #include "net/dns/mock_host_resolver.h" |
| #include "net/test/embedded_test_server/embedded_test_server.h" |
| #include "third_party/blink/public/common/features.h" |
| #include "third_party/blink/public/mojom/frame/frame.mojom-test-utils.h" |
| #include "third_party/blink/public/mojom/scroll/scroll_into_view_params.mojom.h" |
| #include "third_party/re2/src/re2/re2.h" |
| #include "ui/events/event_constants.h" |
| #include "url/gurl.h" |
| |
| #if defined(USE_AURA) |
| #include "content/browser/renderer_host/render_widget_host_view_aura.h" |
| #endif |
| |
| #define EXPECT_TRUE_OR_FAIL(condition) \ |
| EXPECT_TRUE(condition); \ |
| if (!condition) \ |
| return false; |
| |
| namespace content { |
| |
| namespace { |
| |
| // Test variants |
| |
| // kLocalFrame will force all remote frames in a test to be local. |
| enum TestFrameType { kLocalFrame, kRemoteFrame }; |
| |
| // Tests run with both Left-to-Right and Right-to-Left writing modes. |
| enum TestWritingMode { kLTR, kRTL }; |
| |
| // What kind of scroll into view to invoke, via JavaScript binding |
| // (element.scrollIntoView), using the InputHandler |
| // ScrollFocusedEditableNodeIntoView method, or via setting an OSK inset. |
| enum TestInvokeMethod { kJavaScript, kInputHandler, kAuraOnScreenKeyboard }; |
| |
| [[maybe_unused]] std::string DescribeFrameType( |
| const testing::TestParamInfo<TestFrameType>& info) { |
| std::string frame_type; |
| switch (info.param) { |
| case kLocalFrame: { |
| frame_type = "LocalFrame"; |
| } break; |
| case kRemoteFrame: { |
| frame_type = "RemoteFrame"; |
| } break; |
| } |
| return frame_type; |
| } |
| |
| blink::mojom::FrameWidgetInputHandler* GetInputHandler(FrameTreeNode* node) { |
| return node->current_frame_host() |
| ->GetRenderWidgetHost() |
| ->GetFrameWidgetInputHandler(); |
| } |
| |
| // Will block from the destructor until a ScrollFocusedEditableNodeIntoView has |
| // completed. This must be called with the root frame tree node since that's |
| // where the ScrollIntoView and PageScaleAnimation will bubble to. |
| class ScopedFocusScrollWaiter { |
| public: |
| explicit ScopedFocusScrollWaiter(FrameTreeNode* node) { |
| DCHECK(node->IsOutermostMainFrame()); |
| GetInputHandler(node)->WaitForPageScaleAnimationForTesting( |
| run_loop_.QuitClosure()); |
| } |
| |
| ~ScopedFocusScrollWaiter() { run_loop_.Run(); } |
| |
| private: |
| base::RunLoop run_loop_; |
| }; |
| |
| // While this is in scope, causes the TextInputManager of the given WebContents |
| // to always return nullptr. This effectively blocks the IME from receiving any |
| // events from the renderer. Note: RenderWidgetHostViewBase caches this value |
| // so for this to work it must be constructed before the target page is |
| // constructed. |
| class ScopedSuppressImeEvents { |
| public: |
| explicit ScopedSuppressImeEvents(WebContentsImpl* web_contents) |
| : web_contents_(web_contents->GetWeakPtr()) { |
| web_contents->set_suppress_ime_events_for_testing(true); |
| } |
| |
| ~ScopedSuppressImeEvents() { |
| if (!web_contents_) |
| return; |
| |
| static_cast<WebContentsImpl*>(web_contents_.get()) |
| ->set_suppress_ime_events_for_testing(false); |
| } |
| |
| base::WeakPtr<WebContents> web_contents_; |
| }; |
| |
| // Interceptor that can be used to verify calls to |
| // ScrollRectToVisibleInParentFrame on the LocalFrameHost interface. |
| class ScrollRectToVisibleInParentFrameInterceptor |
| : public blink::mojom::LocalFrameHostInterceptorForTesting { |
| public: |
| ScrollRectToVisibleInParentFrameInterceptor() = default; |
| ~ScrollRectToVisibleInParentFrameInterceptor() override = default; |
| |
| void Init(RenderFrameHostImpl* render_frame_host) { |
| render_frame_host_ = render_frame_host; |
| std::ignore = render_frame_host_->local_frame_host_receiver_for_testing() |
| .SwapImplForTesting(this); |
| } |
| |
| blink::mojom::LocalFrameHost* GetForwardingInterface() override { |
| return render_frame_host_; |
| } |
| |
| void ScrollRectToVisibleInParentFrame( |
| const gfx::RectF& rect_to_scroll, |
| blink::mojom::ScrollIntoViewParamsPtr params) override { |
| has_called_method_ = true; |
| } |
| |
| bool HasCalledScrollRectToVisibleInParentFrame() const { |
| return has_called_method_; |
| } |
| |
| private: |
| raw_ptr<RenderFrameHostImpl> render_frame_host_; |
| bool has_called_method_ = false; |
| }; |
| |
| // Test harness for ScrollIntoView related browser tests. These tests are |
| // mainly concerned with behavior of scroll into view related functionality |
| // across remote frames. This harness depends on |
| // cross_site_scroll_into_view_factory.html, which is based on |
| // cross_site_iframe_factory.html. |
| // |
| // cross_site_scroll_into_view_factory.html builds a frame tree from its given |
| // argument, allowing only a single child frame in each frame. The inner most |
| // frame adds an <input> element which can be used to call |
| // ScrollFocusedEditableNodeIntoView. |
| // |
| // Each test starts by performing a non-scrolling focus on the <input> element. |
| // It then performs a scroll into view (either via JavaScript bindings or |
| // content API) and ensures the caret is within a vertically centered band of |
| // the viewport. |
| class ScrollIntoViewBrowserTestBase : public ContentBrowserTest { |
| public: |
| ScrollIntoViewBrowserTestBase() = default; |
| ~ScrollIntoViewBrowserTestBase() override = default; |
| |
| virtual bool IsForceLocalFrames() const = 0; |
| virtual bool IsWritingModeLTR() const = 0; |
| virtual TestInvokeMethod GetInvokeMethod() const = 0; |
| virtual net::EmbeddedTestServer* server() { return embedded_test_server(); } |
| |
| void SetUpOnMainThread() override { |
| ContentBrowserTest::SetUpOnMainThread(); |
| host_resolver()->AddRule("*", "127.0.0.1"); |
| ASSERT_TRUE(server()->Start()); |
| |
| suppress_ime_ = std::make_unique<ScopedSuppressImeEvents>(web_contents()); |
| } |
| |
| void TearDownOnMainThread() override { |
| suppress_ime_.reset(); |
| |
| ContentBrowserTest::TearDownOnMainThread(); |
| } |
| |
| void SetUpCommandLine(base::CommandLine* command_line) override { |
| IsolateAllSitesForTesting(command_line); |
| |
| // Need this to control page scale factor via script or check for root |
| // scroller. |
| command_line->AppendSwitch(switches::kExposeInternalsForTesting); |
| } |
| |
| WebContentsImpl* web_contents() { |
| return static_cast<WebContentsImpl*>(shell()->web_contents()); |
| } |
| |
| FrameTreeNode* InnerMostFrameTreeNode() { |
| FrameTreeNode* inner_most_node = nullptr; |
| ForEachFrameFromRootToInnerMost( |
| [&inner_most_node](FrameTreeNode* node) { inner_most_node = node; }); |
| return inner_most_node; |
| } |
| |
| FrameTreeNode* RootFrameTreeNode() { |
| return web_contents()->GetPrimaryFrameTree().root(); |
| } |
| |
| // Gets the bounding client rect from the element returned via the given query |
| // string (i.e. as found via document.querySelector). |
| gfx::RectF GetClientRect(FrameTreeNode* node, std::string query) { |
| auto result = EvalJs(node, JsReplace(R"JS( |
| JSON.stringify(document.querySelector($1).getBoundingClientRect()); |
| )JS", |
| query)); |
| std::optional<base::Value> value = |
| base::JSONReader::Read(result.ExtractString()); |
| CHECK(value.has_value()); |
| CHECK(value->is_dict()); |
| |
| const base::Value::Dict& dict = value->GetDict(); |
| std::optional<double> x = dict.FindDouble("x"); |
| std::optional<double> y = dict.FindDouble("y"); |
| std::optional<double> width = dict.FindDouble("width"); |
| std::optional<double> height = dict.FindDouble("height"); |
| |
| CHECK(x); |
| CHECK(y); |
| CHECK(width); |
| CHECK(height); |
| |
| return gfx::RectF(*x, *y, *width, *height); |
| } |
| |
| gfx::RectF GetLayoutViewportRect() { |
| return GetClientRect(RootFrameTreeNode(), ".layoutViewport"); |
| } |
| |
| struct VisualViewport { |
| // These are the values coming from the window.visualViewport object. Note: |
| // the width/height are _relative to the root frame_, meaning they decrease |
| // as `scale` increases. |
| double offset_left; |
| double offset_top; |
| double width; |
| double height; |
| double scale; |
| double page_left; |
| double page_top; |
| |
| // This is the _unscaled_ rect computed from values above. |
| gfx::RectF rect; |
| }; |
| |
| VisualViewport GetVisualViewport() { |
| auto result = EvalJs(RootFrameTreeNode(), R"JS( |
| JSON.stringify({ |
| offsetLeft: visualViewport.offsetLeft, |
| offsetTop: visualViewport.offsetTop, |
| width: visualViewport.width, |
| height: visualViewport.height, |
| scale: visualViewport.scale, |
| pageLeft: visualViewport.pageLeft, |
| pageTop: visualViewport.pageTop}); |
| )JS"); |
| |
| std::optional<base::Value> value = |
| base::JSONReader::Read(result.ExtractString()); |
| CHECK(value.has_value()); |
| CHECK(value->is_dict()); |
| |
| const base::Value::Dict& dict = value->GetDict(); |
| std::optional<double> offset_left = dict.FindDouble("offsetLeft"); |
| std::optional<double> offset_top = dict.FindDouble("offsetTop"); |
| std::optional<double> width = dict.FindDouble("width"); |
| std::optional<double> height = dict.FindDouble("height"); |
| std::optional<double> scale = dict.FindDouble("scale"); |
| std::optional<double> page_left = dict.FindDouble("pageLeft"); |
| std::optional<double> page_top = dict.FindDouble("pageTop"); |
| |
| CHECK(offset_left); |
| CHECK(offset_top); |
| CHECK(width); |
| CHECK(height); |
| CHECK(scale); |
| CHECK(page_left); |
| CHECK(page_top); |
| |
| VisualViewport values; |
| values.offset_left = *offset_left; |
| values.offset_top = *offset_top; |
| values.width = *width; |
| values.height = *height; |
| values.scale = *scale; |
| values.page_left = *page_left; |
| values.page_top = *page_top; |
| |
| values.rect = gfx::RectF( |
| gfx::PointF(), |
| gfx::ScaleSize(gfx::SizeF(values.width, values.height), values.scale)); |
| |
| return values; |
| } |
| |
| // Gets the bounding rect of the caret (taken from the <input> element in the |
| // inner-most frame) as it appears in the root most viewport. |
| // |
| // This accounts for clipping in each intervening frame. |
| // |
| // WARNING: This doesn't take transforms on the frames into account. It also |
| // makes a guess on where the caret is, based on the writing-mode of the |
| // document. |
| gfx::RectF GetCaretRectInViewport() { |
| FrameTreeNode* node = InnerMostFrameTreeNode(); |
| gfx::RectF rect = GetClientRect(node, "input"); |
| |
| // Take either the left-most or right-most portion of the input box as an |
| // estimate of the caret; based on the writing-mode of the page. |
| constexpr float kCaretBoxWidth = 30.f; |
| if (IsWritingModeLTR()) { |
| rect.Inset(gfx::InsetsF::TLBR(0, 0, 0, rect.width() - kCaretBoxWidth)); |
| } else { |
| rect.Inset(gfx::InsetsF::TLBR(0, rect.width() - kCaretBoxWidth, 0, 0)); |
| } |
| |
| EXPECT_EQ("", EvalJs(node, "document.querySelector('input').value")) |
| << "Caret location is assumed based on empty <input> value"; |
| |
| // If `node` is a child frame, we'll convert rect up the ancestor frame |
| // chain, clipping to each frame rect. |
| FrameTreeNode* frame = |
| FrameTreeNode::From(node->GetParentOrOuterDocument()); |
| while (frame) { |
| gfx::RectF parent_rect = GetClientRect(frame, "#childframe"); |
| rect.Offset(parent_rect.OffsetFromOrigin()); |
| |
| rect = gfx::IntersectRects(parent_rect, rect); |
| |
| frame = FrameTreeNode::From(frame->GetParentOrOuterDocument()); |
| } |
| |
| gfx::RectF root_frame_rect = GetLayoutViewportRect(); |
| root_frame_rect.set_origin(gfx::PointF()); |
| |
| rect = gfx::IntersectRects(root_frame_rect, rect); |
| |
| VisualViewport visual_viewport = GetVisualViewport(); |
| rect.Offset(-visual_viewport.offset_left, -visual_viewport.offset_top); |
| rect.Scale(visual_viewport.scale); |
| |
| rect = gfx::IntersectRects(visual_viewport.rect, rect); |
| |
| return rect; |
| } |
| |
| // Returns the rect within the visual viewport where, if the caret ends up in |
| // after a scroll into view, we'll consider it a success. |
| gfx::RectF GetAcceptableCaretRect() { |
| gfx::RectF caret_in_viewport = GetCaretRectInViewport(); |
| VisualViewport visual_viewport = GetVisualViewport(); |
| |
| gfx::RectF rect = visual_viewport.rect; |
| |
| // Vertically, the caret should be roughly centered (40px of wiggleroom, |
| // e.g. for scrollbars, in either direction) in the viewport. |
| const float kVerticalInset = |
| ((rect.height() - caret_in_viewport.height()) / 2.f) - 40.f; |
| |
| // Horizontally, we're less picky, as long as the caret is in the viewport. |
| // TODO(bokan): The constants used in |
| // WebViewImpl::ComputeScaleAndScrollForEditableElementRects are somewhat |
| // inscrutible and dimension dependent (which is a problem when this test |
| // runs on Android and the width depends on the device). Ideally we'd be |
| // able to ensure the caret appears in the right region of the viewport. |
| const float kHorizontalInset = 0.f; |
| |
| rect.Inset(gfx::InsetsF::VH(kVerticalInset, kHorizontalInset)); |
| return rect; |
| } |
| |
| // Modifies the frame tree string as needed for different test parameters. |
| GURL GetMainURLForFrameTree(std::string frame_tree_string) { |
| // To make things simple, remove any whitespace or empty attribute lists. |
| re2::RE2::GlobalReplace(&frame_tree_string, "\\s*", ""); |
| re2::RE2::GlobalReplace(&frame_tree_string, "{}", ""); |
| |
| // If we're in a local frame test variant, replace all site strings with |
| // "siteA". |
| if (IsForceLocalFrames()) { |
| re2::RE2::GlobalReplace(&frame_tree_string, "site[A-Z]", "siteA"); |
| } |
| |
| // For RTL tests, add {RTL} attribute on each frame. |
| if (!IsWritingModeLTR()) { |
| // Prepend RTL to any existing attribute lists. |
| re2::RE2::GlobalReplace(&frame_tree_string, "{(.*?)}", "{RTL,\\1}"); |
| |
| // Add an attribute list with RTL to sites without an existing list. |
| { |
| std::string regex = |
| // Match any site name (store in capture group 1). |
| "(site[A-Z])" |
| |
| // That's followed by a non-{ character or line-end (store in |
| // capture group 2). |
| "([^{]|$)"; |
| |
| re2::RE2::GlobalReplace(&frame_tree_string, regex, "\\1{RTL}\\2"); |
| } |
| } |
| |
| return server()->GetURL( |
| "a.test", base::StrCat({"/cross_site_scroll_into_view_factory.html?", |
| frame_tree_string})); |
| } |
| |
| // Simualte a keyboard coming up, insetting the viewport by its height. |
| void SetAuraOnScreenKeyboardInset(int keyboard_height) { |
| #if defined(USE_AURA) |
| RenderWidgetHostViewBase* inner_most_view = InnerMostFrameTreeNode() |
| ->current_frame_host() |
| ->GetRenderWidgetHost() |
| ->GetView(); |
| |
| RenderWidgetHostViewBase* root_view = inner_most_view->GetRootView(); |
| |
| // Set the pointer type to simulate the keyboard appearing as a result of |
| // the user tapping on an editable element. |
| root_view->SetLastPointerType(ui::EventPointerType::kTouch); |
| root_view->SetInsets(gfx::Insets::TLBR(0, 0, keyboard_height, 0)); |
| #else |
| NOTREACHED(); |
| #endif |
| } |
| |
| // Calls `func` with each FrameTreeNode in the page, starting from the root |
| // and descending into the inner most frame, traversing frame tree boundaries |
| // such as fenced frames. |
| template <typename Function> |
| void ForEachFrameFromRootToInnerMost(const Function& func) { |
| FrameTreeNode* node = web_contents()->GetPrimaryFrameTree().root(); |
| while (node) { |
| bool is_proxy_for_inner_frame_tree = |
| !node->current_frame_host() |
| ->inner_tree_main_frame_tree_node_id() |
| .is_null(); |
| // The functor isn't called for the placeholder FrameTreeNode, it'll be |
| // called on the inner tree's root. |
| if (!is_proxy_for_inner_frame_tree) |
| func(node); |
| |
| if (node->child_count()) { |
| CHECK(node->current_frame_host() |
| ->inner_tree_main_frame_tree_node_id() |
| .is_null()); |
| // These tests never have multiple child frames. |
| CHECK_EQ(node->child_count(), 1ul); |
| node = node->child_at(0); |
| } else if (is_proxy_for_inner_frame_tree) { |
| CHECK_EQ(node->child_count(), 0ul); |
| node = FrameTreeNode::GloballyFindByID( |
| node->current_frame_host()->inner_tree_main_frame_tree_node_id()); |
| } else { |
| node = nullptr; |
| } |
| } |
| } |
| |
| // Cross origin frames may throttle their lifecycle when not visible. |
| // This method ensure each frame is brought into view and a frame produced to |
| // ensure up-to-date layout. |
| void EnsureAllFramesCompletedLifecycle() { |
| // Wait until each frame presents a CompositorFrame and then scroll its |
| // child frame (if it has one) into view, so that it is unthrottled and |
| // able to generate and present CompositorFrames. |
| ForEachFrameFromRootToInnerMost([](FrameTreeNode* node) { |
| base::RunLoop loop; |
| node->current_frame_host()->InsertVisualStateCallback( |
| base::BindLambdaForTesting( |
| [&loop](bool visual_state_updated) { loop.Quit(); })); |
| loop.Run(); |
| |
| EXPECT_TRUE(ExecJs(node, R"JS( |
| if (document.getElementById('childframe')) |
| document.getElementById('childframe').scrollIntoView() |
| )JS")); |
| }); |
| |
| // Now that each frame has been in view and produced a frame, reset each |
| // scroll offset. |
| ForEachFrameFromRootToInnerMost([](FrameTreeNode* node) { |
| EXPECT_TRUE(ExecJs(node, "window.scrollTo(0, 0)")); |
| }); |
| } |
| |
| // For frame_tree syntax see tree_parser_util.js. |
| // These tests place two additional restrictions to make some simplifying |
| // assumptions: |
| // |
| // * All site names must start with "site" and be followed by [A-Z]. |
| // * Allow only one or zero children. That is, siteA(siteB) is valid but |
| // siteA(siteB, siteB) is not. |
| // |
| // For valid arguments, see comments in |
| // cross_site_scroll_into_view_factory.html |
| bool SetupTest(std::string frame_tree) { |
| const GURL kMainUrl(GetMainURLForFrameTree(frame_tree)); |
| |
| if (!NavigateToURL(shell(), kMainUrl)) |
| return false; |
| |
| EnsureAllFramesCompletedLifecycle(); |
| |
| VisualViewport viewport = GetVisualViewport(); |
| double page_scale_factor_before = viewport.scale; |
| double page_left_before = viewport.page_left; |
| double page_top_before = viewport.page_top; |
| |
| if (GetInvokeMethod() == kInputHandler || |
| GetInvokeMethod() == kAuraOnScreenKeyboard) { |
| // Focus the input for tests that rely on scrolling to a focused element |
| // (i.e. via ScrollFocusedEditableNodeIntoView). Use `preventScroll` to |
| // avoid affecting the test via the automatic scrolling caused by focus. |
| // |
| // Note: normally, an IME (i.e. On-Screen Keyboard) can also attempt to |
| // scroll into view (in fact, using ScrollFocusedEditableNodeIntoView |
| // which we're trying to test). However, in order to reliably test this |
| // across platforms this test harness suppresses IME events so that the |
| // on-screen keyboard on a platform that uses one will not activate in |
| // response to this. See ScopedSuppressImeEvents above. |
| EXPECT_TRUE_OR_FAIL(ExecJs(InnerMostFrameTreeNode(), R"JS( |
| document.querySelector('input').focus({preventScroll: true}); |
| )JS")); |
| } |
| |
| // The test should start with fresh scroll and scale. |
| viewport = GetVisualViewport(); |
| CHECK_EQ(viewport.scale, page_scale_factor_before); |
| CHECK_EQ(viewport.page_left, page_left_before); |
| CHECK_EQ(viewport.page_top, page_top_before); |
| |
| return true; |
| } |
| |
| void RunTest() { |
| switch (GetInvokeMethod()) { |
| case kInputHandler: { |
| ScopedFocusScrollWaiter wait_for_scroll_done(RootFrameTreeNode()); |
| |
| GetInputHandler(InnerMostFrameTreeNode()) |
| ->ScrollFocusedEditableNodeIntoView(); |
| } break; |
| case kAuraOnScreenKeyboard: { |
| ScopedFocusScrollWaiter wait_for_scroll_done(RootFrameTreeNode()); |
| SetAuraOnScreenKeyboardInset(/*keyboard_height=*/400); |
| } break; |
| case kJavaScript: { |
| RenderFrameSubmissionObserver frame_observer(web_contents()); |
| EXPECT_TRUE(ExecJs(InnerMostFrameTreeNode(), R"JS( |
| document.querySelector('input').scrollIntoView({ |
| behavior: 'instant', |
| block: 'center', |
| inline: 'center' |
| }) |
| )JS")); |
| frame_observer.WaitForScrollOffsetAtTop( |
| /*expected_scroll_offset_at_top=*/false); |
| } break; |
| } |
| |
| gfx::RectF caret_in_viewport = GetCaretRectInViewport(); |
| gfx::RectF acceptable_rect = GetAcceptableCaretRect(); |
| |
| EXPECT_TRUE(acceptable_rect.Contains(caret_in_viewport)) |
| << "Expected caret to within [" << acceptable_rect.ToString() |
| << "] but caret is [" << caret_in_viewport.ToString() << "]"; |
| } |
| |
| private: |
| std::unique_ptr<ScopedSuppressImeEvents> suppress_ime_; |
| }; |
| |
| // Runs tests in all combinations of Local/Remote frames, |
| // left-to-right/right-to-left writing modes, and scrollIntoView via |
| // element.scrollIntoView/InputHandler.ScrollFocusedEditableNodeIntoView. The |
| // kAuraOnScreenKeyboard is intentionally omitted as it is expected to be |
| // functionally equivalent to kInputHandler. |
| class ScrollIntoViewBrowserTest |
| : public ScrollIntoViewBrowserTestBase, |
| public ::testing::WithParamInterface< |
| std::tuple<TestFrameType, TestWritingMode, TestInvokeMethod>> { |
| public: |
| bool IsForceLocalFrames() const override { |
| return std::get<0>(GetParam()) == kLocalFrame; |
| } |
| |
| bool IsWritingModeLTR() const override { |
| return std::get<1>(GetParam()) == kLTR; |
| } |
| |
| TestInvokeMethod GetInvokeMethod() const override { |
| return std::get<2>(GetParam()); |
| } |
| |
| static std::string DescribeParams( |
| const testing::TestParamInfo<ParamType>& info) { |
| auto [frame_type_param, writing_mode_param, invoke_method_param] = |
| info.param; |
| |
| std::string frame_type; |
| switch (frame_type_param) { |
| case kLocalFrame: { |
| frame_type = "LocalFrame"; |
| } break; |
| case kRemoteFrame: { |
| frame_type = "RemoteFrame"; |
| } break; |
| } |
| |
| std::string writing_mode; |
| switch (writing_mode_param) { |
| case kLTR: { |
| writing_mode = "LTR"; |
| } break; |
| case kRTL: { |
| writing_mode = "RTL"; |
| } break; |
| } |
| |
| std::string invoke_method; |
| switch (invoke_method_param) { |
| case kJavaScript: { |
| invoke_method = "JavaScript"; |
| } break; |
| case kInputHandler: { |
| invoke_method = "ScrollFocusedEditableNodeIntoView"; |
| } break; |
| case kAuraOnScreenKeyboard: { |
| invoke_method = "AuraOnScreenKeyboard"; |
| } break; |
| } |
| |
| return base::StringPrintf("%s_%s_%s", frame_type.c_str(), |
| writing_mode.c_str(), invoke_method.c_str()); |
| } |
| }; |
| |
| // See comment in SetupTest for frame tree syntax. |
| |
| // ScrollIntoViewBrowserTest runs with all combinations of multiple parameters |
| // to test the basic scroll into view machinery so each test instantiates 8 |
| // cases. To avoid an explosion of tests, prefer to add new tests to a more |
| // specific suite unless the functionality it's testing is likely to differ |
| // across the various parameters and isn't already covered. |
| |
| IN_PROC_BROWSER_TEST_P(ScrollIntoViewBrowserTest, EditableInSingleNestedFrame) { |
| ASSERT_TRUE(SetupTest("siteA(siteB)")); |
| RunTest(); |
| } |
| |
| IN_PROC_BROWSER_TEST_P(ScrollIntoViewBrowserTest, EditableInLocalRoot) { |
| ASSERT_TRUE(SetupTest("siteA(siteB(siteA))")); |
| RunTest(); |
| } |
| |
| IN_PROC_BROWSER_TEST_P(ScrollIntoViewBrowserTest, EditableInDoublyNestedFrame) { |
| ASSERT_TRUE(SetupTest("siteA(siteB(siteC))")); |
| RunTest(); |
| } |
| |
| IN_PROC_BROWSER_TEST_P( |
| ScrollIntoViewBrowserTest, |
| CrossesEditableInDoublyNestedFrameLocalAndRemoteBoundaries) { |
| ASSERT_TRUE(SetupTest("siteA(siteA(siteB(siteB)))")); |
| RunTest(); |
| } |
| |
| INSTANTIATE_TEST_SUITE_P( |
| /* no prefix */, |
| ScrollIntoViewBrowserTest, |
| testing::Combine(testing::Values(kLocalFrame, kRemoteFrame), |
| testing::Values(kLTR, kRTL), |
| // kAuraOnScreenKeyboard is intentionally omitted as it is |
| // expected to be functionally equivalent to |
| // kInputHandler. |
| testing::Values(kJavaScript, kInputHandler)), |
| ScrollIntoViewBrowserTest::DescribeParams); |
| |
| #if defined(USE_AURA) |
| |
| // Tests viewport insetting as a result of keyboard insets. Insetting is only |
| // used on Aura platforms. The OSK on Android resizes the entire view. |
| class InsetScrollIntoViewBrowserTest |
| : public ScrollIntoViewBrowserTestBase, |
| public ::testing::WithParamInterface<TestFrameType> { |
| public: |
| bool IsForceLocalFrames() const override { return GetParam() == kLocalFrame; } |
| bool IsWritingModeLTR() const override { return true; } |
| TestInvokeMethod GetInvokeMethod() const override { |
| return kAuraOnScreenKeyboard; |
| } |
| }; |
| |
| // Ensure that insetting the viewport causes the visual viewport to be resized |
| // and focused editable scrolled into view. (https://crbug.com/927483) |
| IN_PROC_BROWSER_TEST_P(InsetScrollIntoViewBrowserTest, |
| InsetsCauseScrollToFocusedEditable) { |
| ASSERT_TRUE(SetupTest("siteA(siteB(siteC))")); |
| |
| int contents_height = web_contents()->GetViewBounds().height(); |
| |
| // Ensure the window height is large enough to accommodate the inset and leave |
| // some space for a caret. Note: we can't just assume 800x600 because some |
| // Windows 7 bots have less than 600px of workspace area available which |
| // results in a smaller window. |
| ASSERT_GT(contents_height, 450); |
| |
| int visual_viewport_height_before = GetVisualViewport().height; |
| int layout_viewport_height_before = GetLayoutViewportRect().height(); |
| |
| // We expect the viewport height to match the WebContents but allow some |
| // fuzziness due to differing scrollbars and window decorations on different |
| // platforms. |
| const int kEpsilon = 30; |
| EXPECT_NEAR(visual_viewport_height_before, contents_height, kEpsilon); |
| EXPECT_NEAR(layout_viewport_height_before, contents_height, kEpsilon); |
| EXPECT_EQ(1.f, GetVisualViewport().scale); |
| |
| RunTest(); |
| |
| // The visualViewport should have been insetted by 400px but not the root |
| // frame. |
| EXPECT_EQ(visual_viewport_height_before - GetVisualViewport().height, 400); |
| EXPECT_EQ(layout_viewport_height_before, GetLayoutViewportRect().height()); |
| EXPECT_EQ(1.f, GetVisualViewport().scale); |
| |
| // The rect where we expect the caret to appear must not not be below the |
| // inset region. |
| ASSERT_LT(GetAcceptableCaretRect().bottom(), 200); |
| } |
| |
| INSTANTIATE_TEST_SUITE_P(/* no prefix */, |
| InsetScrollIntoViewBrowserTest, |
| testing::Values(kLocalFrame, kRemoteFrame), |
| DescribeFrameType); |
| |
| #endif |
| |
| // Only Chrome Android performs a zoom when focusing an editable. |
| #if BUILDFLAG(IS_ANDROID) |
| |
| constexpr double kMobileMinimumScale = 0.25; |
| |
| // Tests zooming behaviors for ScrollFocusedEditableNodeIntoView. These tests |
| // runs only on Android since that's the only platorm that uses this behavior. |
| class ZoomScrollIntoViewBrowserTest |
| : public ScrollIntoViewBrowserTestBase, |
| public ::testing::WithParamInterface<TestFrameType> { |
| public: |
| bool IsForceLocalFrames() const override { return GetParam() == kLocalFrame; } |
| |
| bool IsWritingModeLTR() const override { return true; } |
| |
| TestInvokeMethod GetInvokeMethod() const override { return kInputHandler; } |
| }; |
| |
| // A regular "desktop" site (i.e. no viewport <meta> tag) on Chrome Android |
| // should zoom in on a focused editable so that it's legible. |
| IN_PROC_BROWSER_TEST_P(ZoomScrollIntoViewBrowserTest, DesktopViewportMustZoom) { |
| ASSERT_TRUE(SetupTest("siteA(siteB)")); |
| |
| EXPECT_EQ(kMobileMinimumScale, GetVisualViewport().scale); |
| |
| RunTest(); |
| |
| // Without a viewport tag, the page is considered a "desktop" page so we |
| // should enable zooming to a legible scale. |
| EXPECT_NEAR(1, GetVisualViewport().scale, 0.05); |
| } |
| |
| // Ensure that adding a `width=device-width` viewport <meta> tag disables the |
| // zooming behavior so that "mobile-friendly" pages do not zoom in on input |
| // boxes. |
| IN_PROC_BROWSER_TEST_P(ZoomScrollIntoViewBrowserTest, |
| MobileViewportDisablesZoom) { |
| ASSERT_TRUE(SetupTest("siteA{MobileViewport}(siteB)")); |
| |
| EXPECT_EQ(kMobileMinimumScale, GetVisualViewport().scale); |
| |
| RunTest(); |
| |
| // width=device-width must prevent the zooming behavior. |
| EXPECT_LE(kMobileMinimumScale, GetVisualViewport().scale); |
| } |
| |
| // Similar to above, an input in a touch-action region that disables pinch-zoom |
| // shouldn't cause zoom since it may trap the user at that zoom level. |
| IN_PROC_BROWSER_TEST_P(ZoomScrollIntoViewBrowserTest, |
| TouchActionNoneDisablesZoom) { |
| ASSERT_TRUE(SetupTest("siteA(siteB{TouchActionNone})")); |
| |
| EXPECT_EQ(kMobileMinimumScale, GetVisualViewport().scale); |
| |
| RunTest(); |
| |
| // touch-action: none must prevent the zooming behavior since the user may |
| // not be able to zoom back out. |
| EXPECT_EQ(kMobileMinimumScale, GetVisualViewport().scale); |
| } |
| |
| class RootScrollerScrollIntoViewBrowserTest |
| : public ScrollIntoViewBrowserTestBase { |
| public: |
| bool IsForceLocalFrames() const override { return false; } |
| bool IsWritingModeLTR() const override { return true; } |
| TestInvokeMethod GetInvokeMethod() const override { return kInputHandler; } |
| }; |
| |
| IN_PROC_BROWSER_TEST_F(RootScrollerScrollIntoViewBrowserTest, |
| FocusInRootScroller) { |
| ASSERT_TRUE(SetupTest("siteA{RootScroller,MobileViewportNoZoom}")); |
| |
| // Root scroller is recomputed after a Blink lifecycle so ensure a frame is |
| // produced to make sure the renderer has had time to evaluate the root |
| // scroller. |
| { |
| base::RunLoop loop; |
| shell()->web_contents()->GetPrimaryMainFrame()->InsertVisualStateCallback( |
| base::BindLambdaForTesting( |
| [&loop](bool visual_state_updated) { loop.Quit(); })); |
| loop.Run(); |
| } |
| |
| ASSERT_EQ(1.0, GetVisualViewport().scale); |
| ASSERT_EQ( |
| true, |
| EvalJs( |
| InnerMostFrameTreeNode(), |
| "window.internals.effectiveRootScroller(document).tagName == 'DIV'")); |
| |
| RunTest(); |
| } |
| |
| INSTANTIATE_TEST_SUITE_P(/* no prefix */, |
| ZoomScrollIntoViewBrowserTest, |
| testing::Values(kLocalFrame, kRemoteFrame), |
| DescribeFrameType); |
| #endif |
| |
| // Tests scrollIntoView behaviors related to a fenced frame. |
| class ScrollIntoViewFencedFrameBrowserTest |
| : public ScrollIntoViewBrowserTestBase { |
| public: |
| ScrollIntoViewFencedFrameBrowserTest() { |
| feature_list_.InitWithFeatures({blink::features::kFencedFrames, |
| features::kPrivacySandboxAdsAPIsOverride, |
| blink::features::kFencedFramesAPIChanges, |
| blink::features::kFencedFramesDefaultMode}, |
| {/* disabled_features */}); |
| } |
| bool IsForceLocalFrames() const override { return false; } |
| bool IsWritingModeLTR() const override { return true; } |
| TestInvokeMethod GetInvokeMethod() const override { return kInputHandler; } |
| net::EmbeddedTestServer* server() override { return &https_server_; } |
| |
| void SetUpOnMainThread() override { |
| https_server_.ServeFilesFromSourceDirectory(GetTestDataFilePath()); |
| https_server_.SetSSLConfig(net::EmbeddedTestServer::CERT_TEST_NAMES); |
| ScrollIntoViewBrowserTestBase::SetUpOnMainThread(); |
| } |
| |
| private: |
| net::EmbeddedTestServer https_server_{net::EmbeddedTestServer::TYPE_HTTPS}; |
| base::test::ScopedFeatureList feature_list_; |
| }; |
| |
| IN_PROC_BROWSER_TEST_F(ScrollIntoViewFencedFrameBrowserTest, |
| SingleFencedFrame) { |
| ASSERT_TRUE(SetupTest("siteA{FencedFrame}(siteB)")); |
| RunTest(); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(ScrollIntoViewFencedFrameBrowserTest, |
| NestedFencedFrames) { |
| ASSERT_TRUE(SetupTest("siteA{FencedFrame}(siteB{FencedFrame}(siteC))")); |
| RunTest(); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(ScrollIntoViewFencedFrameBrowserTest, |
| LocalFrameInFencedFrame) { |
| ASSERT_TRUE(SetupTest("siteA{FencedFrame}(siteB(siteB))")); |
| RunTest(); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(ScrollIntoViewFencedFrameBrowserTest, |
| RemoteFrameInFencedFrame) { |
| ASSERT_TRUE(SetupTest("siteA{FencedFrame}(siteB(siteC))")); |
| |
| // TODO(bokan): This is required due to a race in how page-level focus is |
| // transferred. If the race is won by the page level focus notification then |
| // it'll clobber the <input> focus and reset it to the main frame. In this |
| // case, trying again will work because the fenced frame tree already has |
| // page focus now so focusing it doesn't change page focus. See |
| // https://crbug.com/1327439. |
| { |
| VisualViewport viewport = GetVisualViewport(); |
| double page_scale_factor_before = viewport.scale; |
| |
| EXPECT_TRUE(ExecJs(InnerMostFrameTreeNode(), R"JS( |
| document.querySelector('input').focus({preventScroll: true}); |
| )JS")); |
| |
| // The test should start with fresh scroll and scale. |
| ASSERT_EQ(viewport.scale, page_scale_factor_before); |
| ASSERT_EQ(viewport.page_left, 0); |
| ASSERT_EQ(viewport.page_top, 0); |
| } |
| |
| RunTest(); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(ScrollIntoViewFencedFrameBrowserTest, |
| FencedFrameInRemoteFrame) { |
| ASSERT_TRUE(SetupTest("siteA(siteB{FencedFrame}(siteC))")); |
| RunTest(); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(ScrollIntoViewFencedFrameBrowserTest, |
| ProgrammaticScrollIntoViewDoesntCrossFencedFrame) { |
| ASSERT_TRUE(SetupTest("siteA{FencedFrame}(siteB)")); |
| |
| ScrollRectToVisibleInParentFrameInterceptor interceptor; |
| interceptor.Init(InnerMostFrameTreeNode()->current_frame_host()); |
| |
| ASSERT_EQ(0, EvalJs(InnerMostFrameTreeNode(), "window.scrollX")); |
| ASSERT_EQ(0, EvalJs(InnerMostFrameTreeNode(), "window.scrollY")); |
| ASSERT_TRUE(ExecJs(InnerMostFrameTreeNode(), R"JS( |
| document.querySelector('input').scrollIntoView({ |
| behavior: 'instant', |
| block: 'center', |
| inline: 'center' |
| }) |
| )JS")); |
| ASSERT_LT(0, EvalJs(InnerMostFrameTreeNode(), "window.scrollX")); |
| ASSERT_LT(0, EvalJs(InnerMostFrameTreeNode(), "window.scrollY")); |
| |
| // Since bubbling to a parent frame happens synchronously in scrollIntoView, |
| // once the fenced frame has visible scroll we can guarantee that, if it |
| // tried bubbling the scroll to the parent the message must have been sent to |
| // the browser by now. |
| InnerMostFrameTreeNode() |
| ->current_frame_host() |
| ->local_frame_host_receiver_for_testing() |
| .FlushForTesting(); |
| EXPECT_FALSE(interceptor.HasCalledScrollRectToVisibleInParentFrame()); |
| } |
| |
| } // namespace |
| |
| } // namespace content |