blob: 7e2f43bfe0a162403d8e71222fc0c0b0b7c631e3 [file]
// 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 "ui/base/interaction/interactive_test_internal.h"
#include <cstdlib>
#include <iterator>
#include <memory>
#include <ostream>
#include <sstream>
#include <string_view>
#include <variant>
#include <vector>
#include "base/callback_list.h"
#include "base/check.h"
#include "base/containers/adapters.h"
#include "base/containers/map_util.h"
#include "base/functional/bind.h"
#include "base/strings/string_util.h"
#include "base/strings/stringprintf.h"
#include "base/types/pass_key.h"
#include "build/build_config.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/abseil-cpp/absl/functional/overload.h"
#include "ui/base/interaction/element_identifier.h"
#include "ui/base/interaction/element_test_util.h"
#include "ui/base/interaction/element_tracker.h"
#include "ui/base/interaction/framework_specific_implementation.h"
#include "ui/gfx/native_ui_types.h"
#if BUILDFLAG(IS_MAC)
#include "ui/base/interaction/interaction_test_util_mac.h"
#elif BUILDFLAG(IS_ANDROID)
#include "ui/android/window_android.h"
#elif USE_AURA
#include "ui/aura/window.h"
#endif
#if !BUILDFLAG(IS_IOS)
#include "ui/native_window_tracker/native_window_tracker.h"
#endif
namespace ui::test::internal {
namespace {
// Basic implementation of the framework for dumping generic elements.
class InteractiveTestPrivateFrameworkImpl
: public InteractiveTestPrivateFrameworkBase {
public:
DECLARE_FRAMEWORK_SPECIFIC_METADATA()
explicit InteractiveTestPrivateFrameworkImpl(
InteractiveTestPrivate& test_impl)
: InteractiveTestPrivateFrameworkBase(test_impl) {}
~InteractiveTestPrivateFrameworkImpl() override = default;
std::vector<DebugTreeNode> DebugDumpElements(
std::set<const ui::TrackedElement*>& elements) const override {
std::vector<DebugTreeNode> nodes;
for (auto* el : elements) {
if (el->identifier() == kInteractiveTestPivotElementId) {
nodes.insert(nodes.begin(),
DebugTreeNode("Pivot element (part of test automation)"));
} else {
nodes.emplace_back(
base::StringPrintf("%s - %s at %s", el->GetImplementationName(),
el->identifier().GetName().c_str(),
DebugDumpBounds(el->GetScreenBounds())));
}
}
elements.clear();
return nodes;
}
std::string DebugDescribeContext(ui::ElementContext context) const override {
std::ostringstream oss;
oss << context;
return oss.str();
}
gfx::NativeWindow GetNativeWindowFromElement(
const TrackedElement* el) const override {
#if BUILDFLAG(IS_MAC)
return InteractionTestUtilMac::GetNativeWindowFor(el);
#elif BUILDFLAG(IS_ANDROID)
const auto view = el->GetNativeView();
return view ? view->GetWindowAndroid() : gfx::NativeWindow();
#elif USE_AURA
const auto view = el->GetNativeView();
return view ? view->GetToplevelWindow() : gfx::NativeWindow();
#else
return gfx::NativeWindow();
#endif
}
};
DEFINE_FRAMEWORK_SPECIFIC_METADATA(InteractiveTestPrivateFrameworkImpl)
} // namespace
DEFINE_ELEMENT_IDENTIFIER_VALUE(kInteractiveTestPivotElementId);
DEFINE_CUSTOM_ELEMENT_EVENT_TYPE(kInteractiveTestPivotEventType);
DEFINE_STATE_IDENTIFIER_VALUE(PollingStateObserver<bool>,
kInteractiveTestPollUntilState);
// Caches the last-known native window associated with a context.
// Useful for executing ClickMouse() and ReleaseMouse() commands, as no target
// element is provided for those commands. A NativeWindowTracker is used to
// prevent using a cached value after the native window has been destroyed.
class InteractiveTestPrivate::NativeWindowReference {
public:
NativeWindowReference() = default;
~NativeWindowReference() = default;
NativeWindowReference(NativeWindowReference&& other) = default;
NativeWindowReference& operator=(NativeWindowReference&& other) = default;
bool IsValid() const {
#if BUILDFLAG(IS_IOS)
// iOS uses a weak reference already.
return static_cast<bool>(window_);
#else
return window_ && tracker_ && !tracker_->WasNativeWindowDestroyed();
#endif
}
gfx::NativeWindow GetWindow() const {
return IsValid() ? window_ : gfx::NativeWindow();
}
void SetWindow(gfx::NativeWindow window) {
if (window_ == window) {
return;
}
window_ = window;
#if !BUILDFLAG(IS_IOS)
tracker_ = window ? ui::NativeWindowTracker::Create(window) : nullptr;
#endif
}
private:
gfx::NativeWindow window_ = gfx::NativeWindow();
#if !BUILDFLAG(IS_IOS)
std::unique_ptr<ui::NativeWindowTracker> tracker_;
#endif
};
StateObserverElement::StateObserverElement(ElementIdentifier id,
ElementContext context)
: TestElementBase(id, context) {}
StateObserverElement::~StateObserverElement() = default;
DEFINE_FRAMEWORK_SPECIFIC_METADATA(StateObserverElement)
// static
bool InteractiveTestPrivate::allow_interactive_test_verbs_ = false;
InteractiveTestPrivate::AdditionalContext::AdditionalContext() = default;
InteractiveTestPrivate::AdditionalContext::AdditionalContext(
InteractiveTestPrivate& owner,
intptr_t handle)
: owner_(owner.GetAsWeakPtr()), handle_(handle) {
CHECK(handle);
}
InteractiveTestPrivate::AdditionalContext::AdditionalContext(
const AdditionalContext& other) = default;
InteractiveTestPrivate::AdditionalContext&
InteractiveTestPrivate::AdditionalContext::operator=(
const AdditionalContext& other) = default;
InteractiveTestPrivate::AdditionalContext::~AdditionalContext() = default;
void InteractiveTestPrivate::AdditionalContext::Set(
const std::string_view& additional_context) {
auto* const owner = owner_.get();
CHECK(owner) << "Set() should never be executed after destruction of the "
"owning sequence.";
CHECK(handle_)
<< "Set() should never be executed on a default-constructed object.";
owner_->additional_context_data_[handle_] = additional_context;
}
std::string InteractiveTestPrivate::AdditionalContext::Get() const {
auto* const owner = owner_.get();
CHECK(owner) << "Set() should never be executed after destruction of the "
"owning sequence.";
CHECK(handle_)
<< "Set() should never be executed on a default-constructed object.";
const auto it = owner_->additional_context_data_.find(handle_);
return (it != owner_->additional_context_data_.end()) ? it->second
: std::string();
}
void InteractiveTestPrivate::AdditionalContext::Clear() {
auto* const owner = owner_.get();
CHECK(owner) << "Clear() should never be executed after destruction of the "
"owning sequence.";
CHECK(handle_)
<< "Clear() should never be executed on a default-constructed object.";
owner_->additional_context_data_.erase(handle_);
}
InteractiveTestPrivate::InteractiveTestPrivate() {
MaybeRegisterFrameworkImpl<InteractiveTestPrivateFrameworkImpl>();
}
InteractiveTestPrivate::~InteractiveTestPrivate() = default;
void InteractiveTestPrivate::Init(ElementContext initial_context) {
success_ = false;
sequence_skipped_ = false;
MaybeAddPivotElement(initial_context);
for (ElementContext context :
ElementTracker::GetElementTracker()->GetAllContextsForTesting()) {
MaybeAddPivotElement(context);
}
context_subscription_ =
ElementTracker::GetElementTracker()->AddAnyElementShownCallbackForTesting(
base::BindRepeating(&InteractiveTestPrivate::OnElementAdded,
base::Unretained(this)));
}
void InteractiveTestPrivate::Cleanup() {
context_subscription_ = base::CallbackListSubscription();
pivot_elements_.clear();
}
void InteractiveTestPrivate::OnElementAdded(TrackedElement* el) {
if (el->identifier() == kInteractiveTestPivotElementId)
return;
MaybeAddPivotElement(el->context());
}
void InteractiveTestPrivate::MaybeAddPivotElement(ElementContext context) {
CHECK(context) << "Attempted to run steps in an invalid (null) context.";
if (!pivot_elements_.contains(context)) {
auto pivot =
std::make_unique<TestElement>(kInteractiveTestPivotElementId, context);
auto* const el = pivot.get();
pivot_elements_.emplace(context, std::move(pivot));
el->Show();
}
}
base::WeakPtr<InteractiveTestPrivate> InteractiveTestPrivate::GetAsWeakPtr() {
return weak_ptr_factory_.GetWeakPtr();
}
void InteractiveTestPrivate::SetDefaultContext(
ElementContext context,
gfx::NativeWindow default_context_window) {
default_context_ = context;
if (default_context_window) {
if (!default_context_window_) {
default_context_window_ = std::make_unique<NativeWindowReference>();
}
default_context_window_->SetWindow(default_context_window);
} else {
default_context_window_.reset();
}
for (auto& framework : framework_implementations_) {
framework.OnDefaultContextSet();
}
}
gfx::NativeWindow InteractiveTestPrivate::GetDefaultContextWindow() const {
return default_context_window_ ? default_context_window_->GetWindow()
: gfx::NativeWindow();
}
gfx::NativeWindow InteractiveTestPrivate::GetNativeWindowFor(
const ui::TrackedElement* el) const {
gfx::NativeWindow window = gfx::NativeWindow();
for (auto& framework : base::Reversed(framework_implementations_)) {
window = framework.GetNativeWindowFromElement(el);
if (window) {
break;
}
}
if (window) {
// Want to remember the last window hit within each context so that verbs
// which do not target a real element use the same window as the previous
// action.
const auto emplace_result = most_recent_windows_.try_emplace(
el->context(), NativeWindowReference());
emplace_result.first->second.SetWindow(window);
} else {
// If the element does not correspond to a specific native window (because
// it is a pivot, test element, or for some other reason), fall back to the
// most recent window for that context.
if (auto* const entry =
base::FindOrNull(most_recent_windows_, el->context())) {
window = entry->GetWindow();
}
}
// If we still don't know what window to use, use the default one for the
// current context. We don't check this one before the most recent windows
// cache because it will always return the primary window for the context, but
// the mouse could be over a child window.
if (!window) {
for (auto& framework : base::Reversed(framework_implementations_)) {
window = framework.GetNativeWindowFromContext(el->context());
if (window) {
break;
}
}
}
return window;
}
void InteractiveTestPrivate::HandleActionResult(
InteractionSequence* seq,
const TrackedElement* el,
const std::string& operation_name,
ActionResult result,
bool defer_failure) {
switch (result) {
case ActionResult::kSucceeded:
break;
case ActionResult::kFailed:
if (defer_failure) {
ReportDeferredFailure(operation_name, el->context());
} else {
LOG(ERROR) << operation_name << " failed for " << *el;
seq->FailForTesting();
}
break;
case ActionResult::kNotAttempted:
LOG(ERROR) << operation_name << " could not be applied to " << *el;
seq->FailForTesting();
break;
case ActionResult::kKnownIncompatible:
LOG(WARNING) << operation_name
<< " failed because it is unsupported on this platform for "
<< *el;
if (!on_incompatible_action_reason_.empty()) {
LOG(WARNING) << "Unsupported action was expected: "
<< on_incompatible_action_reason_;
} else {
LOG(ERROR) << "Unsupported action was unexpected. "
"Did you forget to call SetOnIncompatibleAction()?";
}
switch (on_incompatible_action_) {
case OnIncompatibleAction::kFailTest:
seq->FailForTesting();
break;
case OnIncompatibleAction::kSkipTest:
case OnIncompatibleAction::kHaltTest:
sequence_skipped_ = true;
seq->FailForTesting();
break;
case OnIncompatibleAction::kIgnoreAndContinue:
break;
}
break;
}
}
TrackedElement* InteractiveTestPrivate::GetPivotElement(
ElementContext context) const {
const auto it = pivot_elements_.find(context);
CHECK(it != pivot_elements_.end())
<< "Tried to reference non-existent context.";
return it->second.get();
}
bool InteractiveTestPrivate::RemoveStateObserver(UntypedStateIdentifier id,
ElementContext context) {
using It = decltype(state_observer_elements_.begin());
It found = state_observer_elements_.end();
const auto element_id = StateToElementId(id);
for (It it = state_observer_elements_.begin();
it != state_observer_elements_.end(); ++it) {
auto& entry = **it;
if (entry.identifier() == element_id &&
(!context || entry.context() == context)) {
CHECK(found == state_observer_elements_.end())
<< "RemoveStateObserver: Duplicate entries found for " << id;
found = it;
}
}
if (found == state_observer_elements_.end()) {
LOG(ERROR) << "RemoveStateObserver: Entry not found for " << id;
return false;
}
state_observer_elements_.erase(found);
return true;
}
InteractiveTestPrivate::AdditionalContext
InteractiveTestPrivate::CreateAdditionalContext() {
return AdditionalContext(*this, next_additional_context_handle_++);
}
std::vector<std::string> InteractiveTestPrivate::GetAdditionalContext() const {
std::vector<std::string> entries;
std::transform(additional_context_data_.begin(),
additional_context_data_.end(), std::back_inserter(entries),
[](const auto& entry) { return entry.second; });
return entries;
}
void InteractiveTestPrivate::DoTestSetUp() {
temporary_storage_.emplace();
for (auto& framework : framework_implementations_) {
framework.DoTestSetUp();
}
}
void InteractiveTestPrivate::DoTestTearDown() {
for (auto& framework : base::Reversed(framework_implementations_)) {
framework.DoTestTearDown();
}
state_observer_elements_.clear();
temporary_storage_.reset();
}
void InteractiveTestPrivate::OnSequenceComplete() {
for (auto& framework : base::Reversed(framework_implementations_)) {
framework.OnSequenceComplete();
}
if (deferred_failures_.empty()) {
success_ = true;
} else {
std::ostringstream full_error_message;
full_error_message
<< "Interactive test failed.\n"
"Some steps reported errors (see test log for more details):";
for (const auto& failure : deferred_failures_) {
full_error_message << "\n" << failure;
}
if (aborted_callback_for_testing_) {
InteractionSequence::AbortedData data;
data.aborted_reason =
InteractionSequence::AbortedReason::kFailedForTesting;
data.context = default_context();
data.step_description = full_error_message.str();
std::move(aborted_callback_for_testing_).Run(data);
return;
}
GTEST_FAIL() << full_error_message.str();
}
}
void InteractiveTestPrivate::OnSequenceAborted(
const InteractionSequence::AbortedData& data) {
for (auto& framework : base::Reversed(framework_implementations_)) {
framework.OnSequenceAborted(data);
}
if (aborted_callback_for_testing_) {
std::move(aborted_callback_for_testing_).Run(data);
return;
}
if (sequence_skipped_) {
LOG(WARNING) << kInteractiveTestFailedMessagePrefix << data;
if (on_incompatible_action_ == OnIncompatibleAction::kSkipTest) {
GTEST_SKIP();
} else {
DCHECK_EQ(OnIncompatibleAction::kHaltTest, on_incompatible_action_);
}
} else {
std::ostringstream additional_message;
if (data.aborted_reason == InteractionSequence::AbortedReason::
kElementHiddenBetweenTriggerAndStepStart) {
additional_message
<< "\nNOTE: Please check for one of the following common mistakes:\n"
" - A RunLoop whose type is not set to kNestableTasksAllowed. "
"Change the type and try again.\n"
" - A check being performed on an element that has been hidden. "
"Wrap waiting for the hide and subsequent checks in a "
"WithoutDelay() to avoid possible access-after-delete.";
}
const auto additional_context = GetAdditionalContext();
if (!additional_context.empty()) {
additional_message << "\nAdditional test context:";
for (const auto& ctx : additional_context) {
additional_message << "\n * " << ctx;
}
}
DebugDumpElements(data.context).PrintTo(additional_message);
if (!deferred_failures_.empty()) {
additional_message << "\n" << "Some prior steps also failed:";
for (const auto& failure : deferred_failures_) {
additional_message << "\n" << failure;
}
}
GTEST_FAIL() << "Interactive test failed " << data
<< additional_message.str();
}
}
void InteractiveTestPrivate::ReportDeferredFailure(
std::string_view error_message,
ElementContext current_context) {
std::ostringstream full_error_message;
full_error_message << error_message << "\n";
DebugDumpContext(current_context).PrintTo(full_error_message);
deferred_failures_.push_back(full_error_message.str());
}
InteractiveTestPrivateFrameworkBase::InteractiveTestPrivateFrameworkBase(
InteractiveTestPrivate& test_impl)
: test_impl_(test_impl) {}
InteractiveTestPrivateFrameworkBase::~InteractiveTestPrivateFrameworkBase() =
default;
InteractiveTestPrivateFrameworkBase::DebugTreeNode::DebugTreeNode() = default;
InteractiveTestPrivateFrameworkBase::DebugTreeNode::DebugTreeNode(
std::string initial_text)
: text(initial_text) {}
InteractiveTestPrivateFrameworkBase::DebugTreeNode::DebugTreeNode(
DebugTreeNode&&) noexcept = default;
InteractiveTestPrivateFrameworkBase::DebugTreeNode&
InteractiveTestPrivateFrameworkBase::DebugTreeNode::operator=(
DebugTreeNode&&) noexcept = default;
InteractiveTestPrivateFrameworkBase::DebugTreeNode::~DebugTreeNode() = default;
std::vector<InteractiveTestPrivateFrameworkBase::DebugTreeNode>
InteractiveTestPrivateFrameworkBase::DebugDumpElements(
std::set<const ui::TrackedElement*>& el) const {
return {};
}
std::string InteractiveTestPrivateFrameworkBase::DebugDescribeContext(
ui::ElementContext context) const {
return std::string();
}
// static
std::string InteractiveTestPrivateFrameworkBase::DebugDumpBounds(
const gfx::Rect& bounds) {
return base::StringPrintf("x:%d-%d y:%d-%d (%dx%d)", bounds.x(),
bounds.right(), bounds.y(), bounds.bottom(),
bounds.width(), bounds.height());
}
gfx::NativeWindow
InteractiveTestPrivateFrameworkBase::GetNativeWindowFromElement(
const TrackedElement* el) const {
// Default answer is "no window is associated with this element".
// Other framework implementations will provide ways to convert specific
// types of elements to their native windows.
return gfx::NativeWindow();
}
gfx::NativeWindow
InteractiveTestPrivateFrameworkBase::GetNativeWindowFromContext(
ElementContext) const {
// Default answer is "no window is associated with this context".
// Other framework implementations will provide ways to convert specific
// contexts to their native windows.
return gfx::NativeWindow();
}
namespace {
void PrintDebugTree(std::ostream& stream,
const InteractiveTestPrivate::DebugTreeNode& node,
std::string prefix,
bool last) {
stream << prefix;
if (prefix.empty()) {
stream << "\n";
prefix += " ";
} else {
if (last) {
stream << "╰─";
prefix += " ";
} else {
stream << "├─";
prefix += "│ ";
}
}
stream << node.text << '\n';
for (size_t i = 0; i < node.children.size(); ++i) {
const bool last_child = (i == node.children.size() - 1);
PrintDebugTree(stream, node.children[i], prefix, last_child);
}
}
} // namespace
void InteractiveTestPrivate::DebugTreeNode::PrintTo(
std::ostream& stream) const {
PrintDebugTree(stream, *this, "", true);
}
// static
ElementIdentifier InteractiveTestPrivate::StateToElementId(
UntypedStateIdentifier id) {
return ElementIdentifier::FromRawValue(
id.GetRawValue(base::PassKey<InteractiveTestPrivate>()));
}
InteractiveTestPrivate::DebugTreeNode InteractiveTestPrivate::DebugDumpElements(
ui::ElementContext current_context) const {
DebugTreeNode node("UI Elements");
const auto* const tracker = ui::ElementTracker::GetElementTracker();
for (const auto ctx : tracker->GetAllContextsForTesting()) {
DebugTreeNode ctx_node = DebugDumpContext(ctx);
if (ctx == current_context) {
ctx_node.text = "[CURRENT CONTEXT] " + ctx_node.text;
}
node.children.emplace_back(std::move(ctx_node));
}
return node;
}
InteractiveTestPrivate::DebugTreeNode InteractiveTestPrivate::DebugDumpContext(
ui::ElementContext context) const {
std::string context_description;
for (auto& framework_implementation :
base::Reversed(framework_implementations_)) {
context_description =
framework_implementation.DebugDescribeContext(context);
if (!context_description.empty()) {
break;
}
}
CHECK(!context_description.empty());
DebugTreeNode node(context_description);
auto element_list =
ui::ElementTracker::GetElementTracker()->GetAllElementsForTesting(
context);
std::set<const ui::TrackedElement*> elements(element_list.begin(),
element_list.end());
std::vector<std::vector<DebugTreeNode>> temp;
for (auto& framework_implementation :
base::Reversed(framework_implementations_)) {
temp.push_back(framework_implementation.DebugDumpElements(elements));
}
CHECK(elements.empty());
for (auto& nodes : base::Reversed(temp)) {
std::move(nodes.begin(), nodes.end(), std::back_inserter(node.children));
}
return node;
}
std::string DescribeElement(ElementSpecifier element) {
std::ostringstream oss;
oss << element;
return oss.str();
}
InteractionSequence::Builder BuildSubsequence(
InteractiveTestPrivate::MultiStep steps) {
InteractionSequence::Builder builder;
for (auto& step : steps) {
builder.AddStep(std::move(step));
}
return builder;
}
} // namespace ui::test::internal