// 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.
#import "base/strings/escape.h"
#import "base/strings/stringprintf.h"
#import "base/strings/sys_string_conversions.h"
#import "components/feature_engagement/public/feature_constants.h"
#import "components/plus_addresses/features.h"
#import "components/plus_addresses/metrics/plus_address_metrics.h"
#import "components/plus_addresses/plus_address_test_utils.h"
#import "components/strings/grit/components_strings.h"
#import "ios/chrome/browser/autofill/ui_bundled/autofill_app_interface.h"
#import "ios/chrome/browser/metrics/model/metrics_app_interface.h"
#import "ios/chrome/browser/plus_addresses/ui/plus_address_bottom_sheet_constants.h"
#import "ios/chrome/browser/signin/model/fake_system_identity.h"
#import "ios/chrome/browser/ui/authentication/signin_earl_grey.h"
#import "ios/chrome/browser/ui/authentication/signin_earl_grey_ui_test_util.h"
#import "ios/chrome/browser/ui/settings/settings_table_view_controller_constants.h"
#import "ios/chrome/common/string_util.h"
#import "ios/chrome/test/earl_grey/chrome_actions.h"
#import "ios/chrome/test/earl_grey/chrome_actions_app_interface.h"
#import "ios/chrome/test/earl_grey/chrome_earl_grey.h"
#import "ios/chrome/test/earl_grey/chrome_earl_grey_ui.h"
#import "ios/chrome/test/earl_grey/chrome_matchers.h"
#import "ios/chrome/test/earl_grey/web_http_server_chrome_test_case.h"
#import "ios/testing/earl_grey/app_launch_manager.h"
#import "ios/testing/earl_grey/earl_grey_test.h"
#import "ios/testing/earl_grey/matchers.h"
#import "net/test/embedded_test_server/default_handlers.h"
#import "net/test/embedded_test_server/request_handler_util.h"
#import "ui/base/l10n/l10n_util_mac.h"
namespace {
constexpr char kEmailFormUrl[] = "/email_signup_form.html";
constexpr char kEmailFieldId[] = "email";
constexpr char kFakeSuggestionLabel[] = "Lorem Ipsum";
// Assert that a given plus address modal event of type `event_type` occurred
// `count` times.
void ExpectModalHistogram(
plus_addresses::metrics::PlusAddressModalEvent event_type,
int count) {
NSError* error =
[MetricsAppInterface expectCount:count
forBucket:static_cast<int>(event_type)
forHistogram:@"PlusAddresses.Modal.Events"];
GREYAssertNil(error, @"Failed to record modal event histogram");
}
// Assert that the bottom sheet shown duration metrics is recorded.
// Actual duration is not assessed to avoid unnecessary clock mocking.
void ExpectModalTimeSample(
plus_addresses::metrics::PlusAddressModalCompletionStatus status,
int count) {
NSString* modalStatus = [NSString
stringWithUTF8String:plus_addresses::metrics::
PlusAddressModalCompletionStatusToString(status)
.c_str()];
NSString* name = [NSString
stringWithFormat:@"PlusAddresses.Modal.%@.ShownDuration", modalStatus];
NSError* error = [MetricsAppInterface expectTotalCount:count
forHistogram:name];
GREYAssertNil(error, @"Failed to record modal shown duration histogram");
}
} // namespace
// Test suite that tests plus addresses functionality.
@interface PlusAddressesTestCase : WebHttpServerChromeTestCase
@end
@implementation PlusAddressesTestCase {
FakeSystemIdentity* _fakeIdentity;
}
- (void)setUp {
[super setUp];
net::test_server::RegisterDefaultHandlers(self.testServer);
self.testServer->RegisterRequestHandler(base::BindRepeating(
&net::test_server::HandlePrefixedRequest, "/v1/profiles",
base::BindRepeating(
&plus_addresses::test::HandleRequestToPlusAddressWithSuccess)));
GREYAssertTrue(self.testServer->Start(), @"Server did not start.");
if ([self isRunningTest:@selector(testConfirmPlusAddressUsingBottomSheet)] ||
[self isRunningTest:@selector(testRefresh)] ||
[self isRunningTest:@selector(testCreatePlusAddressIPH)]) {
[self relaunchAppAndSetConfiguration];
}
GREYAssertNil([MetricsAppInterface setupHistogramTester],
@"Failed to set up histogram tester.");
// Ensure a fake identity is available, as this is required by the
// plus_addresses feature.
_fakeIdentity = [FakeSystemIdentity fakeIdentity1];
[SigninEarlGrey signinWithFakeIdentity:_fakeIdentity];
[self loadPlusAddressEligiblePage];
}
- (void)tearDown {
[super tearDown];
GREYAssertNil([MetricsAppInterface releaseHistogramTester],
@"Cannot reset histogram tester.");
}
- (void)relaunchAppAndSetConfiguration {
AppLaunchConfiguration config = self.appConfigurationForTestCase;
config.features_enabled_and_params.clear();
config.features_enabled_and_params.push_back(
{plus_addresses::features::kPlusAddressesEnabled,
{{{"server-url", {self.testServer->base_url().spec()}},
{"oauth-scope", {plus_addresses::test::kFakeOauthScope}},
{"manage-url", {plus_addresses::test::kFakeManagementUrl}},
{"error-report-url", {plus_addresses::test::kFakeErrorReportUrl}}}}});
if ([self isRunningTest:@selector(testCreatePlusAddressIPH)]) {
config.iph_feature_enabled =
feature_engagement::kIPHPlusAddressCreateSuggestionFeature.name;
}
// Relaunch the app to take the configuration into account.
[[AppLaunchManager sharedManager] ensureAppLaunchedWithConfiguration:config];
}
- (AppLaunchConfiguration)appConfigurationForTestCase {
AppLaunchConfiguration config;
// Ensure the feature is enabled, including a required param.
// TODO(crbug.com/40276862): Set up fake responses via `self.testServer`, or
// use an app interface to force different states without a backend
// dependency. The `chrome://version` part in the `server-url` param is just
// to force an invalid response, and must not be used long-term.
std::string fakeLocalUrl =
base::EscapeQueryParamValue("chrome://version", /*use_plus=*/false);
config.features_enabled_and_params.push_back(
{plus_addresses::features::kPlusAddressesEnabled,
{{
{"server-url", {fakeLocalUrl}},
{"manage-url", {fakeLocalUrl}},
}}});
return config;
}
#pragma mark - Helper methods
// Loads simple page on localhost, ensuring that it is eligible for the
// plus_addresses feature.
- (void)loadPlusAddressEligiblePage {
[ChromeEarlGrey loadURL:self.testServer->GetURL(kEmailFormUrl)];
[ChromeEarlGrey waitForWebStateContainingText:"Signup form"];
}
// Taps on the create plus address suggestion to open the bottom sheet.
- (void)openCreatePlusAddressBottomSheet {
// Tap an element that is eligible for plus_address autofilling.
[[EarlGrey selectElementWithMatcher:chrome_test_util::WebViewMatcher()]
performAction:chrome_test_util::TapWebElementWithId(kEmailFieldId)];
NSString* suggestionLabel = base::SysUTF8ToNSString(kFakeSuggestionLabel);
id<GREYMatcher> userChip =
[AutofillAppInterface isKeyboardAccessoryUpgradeEnabled]
? grey_accessibilityLabel([NSString
stringWithFormat:@"%@, %@", suggestionLabel, suggestionLabel])
: grey_text(suggestionLabel);
// Ensure the plus_address suggestion appears.
[ChromeEarlGrey waitForUIElementToAppearWithMatcher:userChip];
// Tapping it will trigger the UI.
[[EarlGrey selectElementWithMatcher:userChip] performAction:grey_tap()];
}
id<GREYMatcher> GetMatcherForErrorReportLink() {
return grey_allOf(
// The link is within
// kPlusAddressModalErrorMessageAccessibilityIdentifier.
grey_ancestor(grey_accessibilityID(
kPlusAddressSheetErrorMessageAccessibilityIdentifier)),
// UIKit instantiates a `UIAccessibilityLinkSubelement` for the link
// element in the label with attributed string.
grey_kindOfClassName(@"UIAccessibilityLinkSubelement"),
grey_accessibilityTrait(UIAccessibilityTraitLink), nil);
}
// Returns a matcher for the email description.
id<GREYMatcher> GetMatcherForEmailDescription(NSString* email) {
NSString* message =
l10n_util::GetNSStringF(IDS_PLUS_ADDRESS_BOTTOMSHEET_DESCRIPTION_IOS,
base::SysNSStringToUTF16(email));
return grey_allOf(
// The link is within
// kPlusAddressSheetDescriptionAccessibilityIdentifier.
grey_text(message),
grey_accessibilityID(kPlusAddressSheetDescriptionAccessibilityIdentifier),
nil);
}
// Returns a matcher for the plus address field.
id<GREYMatcher> GetMatcherForPlusAddressLabel(NSString* labelText) {
return grey_allOf(
// The link is within
// kPlusAddressLabelAccessibilityIdentifier.
grey_accessibilityID(kPlusAddressLabelAccessibilityIdentifier),
grey_text(labelText),
grey_accessibilityTrait(UIAccessibilityTraitStaticText), nil);
}
// Verifies that field with the html `id` has been filled with `value`.
- (void)verifyFieldWithIdHasBeenFilled:(std::string)id value:(NSString*)value {
NSString* condition = [NSString
stringWithFormat:@"window.document.getElementById('%s').value === '%@'",
id.c_str(), value];
[ChromeEarlGrey waitForJavaScriptCondition:condition];
}
#pragma mark - Tests
// Tests showing up a bottom sheet to confirm a plus address. Once, the plus
// address is confirmed checks if it is filled in the file.d
- (void)testConfirmPlusAddressUsingBottomSheet {
[self openCreatePlusAddressBottomSheet];
id<GREYMatcher> plusAddressLabelMatcher = GetMatcherForPlusAddressLabel(
base::SysUTF8ToNSString(plus_addresses::test::kFakePlusAddress));
[ChromeEarlGrey waitForUIElementToAppearWithMatcher:plusAddressLabelMatcher];
id<GREYMatcher> confirmButton =
chrome_test_util::ButtonWithAccessibilityLabelId(
IDS_PLUS_ADDRESS_BOTTOMSHEET_OK_TEXT_IOS);
// Click the okay button, confirming the plus address.
[[EarlGrey selectElementWithMatcher:confirmButton] performAction:grey_tap()];
[self verifyFieldWithIdHasBeenFilled:kEmailFieldId
value:base::SysUTF8ToNSString(
plus_addresses::test::
kFakePlusAddress)];
ExpectModalHistogram(
plus_addresses::metrics::PlusAddressModalEvent::kModalShown, 1);
ExpectModalHistogram(
plus_addresses::metrics::PlusAddressModalEvent::kModalConfirmed, 1);
ExpectModalTimeSample(plus_addresses::metrics::
PlusAddressModalCompletionStatus::kModalConfirmed,
1);
}
// A basic test that simply opens the bottom sheet with an error and then
// dismisses the bottom sheet.
- (void)testShowPlusAddressBottomSheetWithError {
[self openCreatePlusAddressBottomSheet];
// The primary email address should be shown.
[ChromeEarlGrey
waitForUIElementToAppearWithMatcher:GetMatcherForEmailDescription(
_fakeIdentity.userEmail)];
// The request to reserve a plus address is hitting the test server, and
// should fail immediately.
NSString* error_message = l10n_util::GetNSString(
IDS_PLUS_ADDRESS_BOTTOMSHEET_REPORT_ERROR_INSTRUCTION_IOS);
id<GREYMatcher> parsed_error_message =
grey_text(ParseStringWithLinks(error_message).string);
// Ensure error message with link is shown and correctly parsed.
[ChromeEarlGrey waitForUIElementToAppearWithMatcher:parsed_error_message];
// Ensure the cancel button is shown.
id<GREYMatcher> cancelButton =
chrome_test_util::ButtonWithAccessibilityLabelId(
IDS_PLUS_ADDRESS_MODAL_CANCEL_TEXT);
// Click the cancel button, dismissing the bottom sheet.
[[EarlGrey selectElementWithMatcher:cancelButton] performAction:grey_tap()];
ExpectModalHistogram(
plus_addresses::metrics::PlusAddressModalEvent::kModalShown, 1);
ExpectModalHistogram(
plus_addresses::metrics::PlusAddressModalEvent::kModalCanceled, 1);
// The test server currently only response with reserve error. Thus, closing
// status is recorded as `kReservePlusAddressError`.
// TODO(b/321072266) Expand coverage to other responses.
ExpectModalTimeSample(
plus_addresses::metrics::PlusAddressModalCompletionStatus::
kReservePlusAddressError,
1);
}
- (void)testPlusAddressBottomSheetErrorReportLink {
[self openCreatePlusAddressBottomSheet];
id<GREYMatcher> link_text = GetMatcherForErrorReportLink();
// Take note of how many tabs are open before clicking the link.
NSUInteger oldRegularTabCount = [ChromeEarlGrey mainTabCount];
NSUInteger oldIncognitoTabCount = [ChromeEarlGrey incognitoTabCount];
[ChromeEarlGrey waitForUIElementToAppearWithMatcher:link_text];
[[EarlGrey selectElementWithMatcher:link_text] performAction:grey_tap()];
// A new tab should open after tapping the link.
[ChromeEarlGrey waitForMainTabCount:oldRegularTabCount + 1];
[ChromeEarlGrey waitForIncognitoTabCount:oldIncognitoTabCount];
// The bottom sheet should be dismissed.
[[EarlGrey selectElementWithMatcher:link_text]
assertWithMatcher:grey_notVisible()];
}
- (void)testSwipeToDismiss {
// TODO(crbug.com/40949085): Test fails on iPad.
if ([ChromeEarlGrey isIPadIdiom]) {
EARL_GREY_TEST_DISABLED(@"Fails on iPad.");
}
[self openCreatePlusAddressBottomSheet];
id<GREYMatcher> emailDescription =
GetMatcherForEmailDescription(_fakeIdentity.userEmail);
// The primary email address should be shown.
[ChromeEarlGrey waitForUIElementToAppearWithMatcher:emailDescription];
// Then, swipe down on the bottom sheet.
[[EarlGrey selectElementWithMatcher:emailDescription]
performAction:grey_swipeSlowInDirection(kGREYDirectionDown)];
// It should no longer be shown.
[[EarlGrey selectElementWithMatcher:emailDescription]
assertWithMatcher:grey_notVisible()];
ExpectModalHistogram(
plus_addresses::metrics::PlusAddressModalEvent::kModalShown, 1);
// TODO(crbug.com/40276862): separate out the cancel click from other exit
// patterns, on all platforms.
ExpectModalHistogram(
plus_addresses::metrics::PlusAddressModalEvent::kModalCanceled, 1);
ExpectModalTimeSample(
plus_addresses::metrics::PlusAddressModalCompletionStatus::
kReservePlusAddressError,
1);
}
// A test to ensure that a row in the settings view shows up for
// plus_addresses, and that tapping it opens a new tab for its settings, which
// are managed externally.
- (void)testSettings {
[ChromeEarlGreyUI openSettingsMenu];
// Take note of how many tabs are open before clicking the link in settings,
// which should simply open a new tab.
NSUInteger oldRegularTabCount = [ChromeEarlGrey mainTabCount];
NSUInteger oldIncognitoTabCount = [ChromeEarlGrey incognitoTabCount];
[ChromeEarlGreyUI
tapSettingsMenuButton:grey_accessibilityID(kSettingsPlusAddressesId)];
// A new tab should open after tapping the link.
[ChromeEarlGrey waitForMainTabCount:oldRegularTabCount + 1];
[ChromeEarlGrey waitForIncognitoTabCount:oldIncognitoTabCount];
}
// A test to check the refresh plus address functionality.
- (void)testRefresh {
[self openCreatePlusAddressBottomSheet];
id<GREYMatcher> plusAddressLabelMatcher = GetMatcherForPlusAddressLabel(
base::SysUTF8ToNSString(plus_addresses::test::kFakePlusAddress));
[ChromeEarlGrey waitForUIElementToAppearWithMatcher:plusAddressLabelMatcher];
id<GREYMatcher> refreshButton = grey_allOf(
grey_accessibilityID(kPlusAddressRefreshButtonAccessibilityIdentifier),
grey_accessibilityTrait(UIAccessibilityTraitButton), nil);
// Tap on the refresh button
[[EarlGrey selectElementWithMatcher:refreshButton] performAction:grey_tap()];
id<GREYMatcher> refreshed_plus_address = GetMatcherForPlusAddressLabel(
base::SysUTF8ToNSString(plus_addresses::test::kFakePlusAddressRefresh));
// Test that the plus address has been refreshed.
[ChromeEarlGrey waitForUIElementToAppearWithMatcher:refreshed_plus_address];
// Ensure the cancel button is shown.
id<GREYMatcher> cancelButton =
chrome_test_util::ButtonWithAccessibilityLabelId(
IDS_PLUS_ADDRESS_MODAL_CANCEL_TEXT);
// Click the cancel button, dismissing the bottom sheet.
[[EarlGrey selectElementWithMatcher:cancelButton] performAction:grey_tap()];
}
// A test to check the plus address create suggestion IPH feature.
- (void)testCreatePlusAddressIPH {
// Tap an element that is eligible for plus_address autofilling.
[[EarlGrey selectElementWithMatcher:chrome_test_util::WebViewMatcher()]
performAction:chrome_test_util::TapWebElementWithId(kEmailFieldId)];
id<GREYMatcher> iph_chip = grey_text(
l10n_util::GetNSString(IDS_PLUS_ADDRESS_CREATE_SUGGESTION_IPH_IOS));
// Ensure the plus_address suggestion IPH appears.
[ChromeEarlGrey waitForUIElementToAppearWithMatcher:iph_chip];
}
@end