// 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