| // 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 "chrome/test/interaction/webcontents_interaction_test_util.h" |
| |
| #include <algorithm> |
| #include <initializer_list> |
| #include <optional> |
| #include <set> |
| #include <sstream> |
| #include <string> |
| |
| #include "base/callback_list.h" |
| #include "base/functional/callback_helpers.h" |
| #include "base/json/json_reader.h" |
| #include "base/json/json_writer.h" |
| #include "base/location.h" |
| #include "base/memory/ptr_util.h" |
| #include "base/memory/raw_ptr.h" |
| #include "base/memory/weak_auto_reset.h" |
| #include "base/memory/weak_ptr.h" |
| #include "base/notreached.h" |
| #include "base/scoped_observation.h" |
| #include "base/strings/string_util.h" |
| #include "base/strings/stringprintf.h" |
| #include "base/time/time.h" |
| #include "base/timer/elapsed_timer.h" |
| #include "base/values.h" |
| #include "build/chromeos_buildflags.h" |
| #include "chrome/browser/ui/browser.h" |
| #include "chrome/browser/ui/browser_finder.h" |
| #include "chrome/browser/ui/browser_list.h" |
| #include "chrome/browser/ui/browser_list_observer.h" |
| #include "chrome/browser/ui/browser_navigator.h" |
| #include "chrome/browser/ui/browser_navigator_params.h" |
| #include "chrome/browser/ui/browser_window.h" |
| #include "chrome/browser/ui/tabs/tab_strip_model_observer.h" |
| #include "chrome/browser/ui/views/frame/browser_view.h" |
| #include "chrome/browser/ui/views/frame/contents_web_view.h" |
| #include "chrome/test/interaction/interaction_test_util_browser.h" |
| #include "chrome/test/interaction/tracked_element_webcontents.h" |
| #include "content/public/browser/navigation_controller.h" |
| #include "content/public/browser/web_contents.h" |
| #include "content/public/test/browser_test_utils.h" |
| #include "ui/base/interaction/element_identifier.h" |
| #include "ui/base/interaction/element_tracker.h" |
| #include "ui/base/interaction/framework_specific_implementation.h" |
| #include "ui/base/page_transition_types.h" |
| #include "ui/base/window_open_disposition.h" |
| #include "ui/gfx/geometry/rect.h" |
| #include "ui/views/controls/webview/webview.h" |
| #include "ui/views/interaction/element_tracker_views.h" |
| #include "ui/views/view_class_properties.h" |
| #include "ui/views/view_observer.h" |
| |
| namespace content { |
| class RenderFrameHost; |
| } |
| |
| namespace { |
| |
| content::WebContents* GetWebContents(Browser* browser, |
| std::optional<int> tab_index) { |
| auto* const model = browser->tab_strip_model(); |
| return model->GetWebContentsAt(tab_index.value_or(model->active_index())); |
| } |
| |
| // Provides a JavaScript skeleton for "does this element exist" queries. |
| // |
| // Will evaluate and return `on_not_found` if 'err?.selector' is valid. |
| // Will evaluate and return `on_found` if 'el' is valid. |
| std::string GetExistsQuery(const char* on_not_found, const char* on_found) { |
| return base::StringPrintf(R"((el, err) => { |
| if (err?.selector) return %s; |
| if (err) throw err; |
| return %s; |
| })", |
| on_not_found, on_found); |
| } |
| |
| // Does `StateChange` validation, including inferring the actual type for |
| // `Type::kAuto`, and returns the (potentially updated) StateChange. |
| WebContentsInteractionTestUtil::StateChange ValidateAndInferStateChange( |
| const WebContentsInteractionTestUtil::StateChange& state_change) { |
| WebContentsInteractionTestUtil::StateChange configuration = state_change; |
| |
| CHECK(configuration.event) << "StateChange missing event - " << configuration; |
| CHECK(configuration.timeout.has_value() || !configuration.timeout_event) |
| << "StateChange cannot specify timeout event without timeout - " |
| << configuration; |
| |
| const bool has_function = !configuration.test_function.empty(); |
| const bool has_where = !configuration.where.empty(); |
| using Type = WebContentsInteractionTestUtil::StateChange::Type; |
| switch (configuration.type) { |
| case Type::kAuto: |
| if (has_function) { |
| configuration.type = |
| has_where ? Type::kExistsAndConditionTrue : Type::kConditionTrue; |
| } else if (has_where) { |
| configuration.type = Type::kExists; |
| } else { |
| NOTREACHED_NORETURN() |
| << "Unable to infer StateChange type - " << configuration; |
| } |
| break; |
| case Type::kExists: |
| CHECK(has_where) << "Expected where to be non-empty - " << configuration; |
| CHECK(!has_function) << "Expected test function to be empty - " |
| << configuration; |
| break; |
| case Type::kDoesNotExist: |
| CHECK(has_where) << "Expected where to be non-empty - " << configuration; |
| CHECK(!has_function) << "Expected test function to be empty - " |
| << configuration; |
| break; |
| case Type::kConditionTrue: |
| CHECK(!has_where) << "Expected where to be empty - " << configuration; |
| CHECK(has_function) << "Expected test function to be non-empty - " |
| << configuration; |
| break; |
| case Type::kExistsAndConditionTrue: |
| CHECK(has_where && has_function) |
| << "Expected where and function to be non-empty - " << configuration; |
| } |
| return configuration; |
| } |
| |
| // Detects the presence of a javascript `function` that takes (el, err) as |
| // parameters, for backwards-compatibility with older tests that require this. |
| // |
| // Expectation is one of: |
| // ... x, y ... => ... |
| // ... x, y ... { ... |
| // |
| // Functions not in this format will not be recognized as taking an error param. |
| bool HasErrorParameter(const std::string& function) { |
| size_t body1 = function.find("=>"); |
| size_t body2 = function.find('{'); |
| const size_t body = |
| (body1 == std::string::npos) |
| ? body2 |
| : (body2 == std::string::npos ? body1 : std::min(body1, body2)); |
| if (body == std::string::npos) { |
| return false; |
| } |
| const size_t comma = function.find(','); |
| return comma != std::string::npos && comma < body; |
| } |
| |
| // Returns the JS query that must be sent to check a particular state change. |
| std::string GetStateChangeQuery( |
| const WebContentsInteractionTestUtil::StateChange& configuration) { |
| // For `kConditionTrue`, `configuration.test_function` can be used directly |
| // directly, but for the other options it must be modified. |
| using Type = WebContentsInteractionTestUtil::StateChange::Type; |
| switch (configuration.type) { |
| case Type::kAuto: |
| NOTREACHED_NORETURN() << "Auto type should already have been inferred."; |
| case Type::kExists: |
| return GetExistsQuery( |
| /* on_not_found = */ "false", |
| /* on_found = */ "true"); |
| case Type::kDoesNotExist: |
| return GetExistsQuery( |
| /* on_not_found = */ "true", |
| /* on_found = */ "false"); |
| case Type::kConditionTrue: |
| return configuration.test_function; |
| case Type::kExistsAndConditionTrue: |
| if (HasErrorParameter(configuration.test_function)) { |
| return configuration.test_function; |
| } |
| const std::string on_found = "(" + configuration.test_function + ")(el)"; |
| return GetExistsQuery( |
| /* on_not_found = */ "false", on_found.c_str()); |
| } |
| } |
| |
| // Common execution code for `EvalJsLocal()` and `ExecuteJsLocal()`. |
| // Executes `script` on `host`. |
| void ExecuteScript(content::RenderFrameHost* host, const std::string& script) { |
| const std::u16string script16 = base::UTF8ToUTF16(script); |
| if (host->GetLifecycleState() != |
| content::RenderFrameHost::LifecycleState::kPrerendering) { |
| host->ExecuteJavaScriptWithUserGestureForTests( |
| script16, base::NullCallback()); // IN-TEST |
| } else { |
| host->ExecuteJavaScriptForTests(script16, base::NullCallback()); // IN-TEST |
| } |
| } |
| |
| // TODO(dfried): migrate to EvalJs, now that it supports Content Security |
| // Policy. |
| content::EvalJsResult EvalJsLocal( |
| const content::ToRenderFrameHost& execution_target, |
| const std::string& function) { |
| content::RenderFrameHost* const host = execution_target.render_frame_host(); |
| content::DOMMessageQueue dom_message_queue(host); |
| |
| // Theoretically, this script, when executed should produce an object with the |
| // following value: |
| // [ <token>, [<result>, <error>] ] |
| // The values <token> and <error> will be strings, while <result> can be any |
| // type. |
| std::string token = |
| "EvalJsLocal-" + base::Uuid::GenerateRandomV4().AsLowercaseString(); |
| std::string runner_script = base::StringPrintf( |
| R"( |
| (() => { |
| const replyFunc = |
| (reply) => window.domAutomationController.send(['%s', reply]); |
| const errorReply = |
| (error) => [undefined, |
| error && error.stack ? |
| '\n' + error.stack : |
| 'Error: "' + error + '"']; |
| try { |
| Promise.resolve((%s)()) |
| .then((result) => [result, ''], |
| (error) => errorReply(error)) |
| .then((result) => replyFunc(result)); |
| } catch (err) { |
| replyFunc(errorReply(err)); |
| } |
| })(); //# sourceURL=EvalJs-runner.js |
| )", |
| token.c_str(), function.c_str()); |
| |
| if (!host->IsRenderFrameLive()) |
| return content::EvalJsResult(base::Value(), "Error: frame has crashed."); |
| |
| // This will queue up a message to be returned from the runner. |
| ExecuteScript(host, runner_script); |
| |
| std::string json; |
| if (!dom_message_queue.WaitForMessage(&json)) |
| return content::EvalJsResult(base::Value(), |
| "Cannot communicate with DOMMessageQueue."); |
| |
| auto parsed_json = base::JSONReader::ReadAndReturnValueWithError( |
| json, base::JSON_ALLOW_TRAILING_COMMAS); |
| if (!parsed_json.has_value()) { |
| return content::EvalJsResult( |
| base::Value(), "JSON parse error: " + parsed_json.error().message); |
| } |
| |
| if (!parsed_json->is_list() || parsed_json->GetList().size() != 2U || |
| !parsed_json->GetList()[1].is_list() || |
| parsed_json->GetList()[1].GetList().size() != 2U || |
| !parsed_json->GetList()[1].GetList()[1].is_string() || |
| parsed_json->GetList()[0].GetString() != token) { |
| std::ostringstream error_message; |
| error_message << "Received unexpected result: " << *parsed_json; |
| return content::EvalJsResult(base::Value(), error_message.str()); |
| } |
| auto& result = parsed_json->GetList()[1].GetList(); |
| |
| return content::EvalJsResult(std::move(result[0]), result[1].GetString()); |
| } |
| |
| // As EvalJsLocal but does not wait for a response; errors will appear in the |
| // test log. |
| void ExecuteJsLocal(const content::ToRenderFrameHost& execution_target, |
| const std::string& function) { |
| content::RenderFrameHost* const host = execution_target.render_frame_host(); |
| CHECK(host->IsRenderFrameLive()); |
| std::string runner_script = base::StringPrintf("(%s)();", function.c_str()); |
| ExecuteScript(host, runner_script); |
| } |
| |
| std::string CreateDeepQuery( |
| const WebContentsInteractionTestUtil::DeepQuery& where, |
| const std::string& function) { |
| DCHECK(!function.empty()); |
| |
| // Safely convert the selector list in `where` to a JSON/JS list. |
| base::Value::List selector_list; |
| for (const auto& selector : where) |
| selector_list.Append(selector); |
| std::string selectors; |
| CHECK(base::JSONWriter::Write(selector_list, &selectors)); |
| |
| return base::StringPrintf( |
| R"(function() { |
| function deepQuery(selectors) { |
| let cur = document; |
| for (let selector of selectors) { |
| if (cur.shadowRoot) { |
| cur = cur.shadowRoot; |
| } |
| cur = cur.querySelector(selector); |
| if (!cur) { |
| const err = new Error('Selector not found: ' + selector); |
| err.selector = selector; |
| throw err; |
| } |
| } |
| return cur; |
| } |
| |
| let el, err; |
| try { |
| el = deepQuery(%s); |
| } catch (error) { |
| err = error; |
| } |
| |
| const func = (%s); |
| if (err && func.length <= 1) { |
| throw err; |
| } |
| return func(el, err); |
| })", |
| selectors.c_str(), function.c_str()); |
| } |
| |
| } // namespace |
| |
| WebContentsInteractionTestUtil::DeepQuery::DeepQuery() = default; |
| WebContentsInteractionTestUtil::DeepQuery::DeepQuery( |
| std::initializer_list<std::string> segments) |
| : segments_(segments) {} |
| WebContentsInteractionTestUtil::DeepQuery::DeepQuery( |
| const WebContentsInteractionTestUtil::DeepQuery& other) = default; |
| WebContentsInteractionTestUtil::DeepQuery& |
| WebContentsInteractionTestUtil::DeepQuery::operator=( |
| const WebContentsInteractionTestUtil::DeepQuery& other) = default; |
| WebContentsInteractionTestUtil::DeepQuery& |
| WebContentsInteractionTestUtil::DeepQuery::operator=( |
| std::initializer_list<std::string> segments) { |
| segments_ = segments; |
| return *this; |
| } |
| WebContentsInteractionTestUtil::DeepQuery |
| WebContentsInteractionTestUtil::DeepQuery::operator+( |
| const std::string& segment) const { |
| DeepQuery result(*this); |
| result.segments_.emplace_back(segment); |
| return result; |
| } |
| WebContentsInteractionTestUtil::DeepQuery::~DeepQuery() = default; |
| |
| WebContentsInteractionTestUtil::StateChange::StateChange() = default; |
| WebContentsInteractionTestUtil::StateChange::StateChange( |
| const WebContentsInteractionTestUtil::StateChange& other) = default; |
| WebContentsInteractionTestUtil::StateChange& |
| WebContentsInteractionTestUtil::StateChange::operator=( |
| const WebContentsInteractionTestUtil::StateChange& other) = default; |
| WebContentsInteractionTestUtil::StateChange::~StateChange() = default; |
| |
| class WebContentsInteractionTestUtil::NewTabWatcher |
| : public TabStripModelObserver, |
| public BrowserListObserver { |
| public: |
| NewTabWatcher(WebContentsInteractionTestUtil* owner, Browser* browser) |
| : owner_(owner), browser_(browser) { |
| if (browser_) { |
| browser_->tab_strip_model()->AddObserver(this); |
| } else { |
| BrowserList::GetInstance()->AddObserver(this); |
| for (Browser* const open_browser : *BrowserList::GetInstance()) |
| open_browser->tab_strip_model()->AddObserver(this); |
| } |
| } |
| |
| ~NewTabWatcher() override { |
| BrowserList::GetInstance()->RemoveObserver(this); |
| } |
| |
| Browser* browser() { return browser_; } |
| |
| private: |
| // BrowserListObserver: |
| void OnBrowserAdded(Browser* browser) override { |
| CHECK(!browser_); |
| browser->tab_strip_model()->AddObserver(this); |
| } |
| |
| void OnBrowserRemoved(Browser* browser) override { CHECK(!browser_); } |
| |
| // TabStripModelObserver: |
| void OnTabStripModelChanged( |
| TabStripModel* tab_strip_model, |
| const TabStripModelChange& change, |
| const TabStripSelectionChange& selection) override { |
| if (change.type() != TabStripModelChange::Type::kInserted) |
| return; |
| |
| auto* const web_contents = |
| change.GetInsert()->contents.front().contents.get(); |
| CHECK(!browser_ || browser_ == chrome::FindBrowserWithTab(web_contents)); |
| owner_->StartWatchingWebContents(web_contents); |
| } |
| |
| const raw_ptr<WebContentsInteractionTestUtil> owner_; |
| const raw_ptr<Browser> browser_; |
| }; |
| |
| class WebContentsInteractionTestUtil::Poller { |
| public: |
| Poller(WebContentsInteractionTestUtil* const owner, StateChange state_change) |
| : state_change_(std::move(state_change)), |
| js_query_(GetStateChangeQuery(state_change_)), |
| owner_(owner) {} |
| |
| ~Poller() = default; |
| |
| void StartPolling() { |
| CHECK(!timer_.IsRunning()); |
| timer_.Start(FROM_HERE, state_change_.polling_interval, |
| base::BindRepeating(&Poller::Poll, base::Unretained(this))); |
| } |
| |
| const StateChange& state_change() const { return state_change_; } |
| |
| private: |
| void Poll() { |
| // Callback can get called again if Evaluate() below stalls. We don't want |
| // to stack callbacks because of issues with message passing to/from web |
| // contents. |
| if (is_polling_) |
| return; |
| |
| // If there is no page loaded, then there is nothing to poll. |
| if (!owner_->is_page_loaded()) { |
| CHECK(state_change_.continue_across_navigation) |
| << "Page discarded waiting for StateChange event " |
| << state_change_.event; |
| return; |
| } |
| |
| auto weak_ptr = weak_factory_.GetWeakPtr(); |
| base::WeakAutoReset is_polling_auto_reset(weak_ptr, &Poller::is_polling_, |
| true); |
| |
| const base::Value result = |
| state_change_.where.empty() |
| ? owner_->Evaluate(js_query_) |
| : owner_->EvaluateAt(state_change_.where, js_query_); |
| |
| // At this point, weak_ptr might be invalid since we could have been deleted |
| // while we were waiting for Evaluate[At]() to complete. |
| if (weak_ptr) { |
| if (IsTruthy(result)) { |
| owner_->OnPollEvent(this, state_change_.event); |
| } else if (state_change_.timeout.has_value() && |
| elapsed_.Elapsed() > state_change_.timeout.value()) { |
| owner_->OnPollEvent(this, state_change_.timeout_event); |
| } |
| } |
| } |
| |
| const base::ElapsedTimer elapsed_; |
| const StateChange state_change_; |
| const std::string js_query_; |
| const raw_ptr<WebContentsInteractionTestUtil> owner_; |
| base::RepeatingTimer timer_; |
| bool is_polling_ = false; |
| base::WeakPtrFactory<Poller> weak_factory_{this}; |
| }; |
| |
| // Class that tracks a WebView and its WebContents in a secondary UI. |
| class WebContentsInteractionTestUtil::WebViewData : public views::ViewObserver { |
| public: |
| WebViewData(WebContentsInteractionTestUtil* owner, views::WebView* web_view) |
| : owner_(owner), web_view_(web_view) {} |
| ~WebViewData() override { |
| EXPECT_FALSE(minimum_size_data_) |
| << "Minimum size " << minimum_size_data_->webview_size.ToString() |
| << " never reached; event never sent: " |
| << minimum_size_data_->event_type; |
| } |
| |
| // Separate init is required from construction so that the util object that |
| // owns this object can store a pointer before any calls back to the util |
| // object are performed. |
| void Init() { |
| scoped_observation_.Observe(web_view_); |
| web_contents_attached_subscription_ = |
| web_view_->AddWebContentsAttachedCallback(base::BindRepeating( |
| &WebViewData::OnWebContentsAttached, base::Unretained(this))); |
| ui::ElementIdentifier id = |
| web_view_->GetProperty(views::kElementIdentifierKey); |
| if (!id) { |
| id = ui::ElementTracker::kTemporaryIdentifier; |
| web_view_->SetProperty(views::kElementIdentifierKey, id); |
| } |
| context_ = views::ElementTrackerViews::GetContextForView(web_view_); |
| CHECK(context_); |
| |
| shown_subscription_ = |
| ui::ElementTracker::GetElementTracker()->AddElementShownCallback( |
| id, context_, |
| base::BindRepeating(&WebViewData::OnElementShown, |
| base::Unretained(this))); |
| hidden_subscription_ = |
| ui::ElementTracker::GetElementTracker()->AddElementHiddenCallback( |
| id, context_, |
| base::BindRepeating(&WebViewData::OnElementHidden, |
| base::Unretained(this))); |
| |
| if (auto* const element = |
| views::ElementTrackerViews::GetInstance()->GetElementForView( |
| web_view_)) { |
| OnElementShown(element); |
| } |
| } |
| |
| void SendEventOnMinimumSize(const gfx::Size& minimum_webview_size, |
| ui::CustomElementEventType event_type, |
| const DeepQuery& element_to_check, |
| const gfx::Size& minimum_element_size) { |
| CHECK(!minimum_size_data_) |
| << "Already have a pending minimum webview size with event " |
| << minimum_size_data_->event_type; |
| CHECK(!minimum_webview_size.IsEmpty()); |
| CHECK(element_to_check.empty() || !minimum_element_size.IsEmpty()); |
| |
| minimum_size_data_ = std::make_unique<MinimumSizeData>(); |
| minimum_size_data_->webview_size = minimum_webview_size; |
| minimum_size_data_->event_type = event_type; |
| minimum_size_data_->element = element_to_check; |
| minimum_size_data_->element_size = minimum_element_size; |
| |
| // If the WebView already meets the minimum size, queue the event now. |
| if (Contains(minimum_webview_size, web_view_->size())) |
| QueueMinimumSizeEvent(); |
| } |
| |
| ui::ElementContext context() const { return context_; } |
| |
| bool visible() const { return visible_; } |
| |
| views::WebView* web_view() const { return web_view_; } |
| |
| private: |
| struct MinimumSizeData { |
| ui::CustomElementEventType event_type; |
| gfx::Size webview_size; |
| DeepQuery element; |
| gfx::Size element_size; |
| }; |
| |
| void OnElementShown(ui::TrackedElement* element) { |
| if (visible_) |
| return; |
| auto* el = element->AsA<views::TrackedElementViews>(); |
| if (!el || el->view() != web_view_) |
| return; |
| visible_ = true; |
| owner_->Observe(web_view_->web_contents()); |
| owner_->MaybeCreateElement(); |
| } |
| |
| void OnElementHidden(ui::TrackedElement* element) { |
| if (!visible_) |
| return; |
| auto* el = element->AsA<views::TrackedElementViews>(); |
| if (!el || el->view() != web_view_) |
| return; |
| visible_ = false; |
| owner_->Observe(nullptr); |
| owner_->DiscardCurrentElement(); |
| } |
| |
| // views::ViewObserver: |
| void OnViewIsDeleting(views::View* view) override { |
| visible_ = false; |
| web_view_ = nullptr; |
| shown_subscription_ = ui::ElementTracker::Subscription(); |
| hidden_subscription_ = ui::ElementTracker::Subscription(); |
| scoped_observation_.Reset(); |
| owner_->Observe(nullptr); |
| owner_->DiscardCurrentElement(); |
| } |
| |
| void OnViewBoundsChanged(views::View* observed_view) override { |
| if (!minimum_size_data_) |
| return; |
| if (Contains(minimum_size_data_->webview_size, observed_view->size())) |
| QueueMinimumSizeEvent(); |
| } |
| |
| void OnWebContentsAttached(views::WebView* observed_view) { |
| CHECK_EQ(web_view_.get(), observed_view); |
| content::WebContents* const to_observe = |
| visible_ ? observed_view->web_contents() : nullptr; |
| if (owner_->web_contents() == to_observe) { |
| return; |
| } |
| owner_->Observe(to_observe); |
| owner_->DiscardCurrentElement(); |
| owner_->MaybeCreateElement(); |
| } |
| |
| void QueueMinimumSizeEvent() { |
| if (!owner_->current_element_) |
| return; |
| |
| // This clears the current data, allowing us to queue another minimum size |
| // event. |
| std::unique_ptr<MinimumSizeData> data = std::move(minimum_size_data_); |
| |
| // The final step is to poke the WebView to determine when the target |
| // element (or page, if one has not been specified) has actually been |
| // rendered at a nonzero size. |
| owner_->SendEventOnElementMinimumSize(data->event_type, data->element, |
| data->element_size, |
| /* must_already_exist =*/false); |
| } |
| |
| static bool Contains(const gfx::Size& bounds, const gfx::Size& size) { |
| return bounds.height() <= size.height() && bounds.width() <= size.width(); |
| } |
| |
| const raw_ptr<WebContentsInteractionTestUtil> owner_; |
| raw_ptr<views::WebView> web_view_; |
| bool visible_ = false; |
| ui::ElementContext context_; |
| ui::ElementTracker::Subscription shown_subscription_; |
| ui::ElementTracker::Subscription hidden_subscription_; |
| std::unique_ptr<MinimumSizeData> minimum_size_data_; |
| base::ScopedObservation<views::View, views::ViewObserver> scoped_observation_{ |
| this}; |
| base::CallbackListSubscription web_contents_attached_subscription_; |
| base::WeakPtrFactory<WebViewData> weak_factory_{this}; |
| }; |
| |
| // static |
| constexpr base::TimeDelta |
| WebContentsInteractionTestUtil::kDefaultPollingInterval; |
| |
| WebContentsInteractionTestUtil::~WebContentsInteractionTestUtil() { |
| // Stop observing before eliminating the element, as a callback could cascade |
| // into additional events. |
| new_tab_watcher_.reset(); |
| Observe(nullptr); |
| pollers_.clear(); |
| } |
| |
| // static |
| bool WebContentsInteractionTestUtil::IsTruthy(const base::Value& value) { |
| using Type = base::Value::Type; |
| switch (value.type()) { |
| case Type::BOOLEAN: |
| return value.GetBool(); |
| case Type::INTEGER: |
| return value.GetInt() != 0; |
| case Type::DOUBLE: |
| // Note: this should probably also include handling of NaN, but |
| // base::Value itself cannot handle NaN values because JSON cannot. |
| return value.GetDouble() != 0.0; |
| case Type::BINARY: |
| return true; |
| case Type::DICT: |
| return true; |
| case Type::LIST: |
| return true; |
| case Type::STRING: |
| return !value.GetString().empty(); |
| case Type::NONE: |
| return false; |
| } |
| } |
| |
| // static |
| std::unique_ptr<WebContentsInteractionTestUtil> |
| WebContentsInteractionTestUtil::ForExistingTabInContext( |
| ui::ElementContext context, |
| ui::ElementIdentifier page_identifier, |
| std::optional<int> tab_index) { |
| return ForExistingTabInBrowser( |
| InteractionTestUtilBrowser::GetBrowserFromContext(context), |
| page_identifier, tab_index); |
| } |
| |
| // static |
| std::unique_ptr<WebContentsInteractionTestUtil> |
| WebContentsInteractionTestUtil::ForExistingTabInBrowser( |
| Browser* browser, |
| ui::ElementIdentifier page_identifier, |
| std::optional<int> tab_index) { |
| return ForTabWebContents(GetWebContents(browser, tab_index), page_identifier); |
| } |
| |
| // static |
| std::unique_ptr<WebContentsInteractionTestUtil> |
| WebContentsInteractionTestUtil::ForTabWebContents( |
| content::WebContents* web_contents, |
| ui::ElementIdentifier page_identifier) { |
| return base::WrapUnique(new WebContentsInteractionTestUtil( |
| web_contents, page_identifier, std::nullopt, nullptr)); |
| } |
| |
| // static |
| std::unique_ptr<WebContentsInteractionTestUtil> |
| WebContentsInteractionTestUtil::ForNonTabWebView( |
| views::WebView* web_view, |
| ui::ElementIdentifier page_identifier) { |
| return base::WrapUnique(new WebContentsInteractionTestUtil( |
| web_view->GetWebContents(), page_identifier, std::nullopt, web_view)); |
| } |
| |
| // static |
| std::unique_ptr<WebContentsInteractionTestUtil> |
| WebContentsInteractionTestUtil::ForNextTabInContext( |
| ui::ElementContext context, |
| ui::ElementIdentifier page_identifier) { |
| Browser* const browser = |
| InteractionTestUtilBrowser::GetBrowserFromContext(context); |
| return ForNextTabInBrowser(browser, page_identifier); |
| } |
| |
| // static |
| std::unique_ptr<WebContentsInteractionTestUtil> |
| WebContentsInteractionTestUtil::ForNextTabInBrowser( |
| Browser* browser, |
| ui::ElementIdentifier page_identifier) { |
| CHECK(browser); |
| return base::WrapUnique(new WebContentsInteractionTestUtil( |
| nullptr, page_identifier, browser, nullptr)); |
| } |
| |
| // static |
| std::unique_ptr<WebContentsInteractionTestUtil> |
| WebContentsInteractionTestUtil::ForNextTabInAnyBrowser( |
| ui::ElementIdentifier page_identifier) { |
| return base::WrapUnique(new WebContentsInteractionTestUtil( |
| nullptr, page_identifier, nullptr, nullptr)); |
| } |
| |
| views::WebView* WebContentsInteractionTestUtil::GetWebView() { |
| if (web_view_data_) |
| return web_view_data_->web_view(); |
| |
| if (!current_element_) |
| return nullptr; |
| |
| Browser* const browser = InteractionTestUtilBrowser::GetBrowserFromContext( |
| current_element_->context()); |
| BrowserView* const browser_view = |
| BrowserView::GetBrowserViewForBrowser(browser); |
| CHECK(browser_view); |
| if (web_contents() != browser_view->GetActiveWebContents()) |
| return nullptr; |
| |
| return browser_view->contents_web_view(); |
| } |
| |
| void WebContentsInteractionTestUtil::LoadPage(const GURL& url) { |
| CHECK(web_contents()); |
| if (!web_contents()->GetURL().EqualsIgnoringRef(url)) { |
| navigating_away_from_ = web_contents()->GetURL(); |
| DiscardCurrentElement(); |
| } |
| if (url.SchemeIs("chrome") || web_view_data_) { |
| // Secure pages and non-tab WebViews must be navigated via the controller. |
| content::NavigationController::LoadURLParams params(url); |
| CHECK(web_contents()->GetController().LoadURLWithParams(params)); |
| } else { |
| // Regular web pages can be navigated directly. |
| // |
| // In an ideal world, this should use `BeginNavigateToURLFromRenderer()`, |
| // which verifies that the navigation successfully starts. However, |
| // `BeginNavigateToURLFromRenderer()` itself uses a RunLoop to listen for |
| // the navigation starting. |
| // |
| // For reasons that are not well understood, this is problematic when used |
| // in conjunction with the interaction sequence test utils, which often |
| // run the entire test inside a top-level RunLoop; the now nested RunLoop |
| // inside `BeginNavigateToURLFromRenderer()` never receives the |
| // `DidStartNavigation()` callback, and the test just ends up hanging. |
| // |
| // Use Execute() as a workaround this hang. Note that unlike the |
| // similarly-named `content::ExecJs()`, this helper does not actually |
| // validate or wait for the script to execute; hopefully, errors from |
| // navigation failures will be obvious enough in subsequent steps. |
| ExecuteJsLocal(web_contents(), content::JsReplace("location = $1", url)); |
| } |
| } |
| |
| void WebContentsInteractionTestUtil::LoadPageInNewTab(const GURL& url, |
| bool activate_tab) { |
| // We use tertiary operator rather than value_or to avoid failing if we're in |
| // a wait state. |
| Browser* browser = new_tab_watcher_ |
| ? new_tab_watcher_->browser() |
| : chrome::FindBrowserWithTab(web_contents()); |
| CHECK(browser); |
| NavigateParams navigate_params(browser, url, ui::PAGE_TRANSITION_TYPED); |
| navigate_params.disposition = activate_tab |
| ? WindowOpenDisposition::NEW_FOREGROUND_TAB |
| : WindowOpenDisposition::NEW_BACKGROUND_TAB; |
| auto navigate_result = Navigate(&navigate_params); |
| CHECK(navigate_result); |
| } |
| |
| base::Value WebContentsInteractionTestUtil::Evaluate( |
| const std::string& function, |
| std::string* error_message) { |
| CHECK(is_page_loaded()); |
| auto result = EvalJsLocal(web_contents(), function); |
| if (!result.error.empty()) { |
| if (error_message) { |
| *error_message = result.error; |
| return base::Value(); |
| } else { |
| NOTREACHED_NORETURN() << "Uncaught JS exception: " << result.error; |
| } |
| } |
| |
| // Despite the fact that EvalJsResult::value is const, base::Value in general |
| // is moveable and nothing special is done on EvalJsResult destructor, which |
| // means it's safe to const-cast and move the value out of the struct. |
| auto& value = const_cast<base::Value&>(result.value); |
| |
| return std::move(value); |
| } |
| |
| void WebContentsInteractionTestUtil::Execute(const std::string& function) { |
| CHECK(is_page_loaded()); |
| ExecuteJsLocal(web_contents(), function); |
| } |
| |
| void WebContentsInteractionTestUtil::SendEventOnElementMinimumSize( |
| ui::CustomElementEventType event_type, |
| const DeepQuery& where, |
| const gfx::Size& minimum_size, |
| bool must_already_exist) { |
| DCHECK(!minimum_size.IsEmpty()); |
| StateChange change; |
| change.event = event_type; |
| change.type = must_already_exist ? StateChange::Type::kConditionTrue |
| : StateChange::Type::kExistsAndConditionTrue; |
| change.where = where; |
| change.test_function = |
| base::StringPrintf(R"( |
| el => { |
| const rect = el.getBoundingClientRect(); |
| return rect.width >= %i && rect.height >= %i; |
| } |
| )", |
| minimum_size.width(), minimum_size.height()); |
| SendEventOnStateChange(change); |
| } |
| |
| void WebContentsInteractionTestUtil::SendEventOnStateChange( |
| const StateChange& configuration) { |
| CHECK(current_element_); |
| |
| auto actual_config = ValidateAndInferStateChange(configuration); |
| const auto& poller = pollers_.emplace_back( |
| std::make_unique<Poller>(this, std::move(actual_config))); |
| poller->StartPolling(); |
| } |
| |
| bool WebContentsInteractionTestUtil::Exists(const DeepQuery& query, |
| std::string* not_found) { |
| const std::string full_query = |
| CreateDeepQuery(query, GetExistsQuery("err.selector", "''")); |
| const std::string result = Evaluate(full_query).GetString(); |
| if (not_found) |
| *not_found = result; |
| return result.empty(); |
| } |
| |
| base::Value WebContentsInteractionTestUtil::EvaluateAt( |
| const DeepQuery& where, |
| const std::string& function, |
| std::string* error_message) { |
| const std::string full_query = CreateDeepQuery(where, function); |
| return Evaluate(full_query, error_message); |
| } |
| |
| void WebContentsInteractionTestUtil::ExecuteAt(const DeepQuery& where, |
| const std::string& function) { |
| const std::string full_query = CreateDeepQuery(where, function); |
| Execute(full_query); |
| } |
| |
| bool WebContentsInteractionTestUtil::Exists(const std::string& selector) { |
| return Exists(DeepQuery{selector}); |
| } |
| |
| base::Value WebContentsInteractionTestUtil::EvaluateAt( |
| const std::string& selector, |
| const std::string& function) { |
| return EvaluateAt(DeepQuery{selector}, function); |
| } |
| |
| void WebContentsInteractionTestUtil::ExecuteAt(const std::string& selector, |
| const std::string& function) { |
| ExecuteAt(DeepQuery{selector}, function); |
| } |
| |
| gfx::Rect WebContentsInteractionTestUtil::GetElementBoundsInScreen( |
| const DeepQuery& where) { |
| if (!current_element_) |
| return gfx::Rect(); |
| |
| views::WebView* web_view = nullptr; |
| if (web_view_data_) { |
| DCHECK(web_view_data_->visible() && web_view_data_->web_view()); |
| web_view = web_view_data_->web_view(); |
| } else { |
| Browser* const browser = chrome::FindBrowserWithTab(web_contents()); |
| if (!browser || |
| web_contents() != browser->tab_strip_model()->GetActiveWebContents()) { |
| return gfx::Rect(); |
| } |
| web_view = |
| BrowserView::GetBrowserViewForBrowser(browser)->contents_web_view(); |
| } |
| CHECK(web_view); |
| |
| // TODO(dfried): Screen bounds returned by GetBoundsInScreen() are in DIPs. |
| // We are also assuming that Element.getBoundingClientRect() also returns a |
| // value in DIPs (this seems to be borne out by anecdotal evidence in online |
| // discussions). However, if that's not the case, either the offset or element |
| // bounds will need to be adjusted by the current display's scale factor. |
| const gfx::Point offset = web_view->GetBoundsInScreen().origin(); |
| |
| const base::Value result = EvaluateAt(where, |
| R"(el => { |
| const rect = el.getBoundingClientRect(); |
| return { |
| "x": rect.x, |
| "y": rect.y, |
| "w": rect.width, |
| "h": rect.height |
| }; |
| })"); |
| |
| // This will crash if any of the values are not found, however, since this is |
| // test code that's fine; it *should* crash the test. |
| const auto& dict = result.GetDict(); |
| gfx::Rect element_bounds( |
| dict.Find("x")->GetDouble(), dict.Find("y")->GetDouble(), |
| dict.Find("w")->GetDouble(), dict.Find("h")->GetDouble()); |
| |
| element_bounds.Offset(offset.x(), offset.y()); |
| return element_bounds; |
| } |
| |
| gfx::Rect WebContentsInteractionTestUtil::GetElementBoundsInScreen( |
| const std::string& where) { |
| return GetElementBoundsInScreen(DeepQuery{where}); |
| } |
| |
| void WebContentsInteractionTestUtil::SendEventOnWebViewMinimumSize( |
| const gfx::Size& minimum_webview_size, |
| ui::CustomElementEventType event_type, |
| const DeepQuery& element_to_check, |
| const gfx::Size& minimum_element_size) { |
| CHECK(web_view_data_) |
| << "Only supported for util objects created with ForNonTabWebView()"; |
| web_view_data_->SendEventOnMinimumSize( |
| minimum_webview_size, event_type, element_to_check, minimum_element_size); |
| } |
| |
| void WebContentsInteractionTestUtil::DidStopLoading() { |
| // In some cases we will not have an "on load complete" event, so ensure that |
| // we check for page fully loaded in other callbacks. |
| MaybeCreateElement(); |
| } |
| |
| void WebContentsInteractionTestUtil::DidFinishLoad( |
| content::RenderFrameHost* render_frame_host, |
| const GURL& validated_url) { |
| // In some cases we will not have an "on load complete" event, so ensure that |
| // we check for page fully loaded in other callbacks. |
| MaybeCreateElement(); |
| } |
| |
| void WebContentsInteractionTestUtil:: |
| DocumentOnLoadCompletedInPrimaryMainFrame() { |
| // Even if the page is still "loading" it should be ready for interaction at |
| // this point. Note that in some cases we won't receive this event, which is |
| // why we also check at DidStopLoading() and DidFinishLoad(). |
| MaybeCreateElement(/*force =*/true); |
| } |
| |
| void WebContentsInteractionTestUtil::PrimaryPageChanged(content::Page& page) { |
| DiscardCurrentElement(); |
| } |
| |
| void WebContentsInteractionTestUtil::WebContentsDestroyed() { |
| DiscardCurrentElement(); |
| } |
| |
| void WebContentsInteractionTestUtil::OnTabStripModelChanged( |
| TabStripModel* tab_strip_model, |
| const TabStripModelChange& change, |
| const TabStripSelectionChange& selection) { |
| // Don't bother processing if we don't have a target WebContents. |
| if (!web_contents()) |
| return; |
| |
| // Ensure that if a tab is moved to another browser, we track that move. |
| if (change.type() == TabStripModelChange::Type::kRemoved) { |
| for (auto& removed_tab : change.GetRemove()->contents) { |
| if (removed_tab.contents != web_contents()) |
| continue; |
| // We won't handle deleted reason here, since we already capture |
| // WebContentsDestroyed(). |
| if (removed_tab.remove_reason == |
| TabStripModelChange::RemoveReason::kInsertedIntoOtherTabStrip) { |
| DiscardCurrentElement(); |
| Observe(nullptr); |
| } |
| } |
| } else if (change.type() == TabStripModelChange::Type::kReplaced) { |
| auto* const replace = change.GetReplace(); |
| if (web_contents() == replace->old_contents) { |
| DiscardCurrentElement(); |
| Observe(replace->new_contents); |
| MaybeCreateElement(false); |
| } |
| } |
| } |
| |
| WebContentsInteractionTestUtil::WebContentsInteractionTestUtil( |
| content::WebContents* web_contents, |
| ui::ElementIdentifier page_identifier, |
| std::optional<Browser*> browser, |
| views::WebView* web_view) |
| : WebContentsObserver(web_contents), page_identifier_(page_identifier) { |
| CHECK(page_identifier); |
| |
| if (web_view) { |
| // This is specifically for a web view that is not a tab. |
| CHECK(web_contents); |
| CHECK(!browser); |
| CHECK(!chrome::FindBrowserWithTab(web_contents)); |
| web_view_data_ = std::make_unique<WebViewData>(this, web_view); |
| web_view_data_->Init(); |
| } else if (browser.has_value()) { |
| // Watching for a new tab. |
| CHECK(!web_contents); |
| new_tab_watcher_ = std::make_unique<NewTabWatcher>(this, browser.value()); |
| } else { |
| // This has to be a tab, so use standard watching logic. |
| StartWatchingWebContents(web_contents); |
| } |
| } |
| |
| void WebContentsInteractionTestUtil::MaybeCreateElement(bool force) { |
| if (current_element_ || !web_contents()) |
| return; |
| |
| if (!force && !web_contents()->IsDocumentOnLoadCompletedInPrimaryMainFrame()) |
| return; |
| |
| ui::ElementContext context = ui::ElementContext(); |
| if (web_view_data_) { |
| if (!web_view_data_->visible()) |
| return; |
| context = web_view_data_->context(); |
| } else { |
| Browser* const browser = chrome::FindBrowserWithTab(web_contents()); |
| if (!browser) |
| return; |
| context = browser->window()->GetElementContext(); |
| } |
| |
| // Ignore events on a page we're navigating away from. |
| if (navigating_away_from_ && |
| navigating_away_from_->EqualsIgnoringRef(web_contents()->GetURL())) { |
| return; |
| } |
| navigating_away_from_.reset(); |
| |
| current_element_ = std::make_unique<TrackedElementWebContents>( |
| page_identifier_, context, this); |
| |
| // Init (send shown event, etc.) after current_element_ is set in order to |
| // ensure that is_page_loaded() is true during any callbacks. |
| current_element_->Init(); |
| } |
| |
| void WebContentsInteractionTestUtil::DiscardCurrentElement() { |
| current_element_.reset(); |
| for (const auto& poller : pollers_) { |
| CHECK(poller->state_change().continue_across_navigation) |
| << "Unexpectedly left page while still waiting for StateChange event " |
| << poller->state_change().event; |
| } |
| } |
| |
| void WebContentsInteractionTestUtil::OnPollEvent( |
| Poller* poller, |
| ui::CustomElementEventType event) { |
| CHECK(current_element_) |
| << "StateChange succeeded (or failed) while no page was loaded; " |
| "this is always an error even if continue_across_navigation is true."; |
| const auto it = |
| std::find_if(pollers_.begin(), pollers_.end(), |
| [poller](const auto& ptr) { return ptr.get() == poller; }); |
| CHECK(it != pollers_.end()); |
| pollers_.erase(it); |
| if (event) { |
| ui::ElementTracker::GetFrameworkDelegate()->NotifyCustomEvent( |
| current_element_.get(), event); |
| } |
| } |
| |
| void WebContentsInteractionTestUtil::StartWatchingWebContents( |
| content::WebContents* web_contents) { |
| DCHECK(web_contents); |
| Browser* const browser = chrome::FindBrowserWithTab(web_contents); |
| CHECK(browser); |
| browser->tab_strip_model()->AddObserver(this); |
| if (new_tab_watcher_) { |
| new_tab_watcher_.reset(); |
| Observe(web_contents); |
| } |
| MaybeCreateElement(); |
| } |
| |
| void PrintTo(const WebContentsInteractionTestUtil::DeepQuery& deep_query, |
| std::ostream* os) { |
| *os << "{ \"" << base::JoinString(deep_query.segments_, "\", \"") << "\" }"; |
| } |
| |
| extern std::ostream& operator<<( |
| std::ostream& os, |
| const WebContentsInteractionTestUtil::DeepQuery& deep_query) { |
| PrintTo(deep_query, &os); |
| return os; |
| } |
| |
| void PrintTo(const WebContentsInteractionTestUtil::StateChange& state_change, |
| std::ostream* os) { |
| using Type = WebContentsInteractionTestUtil::StateChange::Type; |
| *os << "{ "; |
| switch (state_change.type) { |
| case Type::kAuto: |
| *os << "kAuto"; |
| break; |
| case Type::kExists: |
| *os << "kExists"; |
| break; |
| case Type::kExistsAndConditionTrue: |
| *os << "kExistsAndConditionTrue"; |
| break; |
| case Type::kConditionTrue: |
| *os << "kConditionTrue"; |
| break; |
| case Type::kDoesNotExist: |
| *os << "kDoesNotExist"; |
| break; |
| } |
| |
| *os << ", test_function: \"" << state_change.test_function << "\"" |
| << ", where: " << state_change.where << ", event: " << state_change.event |
| << ", continue_across_navigation: " |
| << (state_change.continue_across_navigation ? "true" : "false") |
| << ", timeout: " << state_change.timeout.value_or(base::TimeDelta()) |
| << ", timeout_event: " << state_change.timeout_event << " }"; |
| } |
| |
| extern std::ostream& operator<<( |
| std::ostream& os, |
| const WebContentsInteractionTestUtil::StateChange& state_change) { |
| PrintTo(state_change, &os); |
| return os; |
| } |