chromium/ios/chrome/browser/ui/omnibox/popup/row/actions/actions_in_suggest_egtest.mm

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#import <XCTest/XCTest.h>

#import "base/functional/bind.h"
#import "base/ios/ios_util.h"
#import "base/strings/sys_string_conversions.h"
#import "base/test/ios/wait_util.h"
#import "components/omnibox/common/omnibox_features.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/ui/omnibox/omnibox_app_interface.h"
#import "ios/chrome/browser/ui/omnibox/omnibox_constants.h"
#import "ios/chrome/browser/ui/omnibox/omnibox_earl_grey.h"
#import "ios/chrome/browser/ui/omnibox/omnibox_test_util.h"
#import "ios/chrome/browser/ui/omnibox/omnibox_ui_features.h"
#import "ios/chrome/browser/ui/omnibox/popup/omnibox_popup_accessibility_identifier_constants.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/chrome_test_case.h"
#import "ios/testing/earl_grey/app_launch_manager.h"
#import "ios/testing/earl_grey/earl_grey_test.h"
#import "net/base/apple/url_conversions.h"
#import "ui/base/l10n/l10n_util_mac.h"

namespace {
// Unhighlighted call button matcher.
id<GREYMatcher> unhighlightedCallButtonMatcher() {
  return grey_accessibilityID(kCallActionIdentifier);
}

// Unhighlighted directions button matcher.
id<GREYMatcher> unhighlightedDirectionsButtonMatcher() {
  return grey_accessibilityID(kDirectionsActionIdentifier);
}

// Unhighlighted reviews button matcher.
id<GREYMatcher> unhighlightedReviewsButtonMatcher() {
  return grey_accessibilityID(kReviewsActionIdentifier);
}

// Unhighlighted call button matcher.
id<GREYMatcher> highlightedCallButtonMatcher() {
  return grey_accessibilityID(kCallActionHighlightedIdentifier);
}

// Unhighlighted call button matcher.
id<GREYMatcher> highlightedDirectionsButtonMatcher() {
  return grey_accessibilityID(kDirectionsActionHighlightedIdentifier);
}

// Unhighlighted call button matcher.
id<GREYMatcher> highlightedReviewsButtonMatcher() {
  return grey_accessibilityID(kReviewsActionHighlightedIdentifier);
}

}  // namespace

@interface OmniboxPopupWithFakeSuggestionActionsTestCase : ChromeTestCase
@end

@implementation OmniboxPopupWithFakeSuggestionActionsTestCase {
  GURL _directionURI;
  GURL _reviewsURI;
  GURL _callURI;
}

- (AppLaunchConfiguration)appConfigurationForTestCase {
  AppLaunchConfiguration config = [super appConfigurationForTestCase];

  config.features_disabled = {};
  config.features_enabled.push_back(kOmniboxActionsInSuggest);
  // HW keyboard simulation can mess up the SW keyboard simulator state.
  // Relaunching resets the state.
  config.relaunch_policy = ForceRelaunchByCleanShutdown;
  return config;
}

- (void)setUp {
  [super setUp];

  [ChromeEarlGrey clearBrowsingHistory];

  [OmniboxAppInterface
      setUpFakeSuggestionsService:@"fake_suggestion_actions.json"];
  [ChromeEarlGrey loadURL:GURL("about:blank")];

  _directionURI = GURL("http://localhost:3000/directions");
  _reviewsURI = GURL("http://localhost:3000/reviews");
  _callURI = GURL("tel://0123456789");
}

- (void)tearDown {
  [OmniboxAppInterface tearDownFakeSuggestionsService];
  [super tearDown];
}

- (void)testDisplayActions {
  // Clears the url and replace it with local url host.
  [ChromeEarlGreyUI focusOmniboxAndReplaceText:@"local restaurant"];

  [self ensureActionButtonsAreDisplayed];
}

- (void)testTapDirectionsButton {
  [ChromeEarlGreyUI focusOmniboxAndReplaceText:@"local restaurant"];

  [self ensureActionButtonsAreDisplayed];

  [ChromeEarlGrey waitForUIElementToAppearWithMatcher:
                      unhighlightedDirectionsButtonMatcher()];

  [[EarlGrey selectElementWithMatcher:unhighlightedDirectionsButtonMatcher()]
      performAction:grey_tap()];

  [ChromeEarlGrey
      waitForUIElementToAppearWithMatcher:chrome_test_util::OmniboxText(
                                              _directionURI.GetContent())];
}

- (void)testTapReviewsButton {
  [ChromeEarlGreyUI focusOmniboxAndReplaceText:@"local restaurant"];

  [self ensureActionButtonsAreDisplayed];

  [[EarlGrey selectElementWithMatcher:unhighlightedReviewsButtonMatcher()]
      performAction:grey_tap()];

  [ChromeEarlGrey
      waitForUIElementToAppearWithMatcher:chrome_test_util::OmniboxText(
                                              _reviewsURI.GetContent())];
}

// TODO (crbug.com/348177731) re-enable when fixed.
- (void)DISABLED_testTapCallButton {
  // Skip the test if the dial app is not installed.
  if (![self dialAppInstalled]) {
    return;
  }

  [ChromeEarlGreyUI focusOmniboxAndReplaceText:@"local restaurant"];

  [self ensureActionButtonsAreDisplayed];

  [[EarlGrey selectElementWithMatcher:unhighlightedCallButtonMatcher()]
      performAction:grey_tap()];

  // The call dialog is a system UI so can't be tested using EG.
  XCUIApplication* springboardApplication = [[XCUIApplication alloc]
      initWithBundleIdentifier:@"com.apple.springboard"];

  // This phone number is hardcoded in the stubbed entity info.
  auto callLabel = springboardApplication.staticTexts[@"Call 01 23 45 67 89"];

  GREYAssert(
      [callLabel
          waitForExistenceWithTimeout:base::test::ios::kWaitForUIElementTimeout
                                          .InSecondsF()],
      @"Call dialog was not shown");

  if ([callLabel exists]) {
    auto cancelButton = springboardApplication.buttons[@"Cancel"];
    [cancelButton tap];
    // Make sure that the call dialog get discarded before the teardown.
    GREYAssertFalse(
        [callLabel waitForExistenceWithTimeout:
                       base::test::ios::kWaitForUIElementTimeout.InSecondsF()],
        @"Call dialog is still shown");
  }
}

- (void)testKeyboardArrowsHighlighting {
  BOOL isDialAppInstalled = [self dialAppInstalled];
  [ChromeEarlGreyUI focusOmniboxAndReplaceText:@"local restaurant"];

  [self ensureActionButtonsAreDisplayed];

  // Go down to the actions popup row.
  [ChromeEarlGrey simulatePhysicalKeyboardEvent:@"downArrow" flags:0];
  // Go down to the first action button.
  [ChromeEarlGrey simulatePhysicalKeyboardEvent:@"downArrow" flags:0];

  if (isDialAppInstalled) {
    // The call button should be highlighted.
    [ChromeEarlGrey
        waitForUIElementToAppearWithMatcher:highlightedCallButtonMatcher()];
    // The directions and reviews buttons should stay unhighlighted.
    [ChromeEarlGrey waitForUIElementToAppearWithMatcher:
                        unhighlightedDirectionsButtonMatcher()];
    [ChromeEarlGrey waitForUIElementToAppearWithMatcher:
                        unhighlightedReviewsButtonMatcher()];
  } else {
    // The directions button should be highlighted.
    [ChromeEarlGrey waitForUIElementToAppearWithMatcher:
                        highlightedDirectionsButtonMatcher()];
    // The reviews button should stay unhighlighted.
    [ChromeEarlGrey waitForUIElementToAppearWithMatcher:
                        unhighlightedReviewsButtonMatcher()];
  }

  // Go to the next unhighlighted action button.
  [ChromeEarlGrey simulatePhysicalKeyboardEvent:@"rightArrow" flags:0];

  if (isDialAppInstalled) {
    // The directions button should now be highlighted.
    [ChromeEarlGrey waitForUIElementToAppearWithMatcher:
                        highlightedDirectionsButtonMatcher()];
    // The call button should now become unhighlighted
    [ChromeEarlGrey
        waitForUIElementToAppearWithMatcher:unhighlightedCallButtonMatcher()];
  } else {
    // The reviews button should now be highlighted.
    [ChromeEarlGrey
        waitForUIElementToAppearWithMatcher:highlightedReviewsButtonMatcher()];
    // The directions button should now become unhighlighted
    [ChromeEarlGrey waitForUIElementToAppearWithMatcher:
                        unhighlightedDirectionsButtonMatcher()];
  }

  // Go back to the previously highlighted action button.
  [ChromeEarlGrey simulatePhysicalKeyboardEvent:@"leftArrow" flags:0];

  if (isDialAppInstalled) {
    // The call button should be highlighted.
    [ChromeEarlGrey
        waitForUIElementToAppearWithMatcher:highlightedCallButtonMatcher()];
    // The directions button should now become unhighlighted.
    [ChromeEarlGrey waitForUIElementToAppearWithMatcher:
                        unhighlightedDirectionsButtonMatcher()];
  } else {
    // The directions button should be highlighted.
    [ChromeEarlGrey waitForUIElementToAppearWithMatcher:
                        highlightedDirectionsButtonMatcher()];
    // The reviews button should now become unhighlighted.
    [ChromeEarlGrey waitForUIElementToAppearWithMatcher:
                        unhighlightedReviewsButtonMatcher()];
  }

  // Go up to unhighlight the action buttons
  [ChromeEarlGrey simulatePhysicalKeyboardEvent:@"upArrow" flags:0];

  // Directions button should be displayed unhighlighted.
  [ChromeEarlGrey waitForUIElementToAppearWithMatcher:
                      unhighlightedDirectionsButtonMatcher()];
  // Reviews button should be displayed unhighlighted.
  [ChromeEarlGrey
      waitForUIElementToAppearWithMatcher:unhighlightedReviewsButtonMatcher()];
  // Call button should be displayed only if the device has a dial app.
  if ([self dialAppInstalled]) {
    [ChromeEarlGrey
        waitForUIElementToAppearWithMatcher:unhighlightedCallButtonMatcher()];
  } else {
    [[EarlGrey selectElementWithMatcher:unhighlightedCallButtonMatcher()]
        assertWithMatcher:grey_notVisible()];
  }
}

- (void)testReturnKeyOnDirectionsButton {
  [ChromeEarlGreyUI focusOmniboxAndReplaceText:@"local restaurant"];

  [self ensureActionButtonsAreDisplayed];

  // Go down to the actions popup row.
  [ChromeEarlGrey simulatePhysicalKeyboardEvent:@"downArrow" flags:0];
  // Go down to the first action button.
  [ChromeEarlGrey simulatePhysicalKeyboardEvent:@"downArrow" flags:0];

  if ([self dialAppInstalled]) {
    // Highlight directions button.
    [ChromeEarlGrey simulatePhysicalKeyboardEvent:@"rightArrow" flags:0];
  }

  // The directions button should be highlighted.
  [ChromeEarlGrey
      waitForUIElementToAppearWithMatcher:highlightedDirectionsButtonMatcher()];

  // press the return key.
  [ChromeEarlGrey simulatePhysicalKeyboardEvent:@"return" flags:0];
  // We expect to trigger the reviews button action.
  [ChromeEarlGrey
      waitForUIElementToAppearWithMatcher:chrome_test_util::OmniboxText(
                                              _directionURI.GetContent())];
}

- (void)testReturnKeyOnReviewsButton {
  [ChromeEarlGreyUI focusOmniboxAndReplaceText:@"local restaurant"];

  [self ensureActionButtonsAreDisplayed];

  // Go down to the actions popup row.
  [ChromeEarlGrey simulatePhysicalKeyboardEvent:@"downArrow" flags:0];
  // Go down to the first action button.
  [ChromeEarlGrey simulatePhysicalKeyboardEvent:@"downArrow" flags:0];

  if ([self dialAppInstalled]) {
    // Highlight directions button.
    [ChromeEarlGrey simulatePhysicalKeyboardEvent:@"rightArrow" flags:0];
    // Highlight reviews button.
    [ChromeEarlGrey simulatePhysicalKeyboardEvent:@"rightArrow" flags:0];
  } else {
    // Highlight reviews button.
    [ChromeEarlGrey simulatePhysicalKeyboardEvent:@"rightArrow" flags:0];
  }

  // The reviews button should be highlighted.
  [ChromeEarlGrey
      waitForUIElementToAppearWithMatcher:highlightedReviewsButtonMatcher()];

  // press the return key.
  [ChromeEarlGrey simulatePhysicalKeyboardEvent:@"return" flags:0];
  // We expect to trigger the reviews button action.
  [ChromeEarlGrey
      waitForUIElementToAppearWithMatcher:chrome_test_util::OmniboxText(
                                              _reviewsURI.GetContent())];
}

// TODO (crbug.com/348177731) re-enable when fixed.
- (void)DISABLED_testReturnKeyOnCallButton {
  // Skip the test if the dial app is not installed.
  if (![self dialAppInstalled]) {
    return;
  }

  [ChromeEarlGreyUI focusOmniboxAndReplaceText:@"local restaurant"];

  [self ensureActionButtonsAreDisplayed];

  // Go down to the actions popup row.
  [ChromeEarlGrey simulatePhysicalKeyboardEvent:@"downArrow" flags:0];
  // Go down to the call action button.
  [ChromeEarlGrey simulatePhysicalKeyboardEvent:@"downArrow" flags:0];

  // The call button should be highlighted.
  [ChromeEarlGrey
      waitForUIElementToAppearWithMatcher:highlightedCallButtonMatcher()];

  // press the return key.
  [ChromeEarlGrey simulatePhysicalKeyboardEvent:@"return" flags:0];

  // The call dialog is a system UI so can't be tested using EG.
  XCUIApplication* springboardApplication = [[XCUIApplication alloc]
      initWithBundleIdentifier:@"com.apple.springboard"];

  // This phone number is hardcoded in the stubbed entity info.
  auto callLabel = springboardApplication.staticTexts[@"Call 01 23 45 67 89"];

  GREYAssert(
      [callLabel
          waitForExistenceWithTimeout:base::test::ios::kWaitForUIElementTimeout
                                          .InSecondsF()],
      @"Call dialog was not shown");

  if ([callLabel exists]) {
    auto cancelButton = springboardApplication.buttons[@"Cancel"];
    [cancelButton tap];
    // Make sure that the call dialog get discarded before the teardown.
    GREYAssertFalse(
        [callLabel waitForExistenceWithTimeout:
                       base::test::ios::kWaitForUIElementTimeout.InSecondsF()],
        @"Call dialog is still shown");
  }
}

#pragma mark - utils

// Ensures that the action buttons are displayed.
- (void)ensureActionButtonsAreDisplayed {
  // Directions button should be displayed unhighlighted.
  [ChromeEarlGrey waitForUIElementToAppearWithMatcher:
                      unhighlightedDirectionsButtonMatcher()];
  // Reviews button should be displayed unhighlighted.
  [ChromeEarlGrey
      waitForUIElementToAppearWithMatcher:unhighlightedReviewsButtonMatcher()];
  // Call button should be displayed only if the device has a dial app.
  if ([self dialAppInstalled]) {
    [ChromeEarlGrey
        waitForUIElementToAppearWithMatcher:unhighlightedCallButtonMatcher()];
  } else {
    [[EarlGrey selectElementWithMatcher:unhighlightedCallButtonMatcher()]
        assertWithMatcher:grey_notVisible()];
  }
}

- (BOOL)dialAppInstalled {
  return [[UIApplication sharedApplication]
      canOpenURL:net::NSURLWithGURL(_callURI)];
}

@end