| // 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. |
| |
| #ifndef UI_BASE_INTERACTION_INTERACTIVE_TEST_INTERNAL_H_ |
| #define UI_BASE_INTERACTION_INTERACTIVE_TEST_INTERNAL_H_ |
| |
| #include <algorithm> |
| #include <concepts> |
| #include <functional> |
| #include <memory> |
| #include <optional> |
| #include <sstream> |
| #include <string> |
| #include <string_view> |
| #include <tuple> |
| #include <type_traits> |
| #include <variant> |
| |
| #include "base/callback_list.h" |
| #include "base/containers/contains.h" |
| #include "base/gtest_prod_util.h" |
| #include "base/logging.h" |
| #include "base/memory/raw_ref.h" |
| #include "base/memory/ref_counted.h" |
| #include "base/memory/weak_ptr.h" |
| #include "base/no_destructor.h" |
| #include "base/strings/strcat.h" |
| #include "base/test/bind.h" |
| #include "base/test/rectify_callback.h" |
| #include "base/types/is_instantiation.h" |
| #include "base/types/pass_key.h" |
| #include "testing/gmock/include/gmock/gmock.h" |
| #include "testing/gtest/include/gtest/gtest.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/base/interaction/framework_specific_registration_list.h" |
| #include "ui/base/interaction/interaction_sequence.h" |
| #include "ui/base/interaction/interaction_test_util.h" |
| #include "ui/base/interaction/interactive_test_definitions.h" |
| #include "ui/base/interaction/polling_state_observer.h" |
| #include "ui/base/interaction/state_observer.h" |
| #include "ui/gfx/geometry/rect.h" |
| |
| class ChromeOSTestLauncherDelegate; |
| class InteractiveUITestSuite; |
| |
| namespace ui::test { |
| |
| class InteractiveTestApi; |
| class InteractiveTestTest; |
| |
| namespace internal { |
| |
| // Element that is present during interactive tests that actions can bounce |
| // events off of. |
| DECLARE_ELEMENT_IDENTIFIER_VALUE(kInteractiveTestPivotElementId); |
| DECLARE_CUSTOM_ELEMENT_EVENT_TYPE(kInteractiveTestPivotEventType); |
| |
| // Used by `PollUntil()`. |
| DECLARE_STATE_IDENTIFIER_VALUE(PollingStateObserver<bool>, |
| kInteractiveTestPollUntilState); |
| |
| inline constexpr char kInteractiveTestFailedMessagePrefix[] = |
| "Interactive test failed "; |
| inline constexpr char kNoCheckDescriptionSpecified[] = |
| "[no description specified]"; |
| |
| class InteractiveTestPrivate; |
| class StateObserverElement; |
| |
| // Represents a private test implementation for a particular framework or |
| // platform. |
| class InteractiveTestPrivateFrameworkBase |
| : public FrameworkSpecificImplementation { |
| public: |
| explicit InteractiveTestPrivateFrameworkBase( |
| InteractiveTestPrivate& test_impl); |
| ~InteractiveTestPrivateFrameworkBase() override; |
| |
| // Represents a node in a debug tree of UI elements that can be pretty- |
| // printed. |
| struct DebugTreeNode { |
| DebugTreeNode(); |
| explicit DebugTreeNode(std::string initial_text); |
| DebugTreeNode(DebugTreeNode&& other) noexcept; |
| DebugTreeNode& operator=(DebugTreeNode&& other) noexcept; |
| ~DebugTreeNode(); |
| |
| std::string text; |
| std::vector<DebugTreeNode> children; |
| |
| void PrintTo(std::ostream& stream) const; |
| }; |
| |
| // Gets a verbose string representation of a set of `bounds` for debug |
| // purposes. |
| static std::string DebugDumpBounds(const gfx::Rect& bounds); |
| |
| // Called to populate any simulators required for this platform. |
| virtual void PopulateSimulators(InteractionTestUtil& test_util) {} |
| |
| // Call this method during test SetUp(), or SetUpOnMainThread() for browser |
| // tests. |
| virtual void DoTestSetUp() {} |
| |
| // Call this method during test TearDown(), or TearDownOnMainThread() for |
| // browser tests. |
| virtual void DoTestTearDown() {} |
| |
| // Called when the sequence ends, but before we break out of the run loop |
| // in RunTestSequenceImpl(). |
| virtual void OnSequenceComplete() {} |
| virtual void OnSequenceAborted(const InteractionSequence::AbortedData& data) { |
| } |
| |
| // Retrieves the native window from `el`. If this particular implementation |
| // does not know how to do this, or there is no window, returns a null/falsy |
| // value. |
| virtual gfx::NativeWindow GetNativeWindowFromElement( |
| const TrackedElement* el) const; |
| |
| // Retrieves the native window from `context`. If this particular |
| // implementation does not know how to do this, or there is no window, returns |
| // a null/falsy value. |
| virtual gfx::NativeWindow GetNativeWindowFromContext( |
| ElementContext context) const; |
| |
| // Convert some or all of `elements` to debug tree nodes; removing elements |
| // that are processed from the set. |
| virtual std::vector<DebugTreeNode> DebugDumpElements( |
| std::set<const ui::TrackedElement*>& elements) const; |
| |
| // Provides the top-level description for a context, or null if none. |
| virtual std::string DebugDescribeContext(ui::ElementContext context) const; |
| |
| protected: |
| InteractiveTestPrivate& test_impl() { return test_impl_.get(); } |
| |
| private: |
| const raw_ref<InteractiveTestPrivate> test_impl_; |
| }; |
| |
| // Class that implements functionality for InteractiveTest* that should be |
| // hidden from tests that inherit the API. |
| class InteractiveTestPrivate { |
| public: |
| using MultiStep = internal::MultiStep; |
| |
| // Describes what should happen when an action isn't compatible with the |
| // current build, platform, or environment. For example, not all tests are set |
| // up to handle screenshots, and some Linux window managers cannot bring a |
| // background window to the front. |
| // |
| // See chrome/test/interaction/README.md for best practices. |
| enum class OnIncompatibleAction { |
| // The test should fail. This is the default, and should be used in almost |
| // all cases. |
| kFailTest, |
| // The sequence should abort immediately and the test should be skipped. |
| // Use this when the remainder of the test would depend on the result of the |
| // incompatible step. Good for smoke/regression tests that have known |
| // incompatibilities but still need to be run in as many environments as |
| // possible. |
| kSkipTest, |
| // As `kSkipTest`, but instead of marking the test as skipped, just stops |
| // the test sequence. This is useful when the test cannot continue past the |
| // problematic step, but you also want to preserve any non-fatal errors that |
| // may have occurred up to that point (or check any conditions after the |
| // test stops). |
| kHaltTest, |
| // The failure should be ignored and the test should continue. |
| // Use this when the step does not affect the outcome of the test, such as |
| // taking an incidental screenshot in a test job that doesn't support |
| // screenshots. |
| kIgnoreAndContinue, |
| }; |
| |
| // Provides a copyable handle to some test state that can be output in the |
| // event of a test failure. The context will persist until `End()` is called |
| // or the test ends. |
| // |
| // Example: |
| // ``` |
| // auto MyVerb() { |
| // AdditionalContext context = CreateAdditionalContext(); |
| // return Steps( |
| // |
| // // Set the context. Note the use of the `mutable` keyword: |
| // AfterShow(..., [context]() mutable { |
| // context.Set(...); |
| // }), |
| // |
| // // Context is still valid here, even if it's not modified. |
| // WithElement(..., [](ui::TrackedElement*) { |
| // ... |
| // }), |
| // |
| // Do([context]() { context.End(); }) |
| // |
| // // Since no more steps reference `context` it is no longer valid |
| // // here; if the test were to fail, no additional information would |
| // // be printed. |
| // PressButton(...)); |
| // } |
| // ``` |
| class AdditionalContext { |
| public: |
| AdditionalContext(); |
| AdditionalContext(const AdditionalContext& other); |
| AdditionalContext& operator=(const AdditionalContext& other); |
| ~AdditionalContext(); |
| |
| // Adds or replaces the existing value with `additional_context`. Until this |
| // is called, nothing will be stored or output. |
| void Set(const std::string_view& additional_context); |
| |
| // Fetches the current value of the context. |
| std::string Get() const; |
| |
| // Removes the context. |
| void Clear(); |
| |
| private: |
| friend InteractiveTestPrivate; |
| |
| // Creates a new context with the given `owner` and `handle`. |
| AdditionalContext(InteractiveTestPrivate& owner, intptr_t handle); |
| |
| base::WeakPtr<InteractiveTestPrivate> owner_; |
| intptr_t handle_ = 0; |
| }; |
| |
| InteractiveTestPrivate(); |
| virtual ~InteractiveTestPrivate(); |
| InteractiveTestPrivate(const InteractiveTestPrivate&) = delete; |
| void operator=(const InteractiveTestPrivate&) = delete; |
| |
| InteractionTestUtil& test_util() { return test_util_; } |
| |
| OnIncompatibleAction on_incompatible_action() const { |
| return on_incompatible_action_; |
| } |
| |
| bool sequence_skipped() const { return sequence_skipped_; } |
| |
| base::WeakPtr<InteractiveTestPrivate> GetAsWeakPtr(); |
| |
| void set_default_context(ElementContext default_context) { |
| default_context_ = default_context; |
| } |
| ElementContext default_context() const { return default_context_; } |
| |
| // Fetch the native window for the given element. |
| gfx::NativeWindow GetNativeWindowFor(const ui::TrackedElement* el) const; |
| |
| // Possibly fails or skips a sequence based on the result of an action |
| // simulation. |
| void HandleActionResult(InteractionSequence* seq, |
| const TrackedElement* el, |
| const std::string& operation_name, |
| ActionResult result); |
| |
| // Gets the pivot element for the specified context, which must exist. |
| TrackedElement* GetPivotElement(ElementContext context) const; |
| |
| // Adds `state_observer` and associates it with an element with identifier |
| // `id` and context `context`. Must be unique in its context. |
| // Returns true on success. |
| template <typename Observer, typename V = Observer::ValueType> |
| bool AddStateObserver(ElementIdentifier id, |
| ElementContext context, |
| std::unique_ptr<Observer> state_observer); |
| |
| // Removes `StateObserver` with identifier `id` in `context`; if the context |
| // is null, assumes there is exactly one matching observer in some context. |
| // Returns true on success. |
| bool RemoveStateObserver(ElementIdentifier id, ElementContext context); |
| |
| // Creates an additional context that will persist as long as copies of the |
| // context exist. |
| [[nodiscard]] AdditionalContext CreateAdditionalContext(); |
| |
| // Gets a string representation of the current additional context for this |
| // test. |
| std::vector<std::string> GetAdditionalContext() const; |
| |
| // Call this method during test SetUp(), or SetUpOnMainThread() for browser |
| // tests. |
| virtual void DoTestSetUp(); |
| |
| // Call this method during test TearDown(), or TearDownOnMainThread() for |
| // browser tests. |
| virtual void DoTestTearDown(); |
| |
| // Called when the sequence ends, but before we break out of the run loop |
| // in RunTestSequenceImpl(). |
| virtual void OnSequenceComplete(); |
| virtual void OnSequenceAborted(const InteractionSequence::AbortedData& data); |
| |
| // Sets a callback that is called if the test sequence fails instead of |
| // failing the current test. Should only be called in tests that are testing |
| // InteractiveTestApi or descendant classes. |
| void set_aborted_callback_for_testing( |
| InteractionSequence::AbortedCallback aborted_callback_for_testing) { |
| aborted_callback_for_testing_ = std::move(aborted_callback_for_testing); |
| } |
| |
| // The following are the classes allowed to set the "allow interactive test |
| // verbs" flag. |
| template <typename T> |
| requires std::same_as<T, ui::test::InteractiveTestTest> || |
| std::same_as<T, ChromeOSTestLauncherDelegate> || |
| std::same_as<T, InteractiveUITestSuite> |
| static void set_interactive_test_verbs_allowed(base::PassKey<T>) { |
| allow_interactive_test_verbs_ = true; |
| } |
| |
| using DebugTreeNode = InteractiveTestPrivateFrameworkBase::DebugTreeNode; |
| |
| template <typename T, typename... Args> |
| requires std::derived_from<T, InteractiveTestPrivateFrameworkBase> |
| T* MaybeRegisterFrameworkImpl(Args&&... args) { |
| T* const result = framework_implementations_.MaybeRegister<T>( |
| *this, std::forward<Args>(args)...); |
| if (result) { |
| result->PopulateSimulators(test_util_); |
| } |
| return result; |
| } |
| |
| protected: |
| // Dumps the entire tree of named elements. Default implementation organizes |
| // all elements by context. This is the entry point when printing test failure |
| // information. The `current_context` is the current context in the test, if |
| // known. |
| DebugTreeNode DebugDumpElements(ui::ElementContext current_context) const; |
| |
| // Dumps the contents of a particular context. |
| virtual DebugTreeNode DebugDumpContext( |
| const ui::ElementContext context) const; |
| |
| private: |
| friend class ui::test::InteractiveTestTest; |
| friend class ui::test::InteractiveTestApi; |
| |
| // Prepare for a sequence to start. |
| void Init(ElementContext initial_context); |
| |
| // Clean up after a sequence. |
| void Cleanup(); |
| |
| // Note when a new element appears; we may update the context list. |
| void OnElementAdded(TrackedElement* el); |
| |
| // Maybe adds a pivot element for the given context. |
| void MaybeAddPivotElement(ElementContext context); |
| |
| // Tracks whether a sequence succeeded or failed. |
| bool success_ = false; |
| |
| // Specifies how an incompatible action should be handled. |
| OnIncompatibleAction on_incompatible_action_ = |
| OnIncompatibleAction::kFailTest; |
| std::string on_incompatible_action_reason_; |
| |
| // Tracks whether a sequence is skipped. Will only be set if |
| // `skip_on_unsupported_operation` is true. |
| bool sequence_skipped_ = false; |
| |
| // Used to simulate input to UI elements. |
| InteractionTestUtil test_util_; |
| |
| // The default context for running test sequences. |
| ElementContext default_context_; |
| |
| // Used to keep track of valid contexts. |
| base::CallbackListSubscription context_subscription_; |
| |
| // Used to track state observers and their associated elements. |
| std::vector<std::unique_ptr<StateObserverElement>> state_observer_elements_; |
| |
| // Used to relay events to trigger follow-up steps. |
| std::map<ElementContext, std::unique_ptr<TrackedElement>> pivot_elements_; |
| |
| // Overrides the default test failure behavior to test the API itself. |
| InteractionSequence::AbortedCallback aborted_callback_for_testing_; |
| |
| intptr_t next_additional_context_handle_ = 1U; |
| std::map<intptr_t, std::string> additional_context_data_; |
| |
| FrameworkSpecificRegistrationList<InteractiveTestPrivateFrameworkBase> |
| framework_implementations_; |
| |
| base::WeakPtrFactory<InteractiveTestPrivate> weak_ptr_factory_{this}; |
| |
| // Whether interactive test verbs are allowed. See |
| // `InteractiveTestApi::RequireInteractiveTest()` for more info. |
| static bool allow_interactive_test_verbs_; |
| }; |
| |
| class StateObserverElement : public TestElementBase { |
| public: |
| StateObserverElement(ElementIdentifier id, ElementContext context); |
| ~StateObserverElement() override; |
| |
| DECLARE_FRAMEWORK_SPECIFIC_METADATA() |
| }; |
| |
| // Implements an element that is shown when an observed state matches a desired |
| // value or pattern, and hidden when it does not. |
| template <typename T> |
| class StateObserverElementT : public StateObserverElement { |
| public: |
| // A lookup table is provided per value of `T`. |
| using LookupTable = std::map<std::pair<ElementIdentifier, ElementContext>, |
| StateObserverElementT<T>*>; |
| using TestContext = InteractiveTestPrivate::AdditionalContext; |
| |
| // Specify the `id` and `context` of the element to be created, as well as the |
| // associated `observer` which will be linked to this element. |
| StateObserverElementT(ElementIdentifier id, |
| ElementContext context, |
| std::unique_ptr<StateObserver<T>> observer, |
| TestContext test_context) |
| : StateObserverElement(id, context), |
| test_context_(test_context), |
| current_value_(observer->GetStateObserverInitialState()), |
| observer_(std::move(observer)) { |
| auto& table = GetLookupTable(); |
| CHECK(!base::Contains(table, std::make_pair(id, context))) |
| << "Duplicate ID + context for StateObserver not allowed: " << id |
| << ", " << context; |
| table.emplace(std::make_pair(id, context), this); |
| observer_->SetStateObserverStateChangedCallback(base::BindRepeating( |
| &StateObserverElementT::OnStateChanged, base::Unretained(this))); |
| OnStateChanged(current_value_); |
| } |
| ~StateObserverElementT() override { |
| CHECK(GetLookupTable().erase(std::make_pair(identifier(), context()))); |
| } |
| |
| void SetTarget(testing::Matcher<T> target) { |
| target_value_ = std::move(target); |
| UpdateVisibility(); |
| } |
| |
| // Helper method that looks up an element based on `id`, `context`, and |
| // whether `seq` allows all contexts to be searched. Fails the sequence if the |
| // element is not found. |
| static StateObserverElementT<T>* LookupElement(ElementIdentifier id, |
| ElementContext context, |
| bool search_all_contexts) { |
| const auto& lookup_table = GetLookupTable(); |
| const auto it = lookup_table.find(std::make_pair(id, context)); |
| if (it != lookup_table.end()) { |
| return it->second; |
| } |
| |
| if (search_all_contexts) { |
| for (const auto& [key, ptr] : lookup_table) { |
| if (key.first == id) { |
| return ptr; |
| } |
| } |
| } |
| |
| return nullptr; |
| } |
| |
| const T& current_value() const { return current_value_; } |
| |
| private: |
| void OnStateChanged(T new_state) { |
| current_value_ = new_state; |
| UpdateVisibility(); |
| } |
| |
| void UpdateVisibility() { |
| testing::StringMatchResultListener listener; |
| if (target_value_ && |
| target_value_->MatchAndExplain(current_value_, &listener)) { |
| test_context_.Clear(); |
| Show(); |
| } else { |
| std::ostringstream oss; |
| oss << "Waiting for state " << identifier() << " " << listener.str(); |
| test_context_.Set(oss.str()); |
| Hide(); |
| } |
| } |
| |
| // Fetch the lookup table associated with a value type/template instantiation. |
| // |
| // This table does not own the instances, just tracks them as long as they are |
| // alive and allows them to be retrieved. There is one static table per |
| // template instantiation due to the use of `base::NoDestructor`, |
| static LookupTable& GetLookupTable() { |
| static base::NoDestructor<LookupTable> lookup_table; |
| return *lookup_table; |
| } |
| |
| private: |
| // Since the context can be updated on observer shutdown and needs access to |
| // the current value, it needs to be destructed last. |
| TestContext test_context_; |
| T current_value_; |
| std::optional<testing::Matcher<T>> target_value_; |
| std::unique_ptr<StateObserver<T>> observer_; |
| }; |
| |
| // Applies `matcher` to `value` and returns the result; on failure a useful |
| // error message is printed using `test_name`, `value`, and `matcher`. |
| // |
| // Steps which use this method will fail if it returns false, printing out the |
| // details of the step in the usual way. |
| template <typename T, typename V = std::decay_t<T>> |
| bool MatchAndExplain(std::string_view test_name, |
| const testing::Matcher<V>& matcher, |
| const T& value) { |
| testing::StringMatchResultListener listener; |
| if (matcher.MatchAndExplain(value, &listener)) { |
| return true; |
| } |
| std::ostringstream oss; |
| oss << test_name << " failed.\nExpected: "; |
| matcher.DescribeTo(&oss); |
| oss << "\nActual: " << testing::PrintToString(value); |
| if (!listener.str().empty()) { |
| oss << "\n" << listener.str(); |
| } |
| LOG(ERROR) << oss.str(); |
| return false; |
| } |
| |
| template <typename Observer, typename V> |
| bool InteractiveTestPrivate::AddStateObserver( |
| ElementIdentifier id, |
| ElementContext context, |
| std::unique_ptr<Observer> state_observer) { |
| CHECK(id); |
| CHECK(context); |
| for (const auto& existing : state_observer_elements_) { |
| if (existing->identifier() == id && existing->context() == context) { |
| LOG(ERROR) << "AddStateObserver: Duplicate observer added for " << id; |
| return false; |
| } |
| } |
| state_observer_elements_.emplace_back( |
| std::make_unique<StateObserverElementT<V>>( |
| id, context, std::move(state_observer), CreateAdditionalContext())); |
| return true; |
| } |
| |
| } // namespace internal |
| |
| } // namespace ui::test |
| |
| inline ui::test::internal::MultiStep& operator+=( |
| ui::test::internal::MultiStep& steps, |
| ui::InteractionSequence::StepBuilder&& step) { |
| steps.push_back(std::move(step)); |
| return steps; |
| } |
| |
| inline ui::test::internal::MultiStep& operator+=( |
| ui::test::internal::MultiStep& steps, |
| ui::test::internal::MultiStep&& other) { |
| std::ranges::move(other, std::back_inserter(steps)); |
| return steps; |
| } |
| |
| #endif // UI_BASE_INTERACTION_INTERACTIVE_TEST_INTERNAL_H_ |