blob: 202d2c7b724b4a36212b3a2af31b99f3e2501f7d [file] [log] [blame]
// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include <cstdint>
#include <map>
#include <ostream>
#include <sstream>
#include <vector>
#include "base/containers/fixed_flat_set.h"
#include "base/feature_list.h"
#include "base/functional/bind.h"
#include "base/run_loop.h"
#include "base/test/scoped_feature_list.h"
#include "chrome/browser/feature_engagement/tracker_factory.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/browser_element_identifiers.h"
#include "chrome/browser/ui/browser_window.h"
#include "chrome/browser/ui/user_education/user_education_service.h"
#include "chrome/browser/ui/user_education/user_education_service_factory.h"
#include "chrome/browser/ui/views/user_education/browser_user_education_service.h"
#include "chrome/test/base/in_process_browser_test.h"
#include "components/feature_engagement/public/configuration.h"
#include "components/feature_engagement/public/feature_configurations.h"
#include "components/feature_engagement/public/feature_constants.h"
#include "components/feature_engagement/public/tracker.h"
#include "components/feature_engagement/test/scoped_iph_feature_list.h"
#include "components/user_education/common/feature_promo_registry.h"
#include "components/user_education/common/feature_promo_specification.h"
#include "components/user_education/common/tutorial_identifier.h"
#include "content/public/test/browser_test.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/abseil-cpp/absl/types/optional.h"
#include "third_party/abseil-cpp/absl/types/variant.h"
#include "ui/base/interaction/element_identifier.h"
#include "ui/base/interaction/interaction_sequence.h"
namespace {
enum class IPHFailureReason {
kNone,
kNotConfigured,
kWrongSessionRate,
kWrongSessionImpact,
kLegacyPromoNoScreenReader,
};
struct IPHException {
IPHException() = default;
IPHException(const base::Feature* feature_,
absl::optional<IPHFailureReason> reason_,
const char* description_)
: feature(feature_), reason(reason_), description(description_) {}
IPHException(const IPHException& other) = default;
IPHException& operator=(const IPHException& other) = default;
~IPHException() = default;
base::raw_ptr<const base::Feature> feature = nullptr;
absl::optional<IPHFailureReason> reason;
const char* description = nullptr;
};
struct IPHFailure {
IPHFailure() = default;
IPHFailure(const base::Feature* feature_,
IPHFailureReason reason_,
const feature_engagement::FeatureConfig* config_)
: feature(feature_), reason(reason_), config(config_) {}
IPHFailure(const IPHFailure& other) = default;
IPHFailure& operator=(const IPHFailure& other) = default;
base::raw_ptr<const base::Feature> feature = nullptr;
IPHFailureReason reason = IPHFailureReason::kNone;
base::raw_ptr<const feature_engagement::FeatureConfig> config = nullptr;
};
std::ostream& operator<<(std::ostream& os,
feature_engagement::ComparatorType type) {
switch (type) {
case feature_engagement::ANY:
os << "ANY";
break;
case feature_engagement::LESS_THAN:
os << "LESS_THAN";
break;
case feature_engagement::GREATER_THAN:
os << "GREATER_THAN";
break;
case feature_engagement::LESS_THAN_OR_EQUAL:
os << "LESS_THAN_OR_EQUAL";
break;
case feature_engagement::GREATER_THAN_OR_EQUAL:
os << "GREATER_THAN_OR_EQUAL";
break;
case feature_engagement::EQUAL:
os << "EQUAL";
break;
case feature_engagement::NOT_EQUAL:
os << "NOT_EQUAL";
break;
}
return os;
}
std::ostream& operator<<(std::ostream& os,
feature_engagement::SessionRateImpact::Type type) {
switch (type) {
case feature_engagement::SessionRateImpact::Type::ALL:
os << "ALL";
break;
case feature_engagement::SessionRateImpact::Type::EXPLICIT:
os << "EXPLICIT";
break;
case feature_engagement::SessionRateImpact::Type::NONE:
os << "NONE";
break;
}
return os;
}
std::ostream& operator<<(std::ostream& os, const IPHFailure& failure) {
os << failure.feature->name;
switch (failure.reason) {
case IPHFailureReason::kNone:
NOTREACHED();
break;
case IPHFailureReason::kNotConfigured:
os << " is not configured. Please add a configuration to "
"feature_configurations.cc (preferred) or "
"fieldtrial_testing_config.json.";
break;
case IPHFailureReason::kWrongSessionRate:
os << " has unexpected session rate: "
<< failure.config->session_rate.type << ", "
<< failure.config->session_rate.value
<< ". Non-toast IPH should have limited session_rate - typically "
"EQUALS, 0 - to prevent other IPH from running in the same "
"session.";
break;
case IPHFailureReason::kWrongSessionImpact:
os << " has unexpected session rate impact: "
<< failure.config->session_rate_impact.type
<< ". An IPH which runs once per session should also prevent other "
"similar IPH from running (session rate impact ALL); an IPH which "
"is not limited should not (session rate impact NONE).";
break;
case IPHFailureReason::kLegacyPromoNoScreenReader:
os << " is a legacy promo with inadequate screen reader support. Use a "
"toast promo instead.";
break;
}
return os;
}
template <typename T, typename U>
void MaybeAddFailure(T& failures,
const U& exceptions,
const base::Feature* feature,
IPHFailureReason reason,
const feature_engagement::FeatureConfig* feature_config) {
IPHFailure failure(feature, reason, feature_config);
for (const auto& exception : exceptions) {
if (exception.feature == feature) {
if ((exception.reason.has_value() &&
exception.reason.value() == reason) ||
(reason != IPHFailureReason::kNotConfigured &&
!exception.reason.has_value())) {
LOG(WARNING) << "Allowed by exception or currently being worked - "
<< exception.description << ":\n"
<< failure;
}
return;
}
}
failures.push_back(failure);
}
template <typename T>
std::string FailuresToString(const T& failures, const char* type) {
std::ostringstream oss;
oss << "Errors found during " << type << " configuration validation.";
for (auto& failure : failures) {
oss << "\n" << failure;
}
return oss.str();
}
bool IsComparatorLimited(const feature_engagement::Comparator& comparator,
uint32_t max_count) {
switch (comparator.type) {
case feature_engagement::ANY:
case feature_engagement::GREATER_THAN:
case feature_engagement::GREATER_THAN_OR_EQUAL:
case feature_engagement::NOT_EQUAL:
return false;
case feature_engagement::LESS_THAN:
return comparator.value <= max_count;
case feature_engagement::LESS_THAN_OR_EQUAL:
case feature_engagement::EQUAL:
return comparator.value < max_count;
}
}
} // namespace
using BrowserUserEducationServiceBrowserTest = InProcessBrowserTest;
IN_PROC_BROWSER_TEST_F(BrowserUserEducationServiceBrowserTest,
FeatureConfigurationConsistencyCheck) {
// Exceptions to the consistency checks. All of those with crbug.com IDs
// should ideally be fixed. See tracking bug at crbug.com/1442977
const std::vector<IPHException> exceptions({
// Known weird/old/test-only IPH.
{&feature_engagement::kIPHAutofillExternalAccountProfileSuggestionFeature,
IPHFailureReason::kLegacyPromoNoScreenReader, "Known legacy promo."},
{&feature_engagement::kIPHAutofillVirtualCardSuggestionFeature,
IPHFailureReason::kLegacyPromoNoScreenReader, "Known legacy promo."},
{&feature_engagement::kIPHGMCCastStartStopFeature,
IPHFailureReason::kLegacyPromoNoScreenReader, "Known legacy promo."},
{&feature_engagement::kIPHWebUiHelpBubbleTestFeature,
IPHFailureReason::kNotConfigured, "For testing purposes only."},
// Toast IPH that probably need session impact updated.
{&feature_engagement::kIPHPasswordsManagementBubbleAfterSaveFeature,
IPHFailureReason::kWrongSessionImpact, "crbug.com/1442979"},
{&feature_engagement::kIPHPasswordsManagementBubbleDuringSigninFeature,
IPHFailureReason::kWrongSessionImpact, "crbug.com/1442979"},
{&feature_engagement::kIPHPasswordsWebAppProfileSwitchFeature,
IPHFailureReason::kWrongSessionImpact, "crbug.com/1442979"},
{&feature_engagement::kIPHProfileSwitchFeature,
IPHFailureReason::kWrongSessionImpact, "crbug.com/1442979"},
{&feature_engagement::kIPHTabAudioMutingFeature,
IPHFailureReason::kWrongSessionImpact, "crbug.com/1442979"},
// IPH that limit session rate in other ways. These should probably be
// revisited in the future.
{&feature_engagement::kIPHDesktopCustomizeChromeFeature,
IPHFailureReason::kWrongSessionRate, "crbug.com/1443063"},
{&feature_engagement::kIPHDesktopTabGroupsNewGroupFeature,
IPHFailureReason::kWrongSessionRate, "crbug.com/1443063"},
{&feature_engagement::kIPHSideSearchFeature,
IPHFailureReason::kWrongSessionRate, "crbug.com/1443063"},
{&feature_engagement::kIPHHighEfficiencyModeFeature,
IPHFailureReason::kWrongSessionRate, "crbug.com/1443063"},
{&feature_engagement::kIPHPriceTrackingChipFeature, absl::nullopt,
"crbug.com/1443063"},
{&feature_engagement::kIPHPriceTrackingInSidePanelFeature, absl::nullopt,
"crbug.com/1443063"},
{&feature_engagement::kIPHPowerBookmarksSidePanelFeature,
IPHFailureReason::kWrongSessionRate,
"crbug.com/1443067, crbug.com/1443063"},
{&feature_engagement::kIPHPasswordsAccountStorageFeature,
IPHFailureReason::kWrongSessionRate, "crbug.com/1443075"},
// Deprecated; should probably be removed.
{&feature_engagement::kIPHReadingListDiscoveryFeature,
IPHFailureReason::kNotConfigured, "crbug.com/1443020"},
{&feature_engagement::kIPHReadingListEntryPointFeature,
IPHFailureReason::kNotConfigured, "crbug.com/1443020"},
{&feature_engagement::kIPHDesktopSharedHighlightingFeature,
IPHFailureReason::kNotConfigured, "crbug.com/1443071"},
{&feature_engagement::kIPHReadingListInSidePanelFeature, absl::nullopt,
"crbug.com/1443078"},
{&feature_engagement::kIPHTabSearchFeature, absl::nullopt,
"crbug.com/1443079"},
{&feature_engagement::kIPHWebUITabStripFeature, absl::nullopt,
"crbug.com/1443082"},
// Needs configuration.
{&feature_engagement::kIPHLiveCaptionFeature,
IPHFailureReason::kNotConfigured, "crbug.com/1443002"},
{&feature_engagement::kIPHBackNavigationMenuFeature,
IPHFailureReason::kNotConfigured, "crbug.com/1443013"},
{&feature_engagement::kIPHDesktopPwaInstallFeature,
IPHFailureReason::kNotConfigured, "crbug.com/1443016"},
});
// Fetch the tracker and ensure that it is properly initialized.
auto* const tracker =
feature_engagement::TrackerFactory::GetForBrowserContext(
browser()->profile());
base::RunLoop run_loop;
tracker->AddOnInitializedCallback(base::BindOnce(
[](base::OnceClosure callback, bool success) {
ASSERT_TRUE(success);
std::move(callback).Run();
},
run_loop.QuitClosure()));
run_loop.Run();
// Get the configuration from the tracker.
const feature_engagement::Configuration* const configuration =
tracker->GetConfigurationForTesting();
ASSERT_NE(nullptr, configuration);
// Get the associated feature promo registry.
const user_education::FeaturePromoRegistry& registry =
UserEducationServiceFactory::GetForProfile(browser()->profile())
->feature_promo_registry();
std::vector<IPHFailure> failures;
// Iterate through registered IPH and ensure that the configurations are
// consistent.
for (const auto& [feature, spec] :
registry.GetRegisteredFeaturePromoSpecifications()) {
const feature_engagement::FeatureConfig* feature_config =
&configuration->GetFeatureConfig(*feature);
// Fetch the configuration for the given feature.
absl::optional<feature_engagement::FeatureConfig> client_config;
if (!feature_config->valid) {
// Disabled features don't read from feature_configurations.cc by default;
// we have to do it manually to ensure that if Finch enables the feature
// the configuration we read will be correct.
client_config = feature_engagement::GetClientSideFeatureConfig(feature);
if (client_config) {
feature_config = &client_config.value();
} else {
// This is a feature that can only be configured through Finch; current
// best practice is to also include a fieldtrial or (better) a config
// in feature_configurations.cc.
MaybeAddFailure(failures, exceptions, feature,
IPHFailureReason::kNotConfigured, feature_config);
continue;
}
}
const bool limits_other_iph =
feature_config->session_rate_impact.type ==
feature_engagement::SessionRateImpact::Type::ALL;
const bool is_session_limited =
IsComparatorLimited(feature_config->session_rate, 1);
switch (spec.promo_type()) {
case user_education::FeaturePromoSpecification::PromoType::kToast:
// Toast promos are allowed to bypass session exclusivity. However, they
// should not limit other IPH.
if (limits_other_iph) {
MaybeAddFailure(failures, exceptions, feature,
IPHFailureReason::kWrongSessionImpact,
feature_config);
}
break;
case user_education::FeaturePromoSpecification::PromoType::kTutorial:
case user_education::FeaturePromoSpecification::PromoType::kCustomAction:
case user_education::FeaturePromoSpecification::PromoType::kSnooze:
// Standard promos should be session-limited and should limit other IPH.
if (!is_session_limited) {
MaybeAddFailure(failures, exceptions, feature,
IPHFailureReason::kWrongSessionRate, feature_config);
}
if (!limits_other_iph) {
MaybeAddFailure(failures, exceptions, feature,
IPHFailureReason::kWrongSessionImpact,
feature_config);
}
break;
case user_education::FeaturePromoSpecification::PromoType::kLegacy:
case user_education::FeaturePromoSpecification::PromoType::kUnspecified:
// Legacy promos are inherently bad. Use toast or snooze instead.
if (!spec.screen_reader_string_id()) {
MaybeAddFailure(failures, exceptions, feature,
IPHFailureReason::kLegacyPromoNoScreenReader,
feature_config);
}
// Legacy promos should pattern as snooze or toast promos.
if (is_session_limited != limits_other_iph) {
MaybeAddFailure(failures, exceptions, feature,
IPHFailureReason::kWrongSessionImpact,
feature_config);
}
break;
}
}
EXPECT_TRUE(failures.empty()) << FailuresToString(failures, "IPH");
}
namespace {
enum class TutorialFailureReason {
kNone,
kLikelySkippedStep,
kWaitForAlwaysVisibleElement,
};
struct TutorialFailure {
user_education::TutorialIdentifier tutorial_id;
int step_number = -1;
ui::ElementIdentifier identifier;
TutorialFailureReason reason = TutorialFailureReason::kNone;
};
std::ostream& operator<<(std::ostream& os, const TutorialFailure& failure) {
os << failure.tutorial_id;
switch (failure.reason) {
case TutorialFailureReason::kNone:
NOTREACHED();
break;
case TutorialFailureReason::kLikelySkippedStep:
os << " shows a bubble anchored to an always-visible UI element "
<< failure.identifier << " (step " << failure.step_number
<< ") immediately after another bubble. This is likely to cause the "
" previous step to be skipped, as the transition will be "
"instantaneous. Please insert a hidden step between these steps "
"that detects the action you expect the user to take to advance "
"the tutorial (e.g. an activation step for a button press, or an "
"event step for the result of some process).";
break;
case TutorialFailureReason::kWaitForAlwaysVisibleElement:
os << " is waiting for element " << failure.identifier
<< " to become visible in the current context (step "
<< failure.step_number
<< "), and is set to only show on state change (i.e. not visible -> "
"visible). However, this element is already always visible, so the "
"bubble will likely never show. If you did not intend to wait for "
"a state transition, make sure `transition_only_on_event` is "
"false. If you were waiting for another window to appear, make "
"sure that `context_mode` is ContextMode::kAny.";
break;
}
return os;
}
} // namespace
IN_PROC_BROWSER_TEST_F(BrowserUserEducationServiceBrowserTest,
TutorialConsistencyCheck) {
const auto kAlwaysPresentElementIds =
base::MakeFixedFlatSet<ui::ElementIdentifier>(
{kAppMenuButtonElementId, kAvatarButtonElementId,
kBackButtonElementId, kBrowserViewElementId, kForwardButtonElementId,
kNewTabButtonElementId, kOmniboxElementId, kSidePanelButtonElementId,
kTabSearchButtonElementId, kTabStripElementId,
kTabStripRegionElementId, kTopContainerElementId});
std::vector<TutorialFailure> failures;
auto* const service =
UserEducationServiceFactory::GetForProfile(browser()->profile());
const auto& registry = service->tutorial_registry();
for (auto identifier : registry.GetTutorialIdentifiers()) {
const auto* const description = registry.GetTutorialDescription(identifier);
bool was_show_bubble = false;
int step_count = 0;
for (const auto& step : description->steps) {
++step_count;
const bool is_show_bubble =
(step.step_type == ui::InteractionSequence::StepType::kShown &&
step.body_text_id);
const bool is_always_visible =
base::Contains(kAlwaysPresentElementIds, step.element_id);
if (is_show_bubble && was_show_bubble && is_always_visible &&
!step.transition_only_on_event) {
failures.push_back(
TutorialFailure{identifier, step_count, step.element_id,
TutorialFailureReason::kLikelySkippedStep});
} else if (is_always_visible && step.transition_only_on_event &&
step.context_mode !=
ui::InteractionSequence::ContextMode::kAny) {
failures.push_back(TutorialFailure{
identifier, step_count, step.element_id,
TutorialFailureReason::kWaitForAlwaysVisibleElement});
}
was_show_bubble = is_show_bubble;
}
}
EXPECT_TRUE(failures.empty()) << FailuresToString(failures, "Tutorial");
}