chromium/ios/testing/earl_grey/app_launch_manager.mm

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

#import "ios/testing/earl_grey/app_launch_manager.h"

#import <XCTest/XCTest.h>

#import "base/command_line.h"
#import "base/ios/crb_protocol_observers.h"
#import "base/strings/sys_string_conversions.h"
#import "base/test/scoped_feature_list.h"
#import "ios/testing/earl_grey/app_launch_argument_generator.h"
#import "ios/testing/earl_grey/app_launch_manager_app_interface.h"
#import "ios/testing/earl_grey/base_earl_grey_test_case_app_interface.h"
#import "ios/testing/earl_grey/coverage_utils.h"
#import "ios/testing/earl_grey/earl_grey_test.h"
#import "ios/third_party/edo/src/Service/Sources/EDOServiceException.h"

namespace {
// Returns the list of extra app launch args from test command line args.
NSArray<NSString*>* ExtraAppArgsFromTestSwitch() {
  if (!base::CommandLine::InitializedForCurrentProcess()) {
    return [NSArray array];
  }
  base::CommandLine* command_line = base::CommandLine::ForCurrentProcess();

  // Multiple extra app launch arguments can be passed through this switch. The
  // args should be in raw format, separated by commas if more than one.
  const char kExtraAppArgsSwitch[] = "extra-app-args";
  if (!command_line->HasSwitch(kExtraAppArgsSwitch)) {
    return [NSArray array];
  }

  return [base::SysUTF8ToNSString(command_line->GetSwitchValueASCII(
      kExtraAppArgsSwitch)) componentsSeparatedByString:@","];
}

// Checks if two pairs of launch arguments are equivalent.
bool LaunchArgumentsAreEqual(NSArray<NSString*>* args1,
                             NSArray<NSString*>* args2) {
  // isEqualToArray will only return true if both arrays are non-nil,
  // so first check if both arrays are empty or nil
  if (!args1.count && !args2.count) {
    return true;
  }

  return [args1 isEqualToArray:args2];
}
}  // namespace

@interface AppLaunchManager ()
// Similar to EG's -backgroundApplication, but with a longer 20 second wait and
// faster 0.5 second poll interval.
- (BOOL)backgroundApplication;
// List of observers to be notified of actions performed by the app launch
// manager.
@property(nonatomic, strong)
    CRBProtocolObservers<AppLaunchManagerObserver>* observers;
@property(nonatomic) XCUIApplication* runningApplication;
@property(nonatomic) int runningApplicationProcessIdentifier;
@property(nonatomic) NSArray<NSString*>* currentLaunchArgs;
@end

@implementation AppLaunchManager

+ (AppLaunchManager*)sharedManager {
  static AppLaunchManager* instance = nil;
  static dispatch_once_t guard;
  dispatch_once(&guard, ^{
    instance = [[AppLaunchManager alloc] initPrivate];
  });
  return instance;
}

- (instancetype)initPrivate {
  self = [super init];

  Protocol* protocol = @protocol(AppLaunchManagerObserver);
  _observers = (id)[CRBProtocolObservers observersWithProtocol:protocol];
  return self;
}

- (BOOL)appIsLaunched {
  return (self.runningApplication != nil) &&
         (self.runningApplication.state != XCUIApplicationStateNotRunning) &&
         (self.runningApplication.state != XCUIApplicationStateUnknown);
}

- (BOOL)appIsRunning {
  return
      [self appIsLaunched] && (self.runningApplication.state !=
                               XCUIApplicationStateRunningBackgroundSuspended);
}

// Makes sure the app has been started with the appropriate |arguments|.
// In EG2, will launch the app if any of the following conditions are met:
// * The app is not running
// * The app is currently running with different arguments.
// * |forceRestart| is YES
// Otherwise, the app will be activated instead of (re)launched.
// Will wait until app is activated or launched, and fail the test if it
// fails to do so.
// In EG1, this method is a no-op.
- (void)ensureAppLaunchedWithArgs:(NSArray<NSString*>*)arguments
                   relaunchPolicy:(RelaunchPolicy)relaunchPolicy {
  BOOL forceRestart = (relaunchPolicy == ForceRelaunchByKilling) ||
                      (relaunchPolicy == ForceRelaunchByCleanShutdown);
  BOOL gracefullyKill = (relaunchPolicy == ForceRelaunchByCleanShutdown);
  BOOL runResets = (relaunchPolicy == NoForceRelaunchAndResetState);

  // If app has crashed it should be relaunched with the proper resets.
  BOOL appIsRunning = [self appIsRunning];

  // App PID change means an unknown relaunch not from AppLaunchManager, so it
  // needs a correct relaunch for setups.
  BOOL appPIDChanged = YES;
  if (appIsRunning) {
    @try {
      appPIDChanged = (self.runningApplicationProcessIdentifier !=
                       [AppLaunchManagerAppInterface processIdentifier]);
    } @catch (NSException* exception) {
      GREYAssertEqual(
          EDOServiceGenericException, exception.name,
          @"Unknown excption caught when communicating to host app: %@",
          exception.reason);
      // An EDOServiceGenericException here comes from the communication between
      // test and app process, which means there should be issues in host app,
      // but it wasn't reflected in XCUIApplicationState.
      // TODO(crbug.com/40687845): Investigate why the exception is thrown.
      appIsRunning = NO;
    }
  }

  // Extend extra app launch args from test switch to arguments.
  arguments =
      [arguments arrayByAddingObjectsFromArray:ExtraAppArgsFromTestSwitch()];

  bool appNeedsLaunching =
      forceRestart || !appIsRunning || appPIDChanged ||
      !LaunchArgumentsAreEqual(arguments, self.currentLaunchArgs);
  if (!appNeedsLaunching) {
    XCTAssertTrue(self.runningApplication.state ==
                  XCUIApplicationStateRunningForeground);
    return;
  }

  if (appIsRunning) {
    if (gracefullyKill) {
      GREYAssertTrue([self backgroundApplication],
                     @"Failed to background application.");

      if (self.runningApplication.state ==
          XCUIApplicationStateRunningBackgroundSuspended) {
        [self.runningApplication terminate];
      } else {
        [BaseEarlGreyTestCaseAppInterface gracefulTerminate];
        if (![self.runningApplication
                waitForState:XCUIApplicationStateNotRunning
                     timeout:5]) {
          [self.runningApplication terminate];
        }
      }
    }

    // No-op if already terminated above.
    [self.runningApplication terminate];

    // Can't use EG conditionals here since the app is terminated.
    XCTAssertTrue([self.runningApplication
        waitForState:XCUIApplicationStateNotRunning
             timeout:15]);
    XCTAssertTrue(self.runningApplication.state ==
                  XCUIApplicationStateNotRunning);
  }

  XCUIApplication* application = [[XCUIApplication alloc] init];
  application.launchArguments = arguments;

  // Instruct EG to not DYLD_INSERT_LIBRARIES, which can interfere with
  // Chromium's framework setup.
  NSMutableDictionary<NSString*, NSString*>* mutableEnv =
      [application.launchEnvironment mutableCopy];
  mutableEnv[@"EG_SKIP_INSERT_LIBRARIES"] = @"YES";
  application.launchEnvironment = [mutableEnv copy];

  @try {
    [application launch];
  } @catch (id exception) {
    XCTAssertFalse(GREYTestApplicationDistantObject.sharedInstance
                       .hostActiveWithAppComponent);
  }

  if (!GREYTestApplicationDistantObject.sharedInstance
           .hostActiveWithAppComponent) {
    NSLog(@"App has crashed on startup");
    self.runningApplication = nil;
    self.runningApplicationProcessIdentifier = -1;
    self.currentLaunchArgs = nil;
    XCTAssertFalse([self appIsLaunched]);
    return;
  }

  [CoverageUtils configureCoverageReportPath];

  if (self.runningApplication) {
    [self.observers appLaunchManagerDidRelaunchApp:self runResets:runResets];
  }

  self.runningApplication = application;
  self.runningApplicationProcessIdentifier =
      [AppLaunchManagerAppInterface processIdentifier];
  self.currentLaunchArgs = arguments;
}

- (void)ensureAppLaunchedWithConfiguration:
    (AppLaunchConfiguration)configuration {
  NSArray<NSString*>* arguments = ArgumentsFromConfiguration(configuration);

  [self ensureAppLaunchedWithArgs:arguments
                   relaunchPolicy:configuration.relaunch_policy];

  if ([self appIsLaunched]) {
    [BaseEarlGreyTestCaseAppInterface enableFastAnimation];

#if !TARGET_IPHONE_SIMULATOR
    if (@available(iOS 17, *)) {
      [BaseEarlGreyTestCaseAppInterface swizzleKeyboardOOP];
    }
#endif

    // Wait for application to settle before continuing on with test.
    GREYWaitForAppToIdle(@"App failed to idle BEFORE test body started.\n\n"
                         @"**** Check that the prior test left the app in a"
                         @"clean state. ****");
  }
}

- (void)ensureAppLaunchedWithFeaturesEnabled:
            (std::vector<base::test::FeatureRef>)featuresEnabled
                                    disabled:
                                        (std::vector<base::test::FeatureRef>)
                                            featuresDisabled
                              relaunchPolicy:(RelaunchPolicy)relaunchPolicy {
  AppLaunchConfiguration config;
  config.features_enabled = std::move(featuresEnabled);
  config.features_disabled = std::move(featuresDisabled);
  config.relaunch_policy = relaunchPolicy;
  [self ensureAppLaunchedWithConfiguration:config];
}

- (void)backgroundAndForegroundApp {
  GREYAssertTrue([self backgroundApplication],
                 @"Failed to background application.");
  [self.runningApplication activate];
}

- (BOOL)backgroundApplication {
  XCUIApplication* currentApplication = [[XCUIApplication alloc] init];
  // Tell the system to background the app.
  [[XCUIDevice sharedDevice] pressButton:XCUIDeviceButtonHome];
  BOOL (^conditionBlock)(void) = ^BOOL {
    return currentApplication.state == XCUIApplicationStateRunningBackground ||
           currentApplication.state ==
               XCUIApplicationStateRunningBackgroundSuspended;
  };
  GREYCondition* condition =
      [GREYCondition conditionWithName:@"check if backgrounded"
                                 block:conditionBlock];
  return [condition waitWithTimeout:20.0 pollInterval:0.5];
}

- (void)addObserver:(id<AppLaunchManagerObserver>)observer {
  [self.observers addObserver:observer];
}

- (void)removeObserver:(id<AppLaunchManagerObserver>)observer {
  [self.observers removeObserver:observer];
}

@end